Skip to content

Networked co-op (LAN + internet) — design

Networked co-op (LAN + internet) — design

Section titled “Networked co-op (LAN + internet) — design”

Status 2026-10-14: Phases 0-3 shipped and Phase 4 (internet relay) is built on branch coop-relay per docs/superpowers/plans/2026-10-14-internet-coop-relay-phase4.md. This spec predates that code and is stale in places: read the plan’s “Spec vs live code” table first (12 rows: message set, RTT measurement, the features gate instead of a tunable, det_probe, the jitter buffer, privacy wording). Current architecture: docs/architecture/coop-and-pilots.md. This document is kept as written below.

Status: proposed (overnight T7, 2026-10-07). Written unattended; every decision Chris has not already made is marked Chris call and collected at the end. Supersedes for the transport layer: 2026-06-30-networked-multiplayer-design.md (M-D “headless Godot dedicated server on hetzner”) — see § Internet. Builds on, and keeps, the 2026-07-03 decision to use input lockstep for LAN (2026-07-03-input-lockstep-multiplayer-design.md, on the unmerged branch). Next tasks that consume this: T8 (lockstep session + loopback test), T9 (dev-only LAN host/join).

Confidence tags: [Certain] checked in code or a primary source today; [Likely] strong inference; [Guessing] needs a measurement.

  1. Use host-scheduled input lockstep, not state snapshots, for both LAN and internet. The sim is already deterministic and seeded; the only thing that crosses the wire is a tiny per-tick row of every pilot’s quantized InputState plus tick-stamped commands. Payload does not grow with enemy count, which closes the MTU bug class the July branch hit. [Certain] that the July branch reached this conclusion and proved the invariant in tests; [Likely] it is right for 2–4 pilots.
  2. The real work is not the wire, it is the command stream. main.gd mutates the sim outside tick() in dozens of places: level-up roll + pick, Drone Bay, dimension choice, DevFlag mirrors every frame, live console tunables, dev commands (a rough grep finds ~90 sim. call or assignment sites in main.gd + ui/, a large share of them writes). Every one of those must become a tick-stamped SimCommand before two sims can stay identical. Doing that first, with no networking, also makes replays exact through level-ups (today they cannot be: ReplayRecorder records inputs and config patches only). [Certain]
  3. No rollback. A sim tick costs 2.3–4.4 ms on the Apple TV (perf review 2026-09-29), so re-simulating 4–6 ticks per frame plus a full-state save every tick does not fit a 16.7 ms frame. [Certain] for the arithmetic, [Likely] for the conclusion; a sim_bench measurement settles it.
  4. LAN: ENet unicast. Internet: a Cloudflare Durable Object WebSocket relay, one DO per room, same protocol bytes as LAN. Roughly $0.003 per 20-minute 2-player match beyond the included quota (§ Internet). This replaces the hetzner dedicated-server plan: no box to run, and the web demo can join (browsers have no ENet).
  5. Biggest unknown: cross-platform float determinism. Same seed and inputs are only proven to give the same checksum on one machine. Apple arm64 devices probably agree with each other; web (WASM), Android and Intel Macs may not. Phase 0 measures it on real devices before anything else.
  6. iOS UDP broadcast discovery needs Apple’s multicast entitlement (an approval request), so T9’s “UDP broadcast discovery” works Mac↔Mac / Android / tvOS-host but not for an iPhone without it. Manual IP stays the fallback; relay-assisted discovery (Phase 4) removes the need entirely.
  7. tick() supports 2 pilots today. Networked play is capped at 2 until the 4-pilot plan (docs/superpowers/plans/2026-10-05-local-coop-4-pilots.md, written, not executed) lands.

Current state (verified 2026-10-07 on overnight-2026-10-07)

Section titled “Current state (verified 2026-10-07 on overnight-2026-10-07)”
  • Sim.tick(inputs: Array[InputState]) is canonical, but it reads only inputs[0] and inputs[1] (_pilot_inputs := [input, input2]), and integrate() is hard-coded to player / player2. A 3rd pilot ticks weapons/dash/regen but never moves. [Certain] (sim/sim.gd tick()).
  • add_pilot(offset) has no cap; main._can_p2_join caps at 2. [Certain]
  • InputState = move_dir: Vector2, aim_dir: Vector2, decoy: bool (edge), dash: bool (edge). [Certain]
  • Four RNG streams on Sim (rng, upgrade_rng, drone_rng, player_rng), each a SeededRng wrapping a private RandomNumberGenerator with no state accessor on this branch. The July branch added get_state()/set_state(). [Certain]
  • state_checksum() covers pilot pos/hp/xp/level, enemy pos/hp/aura/stacks, projectiles, enemy projectiles, gems and drones. It does not cover RNG stream positions, boss state machines, the overnight T2–T5 state (tackler state/timers, stasis_timer, suppressor/heal state), run_time, kills or mods. It is also what the pinned seed-1234 baselines hash, so it must not change. [Certain]
  • 667 static var tunables live in /sim (remote config / live console). Both peers must hold identical values or they desync. [Certain] (count by grep)
  • DevFlag sim mirrors (tackler_spawns, suppressor_spawns, web_drone_grant, hard_tuning) are written by main._physics_process every frame, outside tick(). [Certain] (main.gd)
  • Meta setup: MetaRunSetup.apply_stats applies meta upgrades and the hull perk to sim.player only; a co-op P2 copies P1’s locked weapons and hull stats. In networked play each device has its own save, so per-pilot setup must be exchanged. [Certain] (sim/meta_run_setup.gd, main.gd)
  • Level-ups pause the whole sim (main._paused_for_levelup); in local co-op the panels chain P1 first. roll_upgrade_choices (which draws upgrade_rng) is called from main.gd when the panel opens, i.e. outside tick(). [Certain]
  • net/ on this branch is telemetry, remote config and the Remote Console poller (HTTP + D1). No multiplayer code, no Durable Objects anywhere. [Certain]
  • origin/networked-multiplayer-mb-mc (54 commits, last 2026-07-03, ~1,770 commits behind main, unmerged): net/mp_host.gd, mp_client.gd, mp_protocol.gd, mp_tick_buffer.gd, ui/mp_lobby.gd, and export_full_state()/import_full_state() across Sim, pools, weapons, bosses, drones, mods (~5.6k lines with tests). It shipped snapshot-based LAN co-op to an Apple TV + iPhone as BUILD 139–143, then reworked to input lockstep with a full-state join bootstrap. [Certain]
  • The tvOS export template (~/Claude/godot-tvos, built 2026-09-29) contains ENetMultiplayerPeer, WebSocketPeer, PacketPeerUDP and WebRTCPeerConnection. [Certain] (strings on the .a)
  • No NSLocalNetworkUsageDescription / NSBonjourServices in either export_presets.cfg. BUILD 141 added it to the old tvOS shell, which no longer exists. [Certain]
  • Android presets already have permissions/internet=true. [Certain]

Lessons the July branch paid for (carry forward, do not rediscover)

Section titled “Lessons the July branch paid for (carry forward, do not rediscover)”
  • A full-state snapshot was 1,610 bytes even near-empty, above ENet’s 1,392-byte MTU; real ones are far larger. → Lockstep. [Certain]
  • A join bootstrap that transfers “most” state is worse than none: RNG positions, pool columns and _next_entity_id were missing, so the client diverged on the first spawn. → Start sessions from tick 0 (both sims built from the same seed), and treat mid-run join as a later, separate phase.
  • Godot @rpc: the CALLING script must declare a matching @rpc stub for every method it rpc_id()s (godotengine/godot#57869), or sends fail with “Unable to get the RPC configuration”. → Do not use @rpc at all; send raw packets through a transport interface (below).
  • CLI user args need a literal --: godot --path . -- --mp-host.
  • Telemetry, kills_by_type, peak_enemies and similar are device-local and need no sync.
A. Host-authoritative snapshots + prediction B. Symmetric delay lockstep C. Host-scheduled input lockstep (recommended) D. Rollback (GGPO-style)
On the wire entity state, grows with enemy count every peer’s input, per tick host broadcasts the final input row per tick; clients send their input to the host inputs, per tick
Who simulates host; clients render + predict own ship everyone everyone everyone, re-simulating on late input
Host feel 0 latency D ticks of delay 0 latency 0 latency
Client feel ~0 with prediction, corrections visible D ticks of delay one RTT plus a 1–2 tick jitter buffer ~0, with rollback pops
One slow peer only that peer suffers everyone stalls only that client trails; host never waits for clients everyone resims more
Determinism needed no yes yes yes, plus fast save/load
Bandwidth high (MTU risk, hit in July) tiny tiny tiny
Cost on our sim serializers for every pool/boss/weapon (5.6k lines in July) low low save state every tick + 4–6 resims per frame at 2.3–4.4 ms/tick on ATV: does not fit

Recommendation: C. It is what the July branch converged on after trying A on real devices. The host owns the canonical tick→inputs table, so client input timing can never cause a desync (the host decides which tick an input lands on, and everyone applies the host’s row). The only desync sources left are non-determinism in the sim itself and a missing command, both caught by the checksum exchange. A client hiccup never freezes the host, which matters once there are 3–4 pilots.

The cost of C is client input latency of about one round trip. On home Wi-Fi that is 1–3 ticks [Likely]; through a relay 3–8 ticks (40–130 ms) [Guessing]. For a Vampire-Survivors-paced game that is playable; it would not be for a fighting game. If it feels bad, the cheap fix is render-only prediction of the client’s own ship (advance a scratch PlayerState.integrate() copy from local input, draw the ship there, never feed it back into the sim). That is cosmetic and cannot desync. Full rollback (D) stays rejected unless sim_bench shows the tick has become several times cheaper.

Chris call — this departs from two earlier locked decisions. 2026-06-30 chose host authority + prediction (A) with a hetzner dedicated server for online; 2026-07-03 chose lockstep for LAN only and suggested rollback for online (memory game_multiplayer_netcode_comparison). This spec proposes C for both LAN and internet. Why: A’s snapshot path failed on real hardware (MTU), a dedicated GDScript server is a box to run and pay for, and rollback does not fit the tick budget. Risk in C: the joining player feels the round trip on a slow connection.

1. The command stream (Phase 1, no networking)

Section titled “1. The command stream (Phase 1, no networking)”

All sim mutations other than tick(inputs) go through one queue:

SimCommand = { tick: int, pilot: int, kind: String, args: Dictionary }
CommandApplier.apply(sim, cmds_for_tick) # called immediately BEFORE sim.tick() for that tick
  • Kinds needed for Survival co-op, taken from main.gd’s current direct calls: levelup_roll (draws upgrade_rng for one pilot), levelup_pick, drone_bay_deploy, drone_loadout, dimension_pick, flags (the four DevFlag mirrors, sent only on change instead of written every frame), config_patch (live console tunables / bible overlay), pilot_join (tick-0 only in Phases 1–3), pilot_leave (sets hp 0, as the July branch did), and the dev commands that touch sim state (dev_*). Story/tutorial modes are out of scope (network sessions force Survival, as July did).
  • Order inside a tick is the order the host appended them. Applying them happens in exactly one place so a single-player run and a networked run share the code path.
  • Single-player keeps working by sending its own commands through the same queue with tick = next tick. The determinism baselines do not move, because the 600-tick baseline runs never issue a command, and the DevFlag mirrors write the same default values. [Likely]; T8/Phase 1 must re-run all three determinism files to prove it.
  • ReplayRecorder records the command list next to the input rows; ReplayLoader replays it. A replay through level-ups then reproduces exactly. This is the Phase 1 acceptance test: drive a real autopilot run with level-ups and a Drone Bay deploy, record it, replay it into a fresh Sim, and assert a match every 60 ticks. Lockstep is that same test with a socket in the middle.
  • Level-up flow in a session: the sim pauses for everyone when any pilot has pending_levelups > 0 (as single-player and local co-op do today). The host issues levelup_roll(pilot); every peer applies it, so every peer holds the same three choices. Only the owning device shows the panel; its pick goes to the host, which stamps levelup_pick into the next tick. Other devices show “P2 is choosing…”. Chris call: pause-all (proposed) vs keep-fighting while one pilot chooses (the 2026-07-02 spec’s idea; harsh on a phone mid-swarm). Proposed addition: a 15 s auto-pick (highest panel score, the autopilot’s existing rule) so one idle player cannot stall the run.

Before tick 0 the host sends, and every client must match exactly, or the join is refused with a plain message:

  • PROTOCOL_VERSION and Sim_Const.BUILD (sim code must be identical; a Dev build cannot join a store build).
  • Hash of the effective content (bible + overlay) and of every registered tunable’s current value (net/tunables_registry.gd already enumerates them).
  • Seed, mode (crystals), DevFlag sim mirrors, dimension.
  • Per pilot: hull id, meta upgrade levels, locked weapons. Each device sends its own; the host applies all of them at tick 0 through a per-pilot MetaRunSetup.apply_stats(sim, meta, ship, pilot) (today it only targets sim.player; that is a prerequisite change). Chris call: each pilot brings their own shop progress (proposed) vs everyone uses the host’s save. Gold: proposed that each device banks the shared run_gold into its own save at run end.
  • New Sim.net_checksum() -> int, separate from state_checksum() so the pinned baselines are untouched: state_checksum() plus the four RNG states (RandomNumberGenerator.state), run_time, kills, boss state-machine fields, and the T2–T5 fields the overnight reports flagged as missing (tackler state + tackle_timer, stasis_timer, suppressor/heal multiplier inputs).
  • The host sends its net_checksum every 30 ticks (0.5 s) inside the input row; each client compares at the same tick. On mismatch: a dev banner, an EventQueue.push("net_desync", {tick, build, platforms}) event, and a dump of snapshot_string() on both sides for diffing. Phases 2–4 then end the session (“Connection out of sync”); Phase 5 can resync from a full state transfer instead.
  • A test perturbs one sim (nudges an enemy) and asserts detection within 30 ticks (T8 spec).

net/ only; /sim gains nothing network-related (only net_checksum, the per-pilot tick loop from the 4-pilot plan, and the command applier, all pure).

class_name NetTransport extends RefCounted # one interface, three implementations
func send(peer: int, bytes: PackedByteArray, reliable: bool) -> void
func poll() -> Array # [{peer, bytes}], drains received packets
func peers() -> PackedInt32Array
signal peer_joined(peer: int) / peer_left(peer: int)
  • LoopbackTransport: in-memory pair with optional injected delay/reorder/drop, for GUT (T8).
  • ENetTransport: ENetMultiplayerPeer used as a raw PacketPeer (put_packet with transfer_mode/transfer_channel), not assigned to SceneTree.multiplayer and with no @rpc. [Likely] this works headless; T8 verifies with a localhost test.
  • RelayTransport: WebSocketPeer to the room DO (Phase 4). Works on web.

Messages (binary, little-endian, StreamPeerBuffer):

msg dir content channel
hello c→h protocol, BUILD, config hashes, per-pilot setup reliable
welcome / reject h→c assigned pilot index, seed, setup of all pilots, start tick reliable
input c→h client tick, quantized input unreliable-sequenced, sent every tick, last 3 ticks repeated for loss
row h→c tick, every pilot’s quantized input, commands for this tick, checksum every 30th reliable, ordered
cmd_req c→h a level-up pick / Drone Bay request reliable
bye both reason reliable
  • Quantize inputs before they reach any sim, including the local one. move_dir → angle byte + zero flag (1.4° resolution; move_dir is length 0 or 1 by contract), aim_dir → two int8, dash/decoy → bits. Both host and client dequantize the same bytes, so the float values are bit-identical everywhere. A row for 2 pilots is under 20 bytes plus header.
  • Client tick pacing: the client ticks when row T is present. It keeps a jitter buffer of 1–2 rows: run 2 ticks in a frame when more than 3 rows are queued, 0 when none. Never skip or drop a row (July’s MpTickBuffer logic; port it). Built 2026-10-07 (LockstepSession, after a Dev 271 Mac→ATV run stalled 108/600 frames with no cushion): the client waits for JITTER_ROWS + 1 queued rows before its first tick, holds JITTER_ROWS (default 2, a local tunable, not hashed into the handshake) in reserve, catches up 2/frame above that, re-arms the prefill when it runs dry, and applies hold rows at once: a hold row at the head drains before the cushion check, and a hold row BEHIND queued tick rows primes the client (the host only sends holds while paused, so no more rows are coming and the roll must not wait on a cushion that never fills). stalls / pause_waits / cushion_waits go out in the lan_run telemetry event, once per session when it ends (run_end or session end; see docs/architecture/dev-tools.md § Telemetry v2).
  • Host input handling: a client input for tick T that arrives late is applied at the host’s current tick; missing input repeats the last one with dash/decoy cleared (edges never repeat).
  • Pauses: the host simply stops issuing rows (level-up, pause menu). A pause flag in the row lets clients show who paused. Chris call: any player can pause everyone (proposed, co-op) vs only the host.
  • Timeouts: a client silent for 5 s is dropped (pilot_leave, hp 0); a host silent for 5 s ends the client’s session with “Host disconnected”. iOS kills sockets in the background, so backgrounding = leaving in Phases 2–4.
Platform Unicast LAN connect UDP broadcast discovery Prompt / keys
iOS / iPadOS allowed after the Local Network prompt needs com.apple.developer.networking.multicast, an entitlement Apple must approve NSLocalNetworkUsageDescription required; NSBonjourServices only if Bonjour is used
tvOS allowed, no prompt [Guessing] — TN3179 says tvOS has no local network privacy and words the multicast rule as an iOS restriction; test on the ATV none required for privacy
macOS 15+ allowed after the prompt allowed, no entitlement (“isn’t required on macOS”) prompt applies to the Mac build
Android allowed (internet permission present) allowed; reliable receipt may need a Wi-Fi MulticastLock + CHANGE_WIFI_MULTICAST_STATE, which Godot does not take [Likely] none
Web impossible (no UDP/ENet in browsers) impossible relay only

Sources: Apple TN3179 Understanding local network privacy (platform table: iOS/iPadOS/visionOS yes, macOS from 15, tvOS/watchOS no; multicast/broadcast send + receive require the entitlement on iOS; “The multicast entitlement isn’t required on macOS”); Apple Local Network Privacy FAQ-3.

Consequences:

  • T9 (tonight): host with UDP broadcast beacons on Mac/ATV/Android and accept manual IP everywhere. The host shows its LAN IP large on screen (the July pause-menu readout). The iPhone joins by typing the IP (the July lobby). Add NSLocalNetworkUsageDescription (e.g. “Dark Cosmos uses your local network to play co-op with nearby devices.”) to the iOS presets only when T9 actually ships a join path; platform/tvos/export_presets.cfg is off-limits overnight, so T9 documents the exact key for Chris instead.
  • Without the entitlement, the iPhone cannot hear broadcasts. Options: (a) apply for the multicast entitlement now (an Apple form; approval takes days–weeks [Guessing]); (b) Bonjour with a declared NSBonjourServices type (_darkcosmos._udp), which needs no entitlement but needs native code (Godot has no Bonjour API; a small extension like DcHaptics); (c) relay-assisted discovery (Phase 4): both devices register with the room DO; devices behind the same public IP are offered each other’s private IP, then connect over LAN. (c) needs no native code and also gives room codes. Recommended: (c), plus apply for the entitlement in parallel as a cheap hedge. Chris call.
  • The ATV is the natural host: no prompt, often wired, and typing an IP on a TV is painful.

6. Internet: Cloudflare Durable Object relay

Section titled “6. Internet: Cloudflare Durable Object relay”
  • Worker route wss://…/mp/room/<code> → env.ROOM.idFromName(code) → one DO per room. Fits the existing CF stack (telemetry + control Workers). The DO is a dumb, ordered fan-out: it knows room membership, which peer is host, a size cap per message, and a rate cap. It does not simulate (the sim is GDScript and cannot run in a DO, as the 06-30 spec noted). Same message bytes as LAN.
  • Use the WebSocket Hibernation API so idle lobbies cost nothing. It will not save anything during a match: 60 Hz traffic keeps the DO awake.
  • Room codes: 5 characters from an unambiguous alphabet, shown big on the host. No accounts, no secrets in the client (the web demo already exposes its dev key). Per-IP connect rate limit.
  • Cost (published DO pricing, Workers Paid; incoming WebSocket messages billed 20:1, outgoing free, duration billed at 128 MB while active) [Certain] for the rates, arithmetic below:
    • 2-player, 20-minute match: host 60 rows/s + client 60 inputs/s = 144,000 incoming messages = 7,200 billed requests; duration 1,200 s × 0.125 GB = 150 GB-s.
    • Included per month: 1 M requests ≈ 138 matches; 400,000 GB-s ≈ 2,660 matches.
    • Beyond that: about $0.0011 (requests) + $0.0019 (duration) ≈ $0.003 per match. Sending rows and inputs at 30 Hz (2 ticks per message) halves the request part.
  • Latency: the DO lives near where it was first created (a locationHint can steer it). Client RTT = client→DO→host→DO→client. [Guessing] 30–80 ms for two UK home connections; cellular more. Measure in Phase 4 before deciding anything else.
  • TCP head-of-line blocking on a lossy link shows up as short stalls. Lockstep needs reliable delivery anyway, so this is acceptable; the 3-tick input redundancy is not needed on this path.
  • Privacy: the relay forwards gameplay inputs and sees IPs (as the telemetry Worker already does). If it stores nothing, the privacy page and ASC label are unchanged; if it logs anything (room stats, desync events), update both per CLAUDE.md. Desync events go through the existing EventQueue, which is already covered.

WebRTC P2P (rejected for now): lower latency and unreliable channels, but native platforms need the webrtc-native GDExtension, whose README lists Windows, Linux, macOS, Android and iOS, not tvOS [Certain]; the ATV would need a port into our fork. TURN for symmetric NATs would come from Cloudflare Realtime ($0.05/GB after 1,000 GB free) [Certain]. Revisit only if relay latency is the measured problem.

Off-the-shelf addons: netfox (client-server, prediction, interpolation, netfox.noray NAT relay) and godot-rollback-netcode (P2P rollback, Godot 4 alpha) both assume Node-property state or per-node save/load; our state is flat PackedArray pools in a RefCounted sim. Their useful parts (tick buffer, hash mismatch detection) are ~100 lines we already wrote in July. [Likely] not worth the dependency.

/sim uses GDScript floats (double) and engine Vector2 math (float32 real_t) plus sin, cos, atan2, pow, exp in 26 files [Certain]. Those come from each platform’s libm and compiler (FMA contraction). [Likely]: Apple arm64 devices (iPhone, iPad, ATV, Apple-silicon Mac) agree because they share libm and compiler; Android (bionic), web (WASM, no FMA) and Intel Macs may differ in the last bit, which lockstep amplifies into a desync within seconds.

Measure before building on it: a dev command net_determinism_probe that runs the existing tools/sim_bench scenario (fixed seed, scripted inputs, invulnerable, 3,600 ticks with bosses) on the device and reports state_checksum() + net_checksum() every 600 ticks to the Remote Console status. Run it on Mac, iPhone, iPad, ATV, Android and web. Outcomes:

  • All equal → cross-play everywhere.
  • Apple-only equal → restrict sessions to matching platform_family in hello; fix the rest later (replace sin/cos in /sim with a deterministic table or polynomial, which would re-pin baselines via bh-determinism-repin — Chris call if it comes to that).

Mostly from the July M-C design, still valid: local_pilot_index drives camera, HUD, level-up panel, haptics routing and dynamic zoom (T1 already picks the fastest pilot in co-op; in a session it should follow the local pilot). Pilot tint per index (cyan/amber/violet/green — the 4-pilot plan adds tints too). The other device’s ship and its effects render from the shared sim like any entity. Chris call: keep the 4-pilot plan’s soft tether in networked play (proposed, keeps spawn rings and perf assumptions intact) vs let networked pilots roam apart (each device has its own camera).

Phase What Network? Done when
0. Determinism groundwork net_checksum(); SeededRng get/set state; net_determinism_probe dev command; run it on every device. Execute the 4-pilot plan (unifies integrate() into the pilots loop, MAX_LOCAL_PILOTS = 4). no probe table filled in; baselines unmoved
1. Command stream SimCommand + CommandApplier; route every out-of-tick mutation through it (audit list first); DevFlag mirrors on change; per-pilot MetaRunSetup; replay records commands no replay with level-ups + Drone Bay matches every 60 ticks; baselines unmoved
2. Lockstep session (T8) NetTransport + Loopback + ENet; LockstepSession host/client; quantized inputs; checksum exchange; stall/timeout loopback + localhost two sims through loopback stay equal for thousands of ticks; a perturbed one is detected
3. Dev LAN co-op (T9) Debug Settings Host/Join (dev builds only, DevFlags), broadcast beacon + manual IP, local_pilot_index camera/HUD, level-up pause-all flow, plist key documented LAN two Macs, then ATV + iPhone, play a run to game over without desync
4. Internet relay CF Worker + room DO; RelayTransport; room codes; relay-assisted LAN discovery; web demo can join; version/config refusal UX internet ATV at home + iPhone on cellular finish a run; RTT and stall counts measured
5. Robustness full-state export/import (port from the July branch, behind the completeness test), mid-run join, reconnect, resync after desync; 3–4 networked pilots; render-only own-ship prediction if latency feels bad both reconnect after backgrounding works; 4 pilots on LAN
6. (optional) WebRTC P2P or rollback, only if Phase 4 measurements demand it — —

Phases 0 and 1 have standalone value (exact replays, cross-device determinism knowledge) even if networking stops there.

  • Every phase keeps the three determinism files green and unmoved.
  • Phase 1: replay round-trip test through the real main-less path (autopilot drives inputs, a level-up pick and a Drone Bay deploy go through CommandApplier, never hand-set).
  • Phase 2: loopback with injected latency/jitter/reorder/drop; perturbation detection; headless ENet localhost pair (127.0.0.1) if feasible in GUT.
  • Phase 3+: two-process Mac loop (godot --path . -- --mp-host / -- --mp-join=127.0.0.1), then devices.
  • Relay: node --test for the Worker/DO (message cap, rate cap, membership, host leave).
  • A missed out-of-tick mutation desyncs silently until the checksum notices. Mitigation: a test that greps main.gd/ui/ for sim. writes and method calls not on an allow-list (structural, not memory), plus the replay round-trip test.
  • Platform float divergence (§7). Measured in Phase 0 before anything depends on it.
  • Tunable drift mid-run: a live console edit on one device only. The session forbids local console edits on clients; the host’s edits become config_patch commands.
  • Host pause = everyone paused (and iOS backgrounding drops the session) — accepted for co-op.
  • Lockstep past ~8 pilots gets heavy (rows and sim cost grow with pilots); the 32-player idea in memory bullet-heaven-multiplayer-scaling-strategy is a different mode, not this one.
  • Old branch rot: do not merge networked-multiplayer-mb-mc. Port pieces (tick buffer, lobby UI, export/import for Phase 5) by hand against current main, with their tests.
  1. Host-scheduled lockstep (C) for LAN and internet, replacing snapshots + hetzner server (06-30) and rollback-for-online (07-03). Proposed: yes.
  2. Internet via a Cloudflare DO relay, ~$0.003 per 20-min 2-player match beyond the included quota. Proposed: yes.
  3. Level-up in a session pauses everyone, with a 15 s auto-pick. Proposed: yes.
  4. Each pilot brings their own save’s meta upgrades and hull; each device banks run_gold. Proposed: yes.
  5. Any player can pause everyone. Proposed: yes.
  6. Discovery: relay-assisted (Phase 4) + apply for the iOS multicast entitlement now as a hedge; manual IP until then.
  7. Soft tether stays on in networked play. Proposed: yes.
  8. If devices disagree on floats: Apple-only cross-play first; a deterministic-trig rewrite (which re-pins baselines) only if non-Apple cross-play matters.
  9. Network cap = 2 pilots until the 4-pilot plan lands, then 4.