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-relayperdocs/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, thefeaturesgate 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.
- 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
InputStateplus 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. - The real work is not the wire, it is the command stream.
main.gdmutates the sim outsidetick()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 ~90sim.call or assignment sites inmain.gd+ui/, a large share of them writes). Every one of those must become a tick-stampedSimCommandbefore two sims can stay identical. Doing that first, with no networking, also makes replays exact through level-ups (today they cannot be:ReplayRecorderrecords inputs and config patches only). [Certain] - 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.
- 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).
- 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.
- 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.
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 onlyinputs[0]andinputs[1](_pilot_inputs := [input, input2]), andintegrate()is hard-coded toplayer/player2. A 3rd pilot ticks weapons/dash/regen but never moves. [Certain] (sim/sim.gdtick()).add_pilot(offset)has no cap;main._can_p2_joincaps 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 aSeededRngwrapping a privateRandomNumberGeneratorwith no state accessor on this branch. The July branch addedget_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 vartunables 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 bymain._physics_processevery frame, outsidetick(). [Certain] (main.gd) - Meta setup:
MetaRunSetup.apply_statsapplies meta upgrades and the hull perk tosim.playeronly; 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 drawsupgrade_rng) is called frommain.gdwhen the panel opens, i.e. outsidetick(). [Certain]
Networking that exists
Section titled “Networking that exists”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, andexport_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) containsENetMultiplayerPeer,WebSocketPeer,PacketPeerUDPandWebRTCPeerConnection. [Certain] (stringson the .a) - No
NSLocalNetworkUsageDescription/NSBonjourServicesin eitherexport_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_idwere 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@rpcstub for every method itrpc_id()s (godotengine/godot#57869), or sends fail with “Unable to get the RPC configuration”. → Do not use@rpcat all; send raw packets through a transport interface (below). - CLI user args need a literal
--:godot --path . -- --mp-host. - Telemetry,
kills_by_type,peak_enemiesand similar are device-local and need no sync.
Architecture options
Section titled “Architecture options”| 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.
Design
Section titled “Design”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(drawsupgrade_rngfor 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. ReplayRecorderrecords the command list next to the input rows;ReplayLoaderreplays 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 freshSim, 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 issueslevelup_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 stampslevelup_pickinto 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.
2. Session config parity
Section titled “2. Session config parity”Before tick 0 the host sends, and every client must match exactly, or the join is refused with a plain message:
PROTOCOL_VERSIONandSim_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.gdalready 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 targetssim.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 sharedrun_goldinto its own save at run end.
3. Desync detection
Section titled “3. Desync detection”- New
Sim.net_checksum() -> int, separate fromstate_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_checksumevery 30 ticks (0.5 s) inside the input row; each client compares at the same tick. On mismatch: a dev banner, anEventQueue.push("net_desync", {tick, build, platforms})event, and a dump ofsnapshot_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).
4. Transport and protocol
Section titled “4. Transport and protocol”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 implementationsfunc send(peer: int, bytes: PackedByteArray, reliable: bool) -> voidfunc poll() -> Array # [{peer, bytes}], drains received packetsfunc peers() -> PackedInt32Arraysignal peer_joined(peer: int) / peer_left(peer: int)LoopbackTransport: in-memory pair with optional injected delay/reorder/drop, for GUT (T8).ENetTransport:ENetMultiplayerPeerused as a rawPacketPeer(put_packetwithtransfer_mode/transfer_channel), not assigned toSceneTree.multiplayerand with no@rpc. [Likely] this works headless; T8 verifies with a localhost test.RelayTransport:WebSocketPeerto 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_diris 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
MpTickBufferlogic; port it). Built 2026-10-07 (LockstepSession, after a Dev 271 Mac→ATV run stalled 108/600 frames with no cushion): the client waits forJITTER_ROWS+ 1 queued rows before its first tick, holdsJITTER_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_waitsgo out in thelan_runtelemetry 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
pauseflag 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.
5. LAN discovery and platform constraints
Section titled “5. LAN discovery and platform constraints”| 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.cfgis 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
NSBonjourServicestype (_darkcosmos._udp), which needs no entitlement but needs native code (Godot has no Bonjour API; a small extension likeDcHaptics); (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
locationHintcan 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.
7. Cross-platform determinism (Phase 0)
Section titled “7. Cross-platform determinism (Phase 0)”/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_familyinhello; fix the rest later (replacesin/cosin/simwith a deterministic table or polynomial, which would re-pin baselines viabh-determinism-repin— Chris call if it comes to that).
8. Per-device view and render
Section titled “8. Per-device view and render”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).
Phases
Section titled “Phases”| 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.
Testing
Section titled “Testing”- 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 throughCommandApplier, 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 --testfor 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/forsim.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_patchcommands. - 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-strategyis 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.
Chris calls (collected)
Section titled “Chris calls (collected)”- Host-scheduled lockstep (C) for LAN and internet, replacing snapshots + hetzner server (06-30) and rollback-for-online (07-03). Proposed: yes.
- Internet via a Cloudflare DO relay, ~$0.003 per 20-min 2-player match beyond the included quota. Proposed: yes.
- Level-up in a session pauses everyone, with a 15 s auto-pick. Proposed: yes.
- Each pilot brings their own save’s meta upgrades and hull; each device banks
run_gold. Proposed: yes. - Any player can pause everyone. Proposed: yes.
- Discovery: relay-assisted (Phase 4) + apply for the iOS multicast entitlement now as a hedge; manual IP until then.
- Soft tether stays on in networked play. Proposed: yes.
- 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.
- Network cap = 2 pilots until the 4-pilot plan lands, then 4.
Sources
Section titled “Sources”- Apple, TN3179 Understanding local network privacy: https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy
- Apple Developer Forums, Local Network Privacy FAQ-3 (multicast entitlement table): https://developer.apple.com/forums/thread/663875
- Cloudflare Durable Objects pricing (WebSocket 20:1 billing, 128 MB duration, hibernation): https://developers.cloudflare.com/durable-objects/platform/pricing/
- Cloudflare Realtime pricing (TURN $0.05/GB, 1,000 GB free): https://developers.cloudflare.com/realtime/pricing/
- godotengine/webrtc-native README (platforms: windows, linux, osx, android, ios): https://github.com/godotengine/webrtc-native
- netfox: https://github.com/foxssake/netfox ; Godot Rollback Netcode (Godot 4): https://godotengine.org/asset-library/asset/2450
- In-repo:
docs/research/2026-09-29-general-performance-review.md(ATV tick cost),docs/architecture/coop-and-pilots.md,docs/superpowers/specs/2026-10-05-local-coop-4-pilots-design.md, branchorigin/networked-multiplayer-mb-mc(July specs + code).