Networked Multiplayer — M-B + M-C design (LAN, 2 players)
Networked Multiplayer — M-B + M-C design (LAN, 2 players)
Section titled “Networked Multiplayer — M-B + M-C design (LAN, 2 players)”Status: approved, ready for planning.
Date: 2026-07-02
Parent spec: docs/superpowers/specs/2026-06-30-networked-multiplayer-design.md — this document is the
implementation-level design for that spec’s M-B (LAN transport) and M-C (per-device view + lobby
UX) milestones, built together per the parent spec’s own recommended build order (“first playable:
iPhone joins ATV, both fly anywhere”). M-A (multi-pilot sim foundation) is DONE on main
(docs/superpowers/plans/2026-07-02-multiplayer-sim-foundation-m-a.md) and is the foundation this
builds on.
One device hosts a normal survival run; a second device joins over local WiFi and flies its own full pilot in the same shared world — own camera, own HP, own XP/level/weapon build, own dash + drone. The concrete target: an iPhone joins an Apple TV, both fly anywhere. Reached by trigger scenario: Chris picks up his iPhone, opens the game, joins his Apple TV’s game over WiFi, and plays alongside it.
This cycle is scoped to exactly 2 players (1 host + 1 joining client) over LAN. Online play via a dedicated server (M-D) and 3+ networked players are later work — see Non-goals.
Why now (context)
Section titled “Why now (context)”Originally this milestone was planned for post-v0.1-launch (parent spec: “parked — NOT in the first App
Store release”). Chris is currently blocked on other v0.1 items and wants to use this window to see how
networked play actually feels, rather than wait. main.V01_LOCK_COOP stays true throughout — this
work is gated off from the shipping build exactly like the rest of co-op, so it carries no launch risk.
Decisions (locked 2026-07-02, with Chris, via brainstorming)
Section titled “Decisions (locked 2026-07-02, with Chris, via brainstorming)”- Isolation: built in a dedicated git worktree/branch, merged back to main once proven — not developed directly on main, since main may receive concurrent v0.1 fixes during this cycle.
- Scope: M-B (transport) and M-C (per-device UX) together in one cycle — the goal is to actually play it, not just prove the wire protocol.
- Discovery: type-the-host’s-IP for this cycle. UDP auto-discovery (“see a list of hosts”) is a deferred, separate follow-on.
- Couch co-op: left exactly as it is today (already fixed for the dash/drone bug by M-A Task 11; its camera model is not touched by this cycle).
- Prediction model: lightweight local-only prediction (see Architecture), not full deterministic resimulation. LAN round-trip times (~1-5ms) make chess-moba’s full rewind-and-replay pattern (buffered inputs + resimulate-from-snapshot) unnecessary complexity for this cycle; it remains the natural upgrade path for M-D (online/dedicated server), where real internet latency will make it worth the cost.
- Test loop: automated GUT tests for the pure protocol/serialization logic, a local dual-instance
dev loop (two
godot --path .processes over127.0.0.1) for day-to-day iteration, and real hardware (Apple TV + iPhone over WiFi) as the final verification pass per chunk — not the primary iteration loop.
Architecture
Section titled “Architecture”/sim stays pure — no ENetMultiplayerPeer/RPC code enters it, unchanged from every prior milestone.
Two new render-side Node scripts alongside the existing net/telemetry.gd / net/control_client.gd:
net/mp_host.gd— runs on the hosting device. Owns the real, authoritativeSimexactly as single-player/local-co-op does today. Each physics tick it merges the latest input received from the network into theinputsarray passed tosim.tick(inputs)— the network client’s input arrives at the same seam a second local controller’s input already does. Every 3rd tick (~20Hz) it broadcasts a full-state snapshot to the connected client.net/mp_client.gd— runs on the joining device. Does not callsim.tick()at all.
The key move: on the client, Sim becomes a receive buffer, not a simulation. The client still
holds a real Sim instance, because every existing renderer (SwarmRenderer, ArchetypeRenderer,
HUD, weapon dock, dialogue, everything) already reads a Sim’s state read-only, per the project’s
one-way-data-flow rule (Input → Sim → Render). Rather than inventing a parallel client-side data
model, mp_client.gd deserializes each incoming host snapshot directly into the same EntityPool
arrays the sim already exposes (enemy positions/types/auras, projectiles, gems, both PlayerStates).
No renderer code changes on the client — a renderer cannot tell the difference between “this Sim just
ticked” and “this Sim’s arrays were just overwritten from the network.”
The only genuinely new client-side logic is local-only prediction for the client’s own pilot: each
render frame, before the next snapshot arrives, mp_client.gd advances its own ship’s predicted
position using PlayerState.integrate() — the same pure /sim method the host’s real tick uses, called
directly (it’s plain /sim code, legitimately callable from a client Node, no duplication) — from the
locally-polled input. When the next authoritative snapshot lands, the predicted position blends
(lerps) toward the host’s true position rather than snapping, so small LAN-scale corrections are
invisible. Non-local entities (enemies, the other pilot, projectiles, gems) are rendered straight from
the two most recent snapshots with simple interpolation between them, to hide the ~20Hz-vs-60Hz
send-rate gap.
Protocol
Section titled “Protocol”- Snapshots (host → client): full-state, not delta — LAN bandwidth is plentiful and delta-encoding
is real complexity for no real gain at this scale. Sent via
@rpc("authority", "unreliable_ordered")so late/duplicate packets are simply dropped; the next snapshot supersedes them. Send cadence: every 3rd sim tick (~20Hz) — the host’s ownSim.tick()keeps running at the real fixed 60Hz internally, completely unaffected; only the network broadcast rate is decoupled and reduced. - Input (client → host): the client’s polled
InputStategoes back over the sameunreliable_orderedchannel every frame. If a packet drops, the host reuses the last-known input for that pilot for one tick rather than stalling — standard graceful degradation, matches how a disconnected local controller would leaveInputStateat its last values. - Level-up round trip: the host owns
upgrade_rng, so it still rolls the client pilot’s choices (via the existing per-pilotpending_upgrade_choices, M-A Task 8) and RPCs them to the client. The client shows its ownLevelUpPanellocally and RPCs back the chosen id; the host applies it viasim.apply_upgrade. Per M-A’s existing per-pilot design, one pilot picking an upgrade does not pause the other pilot’s combat — the world keeps moving around whichever pilot is mid-choice, now visible across two separate screens instead of one. - Serialization functions are pure. “Turn this
Sim’s pool arrays into a payload” and “apply this payload onto aSim’s pool arrays” are written as plain functions taking/returning data, with noENetMultiplayerPeerdependency — this is what makes them GUT-testable headlessly (see Testing).
Per-device UX
Section titled “Per-device UX”- Host/join screen: a new
StartMenucard, gated behindV01_LOCK_COOPexactly like the rest of co-op — invisible in the shipping v0.1 build. Two options: Host (starts a normal run, displays its own local IP somewhere clearly visible, reusing the pause menu’s precedent of a large readable code/address for a TV screen) and Join (a text field for the host’s IP — real keyboard on iPhone; text entry on tvOS is awkward, but in the target scenario the ATV is always the host, so typing an IP mostly lands on the iPhone, which is fine). - Camera + HUD scoping: today the camera and HUD assume
player(pilots[0]) is “mine.” A newlocal_pilot_indexconcept (0 on the host; 1 on the client, since this cycle is capped at one joining client) drives which pilot the camera follows and which pilot’s HP/level/build/pending-upgrade the HUD and level-up panel show. - Pilot colour differentiation (a real, currently-missing gap, confirmed in code): the parent spec
already locked “P1 cyan, P2 amber” as the pilot palette, but
main.gd’s existing_spawn_player2_render()constructsplayer2_rendereras a barePlayerRenderer.new()with no tint — P1 and P2 render identically today. In scope for this cycle: givePlayerRenderera tint parameter and apply it perlocal_pilot_indexso the other pilot is visually distinguishable in your own camera view, not just positionally different.
Error handling (deliberately minimal for a first playable pass)
Section titled “Error handling (deliberately minimal for a first playable pass)”- Client disconnects (app backgrounded, WiFi drops): host detects the ENet peer disconnect and
marks that pilot “down” — same semantics as dying.
_all_pilots_down()already exists and needs no change; the host’s run continues if it is still alive. No reconnect support this cycle — rejoining starts a fresh run. - Host quits/crashes: client shows a plain “Host disconnected” message and returns to its own start menu. No mid-run host migration.
- Explicitly not handled this cycle: protocol/version mismatch checks (both devices are assumed to be running the same build throughout dev), spectator mode, any form of failover.
Testing strategy
Section titled “Testing strategy”- GUT unit tests for the pure serialize/deserialize functions (payload ⇄
EntityPoolarrays), written and tested exactly likeContentLoader.load_from_dict— no real networking involved. This is where most of the protocol’s correctness gets proven headlessly, per the project’s standing TDD discipline. - Local dual-instance dev loop for day-to-day iteration: two
godot --path .processes on the same Mac talking over127.0.0.1, with a small dev convenience (a CLI flag or env varmain.gdchecks at boot) to auto-host or auto-join instead of clicking through the UI twice per iteration. - Real-device pass (Apple TV as host, iPhone as client, real WiFi) as the final verification per chunk — not the primary iteration loop, but the actual “does this feel right” check, and the moment that answers Chris’s “let’s see how network play goes.”
- Determinism baseline is unaffected. The host’s
Sim.tick()runs exactly as today — fixed 60Hz,SeededRng-driven — just fed input from a network source instead of a local device for the second pilot. The existing single-player baseline needs no re-pin, but is still re-verified every chunk per the project’s standing discipline (any chunk that touchessim.gd/main.gdre-runs the determinism- checksum tests before committing).
Non-goals for this cycle (deliberately deferred, not overlooked)
Section titled “Non-goals for this cycle (deliberately deferred, not overlooked)”- 3+ networked players (this cycle is exactly host + 1 client = 2 pilots total, which is what
tick()already supports without the N-pilotintegrate()unification M-A flagged as future work) - UDP auto-discovery (type-the-IP only this cycle)
- Couch co-op’s camera rework (stays exactly as it is today)
- Online play via a dedicated server (M-D) — this cycle is LAN-only
- Reconnect-after-disconnect, host migration/failover
- Full deterministic resimulation (Approach 2 from the brainstorm) — the natural upgrade path once M-D’s internet-scale latency makes Approach 1’s simple local correction feel bad
- Protocol versioning
Risks / watch-items (carried forward + new)
Section titled “Risks / watch-items (carried forward + new)”- Determinism is still the asset — protect it. Any chunk touching
sim.gd’s tick seam re-verifies the baseline, per the parent spec’s standing rule. - World-scale/perf when pilots are far apart (spawns/hash/culling assume one focal player) is a parent-spec risk that mostly doesn’t bite yet at 2 players in early testing, but watch for it once playtesting has pilots split up across the arena.
PlayerState.integrate()reuse for client prediction assumes it stays a pure, Node-free function. If a future change makes it depend on Engine/Node state, client-side prediction breaks silently (it would still compile — it’s called from a Node — but would no longer be safe to treat as/sim-pure). Flag this dependency in a comment at the call site.- Send-rate/tick-rate mismatch (20Hz network vs 60Hz sim) is a new source of visible jitter for fast-moving entities (enemy dashes, boss attacks) that the parent spec’s LAN-only scope didn’t anticipate in detail — the interpolation approach should absorb it, but this is exactly the kind of thing the real-device pass is for.