Networked Multiplayer M-B + M-C Implementation Plan
Networked Multiplayer M-B + M-C Implementation Plan
Section titled “Networked Multiplayer M-B + M-C Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: An iPhone joins an Apple TV’s survival run over local WiFi and both fly anywhere, each with their own camera, HP, XP/level/weapon build, dash, and drone — the parent spec’s “first playable” milestone (M-B LAN transport + M-C per-device UX), scoped to exactly 2 players (1 host + 1 client).
Architecture: /sim stays pure (no networking code enters it). Two new render-side Node scripts — net/mp_host.gd (owns the real, authoritative Sim, merges network input into sim.tick(), broadcasts snapshots) and net/mp_client.gd (holds a Sim used purely as a receive buffer — every existing renderer keeps reading it unmodified — plus lightweight local-only prediction for its own pilot). A third pure file, net/mp_protocol.gd, holds the serialize/deserialize functions as plain static functions with no Node/Engine dependency, so they’re GUT-testable exactly like ContentLoader.load_from_dict.
Tech Stack: Godot 4.6.3 GDScript, ENetMultiplayerPeer + @rpc (no MultiplayerSpawner/MultiplayerSynchronizer — this project’s sim state is flat PackedArray-based, not Node-property-based, so hand-rolled snapshot RPCs fit better than the auto-replication nodes), GUT 9.6.0 for automated tests.
Global Constraints
Section titled “Global Constraints”/simfiles stayextends RefCountedwith zero Node/render/Input/Engine/Time API calls — this is enforced by review across the whole project and must hold here too. None of this plan’s tasks touch/sim’s purity rule; all networking code lives innet/.- Every task that touches
sim.gd’s tick seam re-runs the determinism suite (tests/test_determinism.gd,tests/test_determinism_checksum.gd,tests/test_determinism_crystals.gd) before committing. This plan’s tasks are designed not to touch that seam at all (input is merged into the existinginputs: Array[InputState]array exactly the way local co-op already does) — re-verify anyway per standing project discipline. - Full suite:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit. Always check the printed script count againsttests/test_*.gd’s file count (or runscripts/check-test-count.sh) — a Parse Error in a new test file silently drops it from the run. main.V01_LOCK_COOPstaystruethroughout this plan. All new UI is gated behind it exactly like existing co-op, so none of this leaks into the shipping v0.1 build.- This whole plan executes inside an isolated git worktree (Task 1), not on
maindirectly, becausemainmay receive concurrent v0.1-launch-blocker fixes during this cycle (confirmed live:.claude/worktrees/elite-enemy-reworkcurrently has an uncommitted-to-main commit as of this plan’s writing). - Non-goals, carried over from the design spec — do not implement in this plan: 3+ networked players, UDP auto-discovery, couch co-op’s camera rework, online/dedicated-server play (M-D), reconnect-after-disconnect, host migration, full deterministic resimulation, protocol versioning.
Task 1: Set up the isolated worktree
Section titled “Task 1: Set up the isolated worktree”Files: none (git operations only).
- Step 1: Create the worktree and branch
cd /Users/chris/Claude/bullet-heavengit worktree add .claude/worktrees/networked-multiplayer-mb-mc -b worktree-networked-multiplayer-mb-mcMatches this repo’s existing convention (.claude/worktrees/sim-module-split, .claude/worktrees/elite-enemy-rework etc. — branch name worktree-<dirname>).
- Step 2: Verify the worktree boots clean
cd .claude/worktrees/networked-multiplayer-mb-mcgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitExpected: exit 0, same script/test counts as main (confirms the worktree is a clean copy before any changes). All remaining tasks in this plan run inside this worktree directory — every file path below is relative to .claude/worktrees/networked-multiplayer-mb-mc/, not the main repo root.
- Step 3: No commit for this task (nothing changed yet — the worktree creation itself is the deliverable).
Task 2: net/mp_protocol.gd — pure snapshot/input serialization
Section titled “Task 2: net/mp_protocol.gd — pure snapshot/input serialization”Files:
- Create:
net/mp_protocol.gd - Test:
tests/test_mp_protocol.gd
Interfaces:
- Produces:
MpProtocol.snapshot_from_sim(sim: Sim) -> Dictionary,MpProtocol.apply_snapshot(sim: Sim, snap: Dictionary) -> void,MpProtocol.input_to_payload(input: InputState) -> Dictionary,MpProtocol.payload_to_input(payload: Dictionary) -> InputState— allstatic funcs on aRefCountedclass, no Node/Engine dependency. Later tasks (3, 4) call these directly.
Scope note (read before writing code): this covers the pilots + the four EntityPool-family pools (enemies, projectiles, enemy_proj, gems) — the core interactive/renderable surface. It deliberately does not cover the Array[Dictionary]-based auxiliary systems (bombs, zones, webs, boss_missiles, boss_rockets, funzones, powerups, weapon_pickups, drones) — those are boss/late-game specific and are a separate, explicitly-flagged follow-up (see the plan’s Self-review section). During this cycle’s playtesting, if a boss spawns (BOSS_FIRST_TIME = 210s into a run), its body will render correctly on the client (it’s a normal pooled EnemyPool entry) but its special ranged attacks (rockets, missiles, telegraphed beams) will not — the client will only see the boss itself moving/attacking-adjacent without the projectile hazards. Fine for short early sessions; flagged, not hidden.
- Step 1: Write the failing round-trip test
extends GutTest
var content: ContentDB
func before_each() -> void: var raw := ContentLoader.load_json_file("res://data/bible.json") content = ContentLoader.load_from_dict(raw)
func test_snapshot_round_trip_preserves_pilot_and_enemy_state() -> void: var host_sim := Sim.new(1234, content) host_sim.add_pilot(Vector2(50, 0)) host_sim.pilots[0].pos = Vector2(100, 200) host_sim.pilots[0].hp = 42.0 host_sim.pilots[1].pos = Vector2(-30, 10) host_sim.enemies.add(Vector2(5, 5), Vector2.ZERO, 14.0, 0.0, content.enemy_by_id("swarmer"), 0) var snap := MpProtocol.snapshot_from_sim(host_sim)
var client_sim := Sim.new(1234, content) client_sim.add_pilot(Vector2.ZERO) MpProtocol.apply_snapshot(client_sim, snap)
assert_eq(client_sim.pilots[0].pos, Vector2(100, 200)) assert_eq(client_sim.pilots[0].hp, 42.0) assert_eq(client_sim.pilots[1].pos, Vector2(-30, 10)) assert_eq(client_sim.enemies.count, 1) assert_eq(client_sim.enemies.pos[0], Vector2(5, 5))
func test_input_payload_round_trip() -> void: var input := InputState.new(Vector2(1, 0), Vector2(0, -1), true, false) var payload := MpProtocol.input_to_payload(input) var out := MpProtocol.payload_to_input(payload) assert_eq(out.move_dir, Vector2(1, 0)) assert_eq(out.aim_dir, Vector2(0, -1)) assert_true(out.decoy) assert_false(out.dash)Check the exact enemies.add(...) signature and content.enemy_by_id(...) accessor name against sim/enemy_pool.gd’s func add(...) and sim/content_db.gd before finalizing this step — copy the real signature rather than guessing, since EnemyPool.add takes more positional args than the base EntityPool.add (confirmed: sim/enemy_pool.gd:158 extends the base signature with type/behavior args — read that line directly and match it exactly).
- Step 2: Run the test, confirm it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_mp_protocol.gd -gexit
Expected: FAIL — MpProtocol class does not exist yet.
- Step 3: Implement
net/mp_protocol.gd
class_name MpProtocolextends RefCounted
# Pure serialize/deserialize for the LAN co-op wire protocol (M-B). No Node/Engine API —# testable headlessly. Deliberately covers only the core EntityPool-family state (pilots,# enemies, projectiles, enemy_proj, gems); the Array[Dictionary] boss-auxiliary systems# (bombs/zones/webs/missiles/rockets/funzones/powerups/drones) are NOT covered this cycle.
static func snapshot_from_sim(sim: Sim) -> Dictionary: var pilots := [] for pilot in sim.pilots: pilots.append(_pilot_to_dict(pilot)) return { "run_time": sim.run_time, "pilots": pilots, "enemies": _enemies_to_dict(sim.enemies), "projectiles": _proj_to_dict(sim.projectiles), "enemy_proj": _enemy_proj_to_dict(sim.enemy_proj), "gems": _gems_to_dict(sim.gems), }
static func apply_snapshot(sim: Sim, snap: Dictionary) -> void: sim.run_time = snap.get("run_time", sim.run_time) var pilots: Array = snap.get("pilots", []) for i in range(mini(pilots.size(), sim.pilots.size())): _apply_pilot(sim.pilots[i], pilots[i]) _apply_enemies(sim.enemies, snap.get("enemies", {})) _apply_proj(sim.projectiles, snap.get("projectiles", {})) _apply_enemy_proj(sim.enemy_proj, snap.get("enemy_proj", {})) _apply_gems(sim.gems, snap.get("gems", {}))
static func input_to_payload(input: InputState) -> Dictionary: return { "move_dir": input.move_dir, "aim_dir": input.aim_dir, "decoy": input.decoy, "dash": input.dash, }
static func payload_to_input(payload: Dictionary) -> InputState: return InputState.new( payload.get("move_dir", Vector2.ZERO), payload.get("aim_dir", Vector2.ZERO), payload.get("decoy", false), payload.get("dash", false))
static func _pilot_to_dict(p: PlayerState) -> Dictionary: return { "pos": p.pos, "hp": p.hp, "max_hp": p.max_hp, "level": p.level, "xp": p.xp, "xp_to_next": p.xp_to_next, "armor": p.armor, "radius": p.radius, "dash_dir": p.dash_dir, "dash_timer": p.dash_timer, "iframe_timer": p.iframe_timer, "pending_levelups": p.pending_levelups, "active_weapon_ids": p.arsenal.active_weapon_ids.duplicate() if p.arsenal != null else [], }
static func _apply_pilot(p: PlayerState, d: Dictionary) -> void: p.pos = d.get("pos", p.pos) p.hp = d.get("hp", p.hp) p.max_hp = d.get("max_hp", p.max_hp) p.level = d.get("level", p.level) p.xp = d.get("xp", p.xp) p.xp_to_next = d.get("xp_to_next", p.xp_to_next) p.armor = d.get("armor", p.armor) p.radius = d.get("radius", p.radius) p.dash_dir = d.get("dash_dir", p.dash_dir) p.dash_timer = d.get("dash_timer", p.dash_timer) p.iframe_timer = d.get("iframe_timer", p.iframe_timer) p.pending_levelups = d.get("pending_levelups", p.pending_levelups) if p.arsenal != null and d.has("active_weapon_ids"): p.arsenal.active_weapon_ids = d["active_weapon_ids"]
static func _enemies_to_dict(pool: EnemyPool) -> Dictionary: var n := pool.count return { "count": n, "pos": pool.pos.slice(0, n), "radius": pool.radius.slice(0, n), "type_id": pool.type_id.slice(0, n), "base_element": pool.base_element.slice(0, n), "aura_element": pool.aura_element.slice(0, n), "primed": pool.primed.slice(0, n), "is_elite": pool.is_elite.slice(0, n), "entity_id": pool.entity_id.slice(0, n), "behavior": pool.behavior.slice(0, n), "dash_phase": pool.dash_phase.slice(0, n), "dash_timer": pool.dash_timer.slice(0, n), "stacks": pool.stacks.slice(0, n), "aura_remaining": pool.aura_remaining.slice(0, n), "max_hp": pool.max_hp.slice(0, n), }
static func _apply_enemies(pool: EnemyPool, d: Dictionary) -> void: if d.is_empty(): return pool.count = d["count"] pool.pos = d["pos"]; pool.radius = d["radius"]; pool.type_id = d["type_id"] pool.base_element = d["base_element"]; pool.aura_element = d["aura_element"] pool.primed = d["primed"]; pool.is_elite = d["is_elite"]; pool.entity_id = d["entity_id"] pool.behavior = d["behavior"]; pool.dash_phase = d["dash_phase"] pool.dash_timer = d["dash_timer"]; pool.stacks = d["stacks"] pool.aura_remaining = d["aura_remaining"]; pool.max_hp = d["max_hp"]
static func _proj_to_dict(pool: ProjPool) -> Dictionary: var n := pool.count return { "count": n, "pos": pool.pos.slice(0, n), "vel": pool.vel.slice(0, n), "radius": pool.radius.slice(0, n), "element_idx": pool.element_idx.slice(0, n), }
static func _apply_proj(pool: ProjPool, d: Dictionary) -> void: if d.is_empty(): return pool.count = d["count"] pool.pos = d["pos"]; pool.vel = d["vel"]; pool.radius = d["radius"] pool.element_idx = d["element_idx"]
static func _enemy_proj_to_dict(pool: EnemyProjPool) -> Dictionary: var n := pool.count return { "count": n, "pos": pool.pos.slice(0, n), "vel": pool.vel.slice(0, n), "radius": pool.radius.slice(0, n), "source_type": pool.source_type.slice(0, n), "source_element": pool.source_element.slice(0, n), }
static func _apply_enemy_proj(pool: EnemyProjPool, d: Dictionary) -> void: if d.is_empty(): return pool.count = d["count"] pool.pos = d["pos"]; pool.vel = d["vel"]; pool.radius = d["radius"] pool.source_type = d["source_type"]; pool.source_element = d["source_element"]
static func _gems_to_dict(pool: EntityPool) -> Dictionary: var n := pool.count return {"count": n, "pos": pool.pos.slice(0, n)}
static func _apply_gems(pool: EntityPool, d: Dictionary) -> void: if d.is_empty(): return pool.count = d["count"] pool.pos = d["pos"]Before finalizing, grep -n "^var " sim/enemy_pool.gd sim/proj_pool.gd sim/enemy_proj_pool.gd and confirm every field name above matches exactly — the field names in this step were read directly from those files during planning, but re-check for drift if any other in-flight branch (e.g. elite-enemy-rework) has touched EnemyPool columns since.
- Step 4: Run the test, confirm it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_mp_protocol.gd -gexit
Expected: PASS.
- Step 5: Run the full suite + determinism baseline
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: exit 0, script count increased by 1 (the new test file), determinism tests still assert the existing pinned checksum (this task never calls sim.tick(), so the baseline cannot move).
- Step 6: Commit
git add net/mp_protocol.gd tests/test_mp_protocol.gdgit commit -m "feat(net): MpProtocol — pure snapshot/input serialize for LAN co-op (M-B)"Task 3: net/mp_host.gd — authoritative host session
Section titled “Task 3: net/mp_host.gd — authoritative host session”Files:
- Create:
net/mp_host.gd - Test:
tests/test_mp_host.gd(pure-logic parts only — see note below)
Interfaces:
- Consumes:
MpProtocol.snapshot_from_sim,MpProtocol.payload_to_input(Task 2). - Produces:
MpHost.new()— aNode. Public API:start(port: int) -> Error,sim: Sim(assign after construction),build_tick_inputs(local_input: InputState) -> Array[InputState],note_tick_elapsed() -> bool(pure: returns true on ticks where a snapshot should broadcast — callsend_snapshot_now()when it returns true). Task 8/9 (main.gd wiring) call these.
A real Godot networking gotcha, read before writing code: RPCs dispatch by matching NodePath, not by script class. For mp_host.gd’s calls to reach mp_client.gd (and vice versa), both devices must add their session node as a child of the same parent with the exact same node name — this plan uses the literal name "MpSession" on both sides (set explicitly, since Godot would otherwise auto-name the node after its class — MpHost vs MpClient — which would silently break routing). Task 9 wires this; noted here so Task 3/4’s code makes sense in context.
- Step 1: Write the failing test for the pure send-cadence logic
extends GutTest
func test_note_tick_elapsed_fires_every_third_tick() -> void: var host := MpHost.new() var fired := [] for i in range(9): fired.append(host.note_tick_elapsed()) assert_eq(fired, [false, false, true, false, false, true, false, false, true])
func test_build_tick_inputs_uses_last_known_input_when_client_connected() -> void: var host := MpHost.new() host._client_peer_id = 7 # simulate a connected client, no packet loss handling needed for this test host._last_client_input = InputState.new(Vector2.UP) var local := InputState.new(Vector2.RIGHT) var inputs := host.build_tick_inputs(local) assert_eq(inputs.size(), 2) assert_eq(inputs[0], local) assert_eq(inputs[1].move_dir, Vector2.UP)- Step 2: Run test, confirm it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_mp_host.gd -gexit
Expected: FAIL — MpHost doesn’t exist.
- Step 3: Implement
net/mp_host.gd
class_name MpHostextends Node
# Host side of LAN co-op (M-B): owns the real authoritative Sim, merges the connected# client's network input into sim.tick()'s inputs array at the same seam local co-op's# second controller already uses, and periodically broadcasts a snapshot. Node (not /sim) —# owns ENetMultiplayerPeer, which is an Engine API.
const SEND_EVERY_N_TICKS := 3const DEFAULT_PORT := 8910
var sim: Sim = nullvar _tick_count := 0var _client_peer_id := -1var _last_client_input: InputState = InputState.new()
func start(port: int = DEFAULT_PORT) -> Error: var peer := ENetMultiplayerPeer.new() var err := peer.create_server(port, 1) # 1 max client this cycle (2 players total) if err != OK: return err multiplayer.multiplayer_peer = peer multiplayer.peer_connected.connect(_on_peer_connected) multiplayer.peer_disconnected.connect(_on_peer_disconnected) return OK
func local_ip() -> String: for ip in IP.get_local_addresses(): if ip.begins_with("192.168.") or ip.begins_with("10.") or ip.begins_with("172."): return ip return "unknown"
func has_client() -> bool: return _client_peer_id != -1
# Pure: true on ticks where a snapshot should go out. Call alongside sim.tick() each physics# frame; when it returns true, follow with send_snapshot_now().func note_tick_elapsed() -> bool: _tick_count += 1 return _tick_count % SEND_EVERY_N_TICKS == 0
func send_snapshot_now() -> void: if not has_client() or sim == null: return rpc_id(_client_peer_id, "receive_snapshot", MpProtocol.snapshot_from_sim(sim))
func build_tick_inputs(local_input: InputState) -> Array[InputState]: var inputs: Array[InputState] = [local_input] if has_client(): inputs.append(_last_client_input) return inputs
func _on_peer_connected(id: int) -> void: _client_peer_id = id if sim != null and sim.pilots.size() < 2: sim.add_pilot(Vector2.ZERO)
func _on_peer_disconnected(id: int) -> void: if id == _client_peer_id and sim != null and sim.pilots.size() > 1: sim.pilots[1].hp = 0.0 # matches "down" semantics _all_pilots_down() already checks _client_peer_id = -1
# Called by the client (any_peer — the client isn't the multiplayer authority) every frame.@rpc("any_peer", "unreliable_ordered")func receive_input(payload: Dictionary) -> void: _last_client_input = MpProtocol.payload_to_input(payload)
# Declared on the CLIENT script (mp_client.gd), not here — the host only ever CALLS this by# name via rpc_id, it never needs its own local implementation of a method it never receives.- Step 4: Run test, confirm it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_mp_host.gd -gexit
Expected: PASS. (This test never opens a real ENet connection — create_server/RPC wiring is verified manually in Task 6, since GUT runs single-process and can’t exercise two networked peers.)
- Step 5: Run full suite + determinism baseline
Same commands as Task 2 Step 5. Expected: unchanged baseline (no sim.tick() call added or modified).
- Step 6: Commit
git add net/mp_host.gd tests/test_mp_host.gdgit commit -m "feat(net): MpHost — authoritative LAN host session (M-B)"Task 4: net/mp_client.gd — receive-buffer client + local prediction
Section titled “Task 4: net/mp_client.gd — receive-buffer client + local prediction”Files:
- Create:
net/mp_client.gd - Test:
tests/test_mp_client.gd
Interfaces:
-
Consumes:
MpProtocol.snapshot_from_sim/apply_snapshot/input_to_payload(Task 2),PlayerState.integrate(input: InputState, dt: float) -> void(existing/simmethod,sim/player_state.gd:49). -
Produces:
MpClient.new()— aNode. Public API:join(address: String, port: int) -> Error,sim: Sim(constructed internally, never ticked),local_pilot_index := 1(constant this cycle),predict_local_pilot(input: InputState, dt: float) -> void,send_input(input: InputState) -> void. -
Step 1: Write the failing test for local prediction blend
extends GutTest
var content: ContentDB
func before_each() -> void: content = ContentLoader.load_from_dict(ContentLoader.load_json_file("res://data/bible.json"))
func test_predict_local_pilot_advances_position_from_input() -> void: var client := MpClient.new() client.sim = Sim.new(1234, content) client.sim.add_pilot(Vector2.ZERO) client.sim.pilots[1].pos = Vector2.ZERO client.sim.pilots[1].speed = 100.0 var input := InputState.new(Vector2.RIGHT) client.predict_local_pilot(input, 1.0) assert_eq(client.sim.pilots[1].pos, Vector2(100, 0))
func test_apply_remote_snapshot_does_not_touch_local_pilot_prediction() -> void: var client := MpClient.new() client.sim = Sim.new(1234, content) client.sim.add_pilot(Vector2.ZERO) client.sim.pilots[0].pos = Vector2(5, 5) # remote (host) pilot moved var snap := MpProtocol.snapshot_from_sim(client.sim) client.sim.pilots[0].pos = Vector2.ZERO # simulate staleness before the snapshot lands client.apply_remote_snapshot(snap) assert_eq(client.sim.pilots[0].pos, Vector2(5, 5))- Step 2: Run test, confirm it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_mp_client.gd -gexit
Expected: FAIL — MpClient doesn’t exist.
- Step 3: Implement
net/mp_client.gd
class_name MpClientextends Node
# Client side of LAN co-op (M-B). Holds a real Sim, but NEVER calls sim.tick() — every# renderer keeps reading `sim` exactly as in single-player; this Node's job is to keep that# Sim's data fresh from the network. Local-only prediction (Approach 1 from the design):# the client's own pilot is nudged each frame from local input using the same# PlayerState.integrate() the host's real tick uses, then corrected on each snapshot.
const DEFAULT_PORT := 8910const local_pilot_index := 1 # this cycle: host is always pilots[0], the one joining client is always pilots[1]
var sim: Sim = nullvar connected := false
func join(address: String, port: int = DEFAULT_PORT) -> Error: var peer := ENetMultiplayerPeer.new() var err := peer.create_client(address, port) if err != OK: return err multiplayer.multiplayer_peer = peer multiplayer.connected_to_server.connect(func(): connected = true) multiplayer.server_disconnected.connect(func(): connected = false) return OK
# Advances the local pilot's PREDICTED position between snapshots. Not a full sim tick —# just the same movement integration the real Sim.tick() applies to any pilot, called# directly since PlayerState.integrate() is pure /sim code with no Node dependency.func predict_local_pilot(input: InputState, dt: float) -> void: if sim == null or local_pilot_index >= sim.pilots.size(): return sim.pilots[local_pilot_index].integrate(input, dt)
# Applies an authoritative host snapshot onto the local Sim. Overwrites everything the# snapshot covers, INCLUDING the local pilot's predicted position — small LAN-scale# corrections are invisible; render.gd smooths larger ones via its own lerp, not here# (this function is the "hard" authoritative write; Task 9's render step does the visual# blend so a correction doesn't look like a teleport).func apply_remote_snapshot(snap: Dictionary) -> void: if sim == null: return MpProtocol.apply_snapshot(sim, snap)
func send_input(input: InputState) -> void: if not connected: return rpc_id(1, "receive_input", MpProtocol.input_to_payload(input)) # host is always peer id 1
# Called by the host (authority) every ~3rd tick. Declared "authority" so only the host# (peer id 1, the multiplayer authority by default) can invoke it.@rpc("authority", "unreliable_ordered")func receive_snapshot(payload: Dictionary) -> void: apply_remote_snapshot(payload)- Step 4: Run test, confirm it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_mp_client.gd -gexit
Expected: PASS.
- Step 5: Run full suite + determinism baseline
Same as prior tasks. Expected: unchanged baseline.
- Step 6: Commit
git add net/mp_client.gd tests/test_mp_client.gdgit commit -m "feat(net): MpClient — Sim-as-receive-buffer + local-only prediction (M-B)"Task 5: Local dual-instance dev-loop convenience
Section titled “Task 5: Local dual-instance dev-loop convenience”Files:
- Modify:
main.gd(add a small boot-time CLI check near the top of_ready())
Interfaces:
-
Consumes:
MpHost/MpClient(Tasks 3, 4). -
Produces:
main._dev_mp_autostart() -> void, called once from_ready(). -
Step 1: Write the failing test
This is a thin CLI-args → method-call shim; test the pure parsing logic, not the actual network join (which needs two processes).
extends GutTest
func test_parses_host_flag() -> void: assert_eq(Main.parse_dev_mp_args(["--mp-host"]), {"role": "host", "address": ""})
func test_parses_join_flag_with_address() -> void: assert_eq(Main.parse_dev_mp_args(["--mp-join=127.0.0.1"]), {"role": "join", "address": "127.0.0.1"})
func test_no_flags_is_a_noop() -> void: assert_eq(Main.parse_dev_mp_args(["--other-flag"]), {"role": "", "address": ""})Main here refers to the main.gd script’s class_name — confirm the actual class_name declared at the top of main.gd (grep ^class_name in that file) and use that exact name in the test; if main.gd has no class_name yet, add class_name Main as part of this task’s implementation step (check first — do not add a duplicate if one already exists).
- Step 2: Run test, confirm it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_mp_dev_autostart.gd -gexit
Expected: FAIL.
- Step 3: Implement the parser + wire it into
_ready()
Add near the top of main.gd (as a static function so it’s callable from the test without a full scene tree):
# Dev convenience only (never runs in a real build — OS.get_cmdline_args() is empty when# launched normally): parses --mp-host / --mp-join=<ip> so two local `godot --path .`# processes can auto-host/auto-join over 127.0.0.1 instead of clicking through the UI# every iteration. See Task 5, M-B+M-C plan.static func parse_dev_mp_args(args: PackedStringArray) -> Dictionary: for a in args: if a == "--mp-host": return {"role": "host", "address": ""} if a.begins_with("--mp-join="): return {"role": "join", "address": a.substr("--mp-join=".length())} return {"role": "", "address": ""}In _ready(), after sim/menu setup is otherwise complete, add:
_dev_mp_autostart()and the instance method:
func _dev_mp_autostart() -> void: var parsed := parse_dev_mp_args(OS.get_cmdline_user_args()) if parsed["role"] == "host": _start_mp_host() elif parsed["role"] == "join": _start_mp_client(parsed["address"])_start_mp_host()/_start_mp_client(address) are implemented in Task 9 alongside the real StartMenu host/join card — this task only wires the dev CLI shortcut to call them; if Task 9 hasn’t landed yet when this task is executed standalone, stub both as empty functions with a # TODO(Task 9) comment and fill them in when Task 9 lands (this plan is designed to execute tasks in order, so in practice Task 9 will already exist by the time this matters — flagging the ordering dependency for whoever executes out of order).
- Step 4: Run test, confirm it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_mp_dev_autostart.gd -gexit
Expected: PASS.
- Step 5: Run full suite + determinism baseline; headless boot smoke
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitgodot --headless --path . --quit-after 120Expected: suite green, no SCRIPT ERROR in the boot smoke’s stderr (confirms the new top-level class_name Main — if added — didn’t collide with anything, and _dev_mp_autostart() no-ops safely with no CLI args, which is the normal case).
- Step 6: Commit
git add main.gd tests/test_mp_dev_autostart.gdgit commit -m "feat(dev): --mp-host / --mp-join=<ip> CLI shortcut for local dual-instance testing"Task 6: Manual dual-instance verification checkpoint
Section titled “Task 6: Manual dual-instance verification checkpoint”Files: none — this task is a verification gate, not a code change. (Per this project’s established precedent — see the M-A plan’s InputRouter step — Engine-API-dependent behavior that GUT can’t exercise headlessly gets a documented manual-verification step instead of a fake automated one.)
- Step 1: Launch two local instances
godot --path . --mp-host &godot --path . --mp-join=127.0.0.1 &- Step 2: Confirm basic connectivity
Expected: the join instance’s console shows no create_client error (Error enum, OK = 0); the host instance’s _on_peer_connected fires (add a temporary print("peer connected: ", id) in mp_host.gd for this check if not already visible in the F2 debug overlay — remove before commit if added).
- Step 3: Confirm snapshot flow
On the host instance, move the ship — expected: the join instance’s sim.pilots[0].pos updates (print it, or note that nothing renders it usefully yet since Task 9 hasn’t wired cameras/renderers to read from the network Sim on the client — this step is about confirming DATA arrives, not full visual verification, which comes after Task 9).
- Step 4: If RPCs aren’t arriving, check the NodePath-matching gotcha first
Per Task 3’s callout: both instances’ MpHost/MpClient node must be added under the same parent with the identical name "MpSession". A silent no-op (no error, but receive_input/receive_snapshot never fire) is the classic symptom of a path mismatch — verify with print(get_node("MpSession").get_path()) on both sides and confirm they’re identical.
- Step 5: No commit (verification only; if this step uncovers a bug, fix it in the relevant earlier task’s file and commit there, not here).
Task 7: Level-up choice round trip
Section titled “Task 7: Level-up choice round trip”Files:
- Modify:
net/mp_host.gd(add the host-side RPC methods) - Modify:
net/mp_client.gd(add the client-side RPC methods) - Modify:
sim/upgrade_system.gd— confirm (do not change)roll_upgrade_choices(sim: Sim, n: int) -> Array[String]signature (sim/upgrade_system.gd:45); this task callsUpgradeSystem.apply_upgrade, it doesn’t redefine it - Test:
tests/test_mp_levelup_roundtrip.gd
Interfaces:
-
Consumes:
sim.upgrade_system.roll_upgrade_choices(sim, n),sim.upgrade_system.apply_upgrade(sim, id)(existing, confirmed atsim/upgrade_system.gd:192— noteapply_upgradelives onUpgradeSystem, not directly onSim; there is no forwarding accessor for it),MpHost/MpClient(Tasks 3, 4). -
Produces:
MpHost.offer_levelup_to_client(pilot_index: int, choice_ids: Array[String]) -> void,MpClient.submit_levelup_choice(id: String) -> void, and a new signalMpClient.levelup_offered(choice_ids: Array[String])that Task 9’s UI wiring connects toLevelUpPanel.show_choices(choices). -
Step 1: Write the failing test for the pure dispatch logic
extends GutTest
func test_offer_levelup_to_client_sends_to_client_peer() -> void: var host := MpHost.new() host._client_peer_id = 3 var sent := [] host._test_rpc_hook = func(peer_id, method, args): sent.append([peer_id, method, args]) host.offer_levelup_to_client(1, ["dmg", "fire-rate", "pickup"]) assert_eq(sent, [[3, "_receive_levelup_offer", [["dmg", "fire-rate", "pickup"]]]])_test_rpc_hook is a small seam added purely so this can be tested without a real network connection — see Step 3.
- Step 2: Run test, confirm it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_mp_levelup_roundtrip.gd -gexit
Expected: FAIL.
- Step 3: Implement the round trip
Add to net/mp_host.gd:
# Test seam: when set, offer_levelup_to_client calls this instead of the real rpc_id, so the# dispatch logic is testable without a live ENet connection. null in production.var _test_rpc_hook: Callable = Callable()
func offer_levelup_to_client(_pilot_index: int, choice_ids: Array[String]) -> void: if not has_client(): return if _test_rpc_hook.is_valid(): _test_rpc_hook.call(_client_peer_id, "_receive_levelup_offer", [choice_ids]) else: rpc_id(_client_peer_id, "_receive_levelup_offer", choice_ids)
# Called by the client once it picks. any_peer: the client (not the authority) calls this.@rpc("any_peer", "reliable")func receive_levelup_choice(id: String) -> void: if sim != null and sim.pilots.size() > 1: sim.upgrade_system.apply_upgrade(sim, id)Add to net/mp_client.gd:
signal levelup_offered(choice_ids: Array)
# Called by the host. "authority" — only the host may offer.@rpc("authority", "reliable")func _receive_levelup_offer(choice_ids: Array) -> void: levelup_offered.emit(choice_ids)
func submit_levelup_choice(id: String) -> void: rpc_id(1, "receive_levelup_choice", id)Use "reliable" (not "unreliable_ordered") for both level-up RPCs — a dropped level-up offer or choice is a much worse UX than a dropped movement snapshot (the next one is seconds away, not 50ms away), so these two justify the extra channel cost.
- Step 4: Run test, confirm it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_mp_levelup_roundtrip.gd -gexit
Expected: PASS.
- Step 5: Run full suite + determinism baseline
Standard commands. Expected: unchanged baseline (the host still rolls upgrades on its own Sim via the existing upgrade_rng-driven path; this task only adds a network relay around the existing call, it doesn’t change when/how rolls happen).
- Step 6: Commit
git add net/mp_host.gd net/mp_client.gd tests/test_mp_levelup_roundtrip.gdgit commit -m "feat(net): level-up choice round trip over the LAN session (M-B)"Task 8: PlayerRenderer pilot colour tint
Section titled “Task 8: PlayerRenderer pilot colour tint”Files:
- Modify:
render/player_renderer.gd - Modify:
main.gd:1342area (_spawn_player2_render()) - Test:
tests/test_player_renderer_tint.gd
Interfaces:
- Produces:
PlayerRenderer.tint: Color = Color.WHITE(public var, set beforeadd_child— same pattern as the existingbaked_class).
Confirmed gap (from planning, not speculative): main.gd’s _spawn_player2_render() currently constructs player2_renderer as a bare PlayerRenderer.new() with no differentiation from player_renderer — P1 and P2 render identically today. The parent design spec already locked “P1 cyan, P2 amber” as the palette; this task actually implements it.
- Step 1: Write the failing test
extends GutTest
func test_default_tint_is_white() -> void: var r := PlayerRenderer.new() assert_eq(r.tint, Color.WHITE)
func test_tint_is_settable_before_ready() -> void: var r := PlayerRenderer.new() r.tint = Color(1.0, 0.75, 0.2) # amber assert_eq(r.tint, Color(1.0, 0.75, 0.2))- Step 2: Run test, confirm it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_player_renderer_tint.gd -gexit
Expected: FAIL — tint property doesn’t exist.
- Step 3: Add the
tintfield and apply it
In render/player_renderer.gd, near the existing var baked_class: String = ShipBonuses.DEFAULT_SHIP (line 51):
# Pilot colour identity for co-op (M-C): multiplies into the tier accent colour so P1/P2# read as distinct ships. WHITE = no change (single-player, unaffected).var tint: Color = Color.WHITEFind where TIER_ACCENT[tier] (or equivalent per-tier colour) is actually applied to a material/modulate in _ready()/update_visual() — read the surrounding code in render/player_renderer.gd first (it wasn’t fully read during planning; locate the exact line(s) that set a Color onto the sprite/material) and multiply that colour by tint there, e.g. effective_color = TIER_ACCENT[tier] * tint at that exact call site, rather than guessing a line number here.
In main.gd’s _spawn_player2_render() (around line 1342):
func _spawn_player2_render() -> void: if player2_node != null or sim == null or sim.player2 == null: return player2_node = Node2D.new() add_child(player2_node) player2_renderer = PlayerRenderer.new() player2_renderer.tint = Color(1.0, 0.75, 0.2) # amber, per the parent spec's locked P1 cyan / P2 amber player2_node.add_child(player2_renderer) _player2_facing = 0.0 _last_player2_pos = sim.player2.pos player2_node.position = sim.player2.pos(player_renderer for P1 is left at the default tint = Color.WHITE, which combined with TIER_ACCENT’s existing cyan-at-tier-0 already reads as “P1 cyan” — no change needed there.)
- Step 4: Run test, confirm it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_player_renderer_tint.gd -gexit
Expected: PASS.
- Step 5: Run full suite + determinism baseline; visual spot-check
Standard suite command. Then a manual check per this project’s UI-verification convention (bullet-heaven-ui-look memory): open in the editor, force local co-op (or the dev worktree’s existing manual-join path), confirm P2’s ship visibly reads amber next to P1’s cyan.
- Step 6: Commit
git add render/player_renderer.gd main.gd tests/test_player_renderer_tint.gdgit commit -m "fix(render): P1/P2 pilot colour differentiation (cyan/amber), was rendering identically"Task 9: Per-device UX — host/join screen, camera + HUD scoping
Section titled “Task 9: Per-device UX — host/join screen, camera + HUD scoping”Files:
- Modify:
ui/start_menu.gd(new host/join card, gatedV01_LOCK_COOP) - Modify:
main.gd(_start_mp_host()/_start_mp_client()referenced by Task 5;local_pilot_index-aware camera target; HUD/level-up-panel source selection) - Modify:
ui/hud.gdifupdate_hud(sim: Sim)’s signature needs a pilot-index parameter (confirm current signature atui/hud.gd:225before deciding — it currently hardcodessim.player, so it needs to becomeupdate_hud(sim: Sim, pilot_index: int = 0)readingsim.pilots[pilot_index]instead)
Interfaces:
-
Consumes:
MpHost/MpClient(Tasks 3, 4, 7),StartMenu(existing signalsmode_chosen, plus a new one added here). -
Step 1: Add the StartMenu card
In ui/start_menu.gd, add a new signal near the existing ones (line ~17-21):
signal multiplayer_requested # "Multiplayer" footer button — gated behind V01_LOCK_COOPAdd the card next to the existing footer buttons (ui/start_menu.gd, in the if has_progressed: block around line 146-158, right after the “Enemies” codex_btn and before the if BuildConfig.dev_tools(): Remote Control block), using the exact same _footer_btn pattern those use:
# Multiplayer — gated behind V01_LOCK_COOP exactly like the rest of co-op (main.V01_LOCK_COOP, # not BuildConfig.dev_tools() — this is a co-op feature flag, not a dev-tool visibility flag). if not Main.V01_LOCK_COOP: var mp_btn := _footer_btn("Multiplayer", "multiplayer", Color(0.4, 0.85, 1.0), 44, NeonTheme.title_font(), 19) mp_btn.pressed.connect(func() -> void: _ui_select(); multiplayer_requested.emit()) box.add_child(mp_btn) _reveal.append({"node": mp_btn, "delay": d}) d += REVEAL_STAGGER(Copied directly from the real shop_btn/codex_btn pattern at ui/start_menu.gd:147-151 — same _footer_btn signature, same box.add_child/_reveal.append/d += REVEAL_STAGGER bookkeeping. Main.V01_LOCK_COOP requires class_name Main to exist on main.gd, which Task 5 already adds if missing — if Task 9 runs before Task 5 for any reason, add class_name Main here instead.)
- Step 2: Build the host/join sub-screen
Create a minimal scene-free UI (matching the pause menu’s precedent of a plain Control-based overlay, not a .tscn) — add to main.gd:
var _mp_host: MpHost = nullvar _mp_client: MpClient = null
func _start_mp_host() -> void: _mp_host = MpHost.new() _mp_host.name = "MpSession" # NodePath-matching requirement — see Task 3's callout add_child(_mp_host) var err := _mp_host.start() if err != OK: push_error("MpHost.start failed: %s" % err) return _mp_host.sim = sim # sim already exists from the normal run-start path # Show the host's local IP somewhere visible — reuse the pause menu's large-readable-code # pattern (ui/pause_menu.gd) rather than a new small label; wire this in whatever the # pause/lobby overlay for this screen turns out to be once built by hand against the # current pause_menu.gd structure.
func _start_mp_client(address: String) -> void: _mp_client = MpClient.new() _mp_client.name = "MpSession" add_child(_mp_client) var err := _mp_client.join(address) if err != OK: push_error("MpClient.join failed: %s" % err) return sim = _mp_client.sim # main.gd's existing `sim` field now points at the receive-buffer Sim sim.add_pilot(Vector2.ZERO) # matches the host's 2-pilot Sim shape before any snapshot arrives local_pilot_index = 1Add var local_pilot_index := 0 near the top of main.gd alongside the other top-level vars.
- Step 3: Wire the per-tick host/client seams into the existing loop
In main.gd’s physics/process step where sim.tick_single(input, input2) or sim.tick(inputs) is currently called (grep the exact call site — it moved during M-A, confirm the live line before editing), branch:
if _mp_host != null: var inputs := _mp_host.build_tick_inputs(input) sim.tick(inputs) if _mp_host.note_tick_elapsed(): _mp_host.send_snapshot_now()elif _mp_client != null: _mp_client.predict_local_pilot(input, delta) # delta = the real frame dt, not Sim_Const.DT — this is prediction, not the deterministic tick _mp_client.send_input(input) # NOTE: sim.tick() is never called here — the client's Sim is a receive buffer (Task 4).else: sim.tick_single(input, input2) # existing single-player / local co-op path, unchanged- Step 4: Camera + HUD scoping
In main.gd’s _process(), find the camera-target block (currently around line 1081-1086, var cam_target := sim.player.pos / midpoint logic) and add a branch ahead of it:
if local_pilot_index != 0 and local_pilot_index < sim.pilots.size(): camera.global_position = sim.pilots[local_pilot_index].poselse: # existing midpoint / P1-follow logic, unchanged ...Find the HUD update call (hud.update_hud(sim) — locate the exact call site) and change the signature to accept a pilot index, defaulting to 0 so single-player/host callers don’t change:
hud.update_hud(sim, local_pilot_index)Update ui/hud.gd:225’s update_hud(sim: Sim) to update_hud(sim: Sim, pilot_index: int = 0), replacing its internal sim.player reads with sim.pilots[pilot_index].
Find where _current_choice_ids/level_up.show_choices(...) is triggered (main.gd, near _on_upgrade_chosen at line 1517) and, for the client path, connect it to the new MpClient.levelup_offered signal from Task 7 instead of the local pending_levelups check — read the existing trigger condition first and branch on _mp_client != null the same way Step 3 branches the tick call.
- Step 5: Manual verification
No automated test for this task — it’s UI wiring across main.gd’s render loop, which (per this project’s own bullet-heaven-ui-look memory) is verified by playing, not by a headless assertion. Use the Task 6 dual-instance setup: confirm the join instance’s camera follows its OWN ship (not the host’s), its HUD shows its own HP/level/XP, and a level-up on the client pilot shows the client’s own LevelUpPanel.
- Step 6: Run full suite + determinism baseline; headless boot smoke
Standard commands from Task 5 Step 5. Expected: unchanged baseline — every branch added in Step 3 is gated on _mp_host/_mp_client being non-null, both null by default, so the existing single-player/local-co-op path (else: sim.tick_single(...)) is untouched for any run that never calls _start_mp_host/_start_mp_client.
- Step 7: Commit
git add ui/start_menu.gd ui/hud.gd main.gdgit commit -m "feat(ui): host/join screen + per-device camera/HUD scoping for LAN co-op (M-C)"Task 10: Error handling — disconnects
Section titled “Task 10: Error handling — disconnects”Files:
- Modify:
net/mp_client.gd(host-disconnect UX signal) - Modify:
main.gd(react to it) - Test: extend
tests/test_mp_client.gd
Interfaces:
-
Produces:
MpClient.host_disconnectedsignal. -
Step 1: Write the failing test
# append to tests/test_mp_client.gdfunc test_host_disconnected_signal_exists_and_can_be_emitted() -> void: var client := MpClient.new() var caught := [false] client.host_disconnected.connect(func(): caught[0] = true) client.host_disconnected.emit() assert_true(caught[0])- Step 2: Run test, confirm it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_mp_client.gd -gexit
Expected: FAIL — signal doesn’t exist.
- Step 3: Implement
In net/mp_client.gd, add the signal and connect it in join():
signal host_disconnected
func join(address: String, port: int = DEFAULT_PORT) -> Error: var peer := ENetMultiplayerPeer.new() var err := peer.create_client(address, port) if err != OK: return err multiplayer.multiplayer_peer = peer multiplayer.connected_to_server.connect(func(): connected = true) multiplayer.server_disconnected.connect(func(): connected = false host_disconnected.emit()) return OK(MpHost’s side — client disconnect marking the pilot “down” via _on_peer_disconnected — was already implemented in Task 3 Step 3; no change needed there. This task is only the client-observing-host-loss half.)
In main.gd, where _mp_client is constructed (Task 9 Step 2’s _start_mp_client), connect the new signal:
_mp_client.host_disconnected.connect(_on_mp_host_disconnected)func _on_mp_host_disconnected() -> void: sim = null _mp_client = null # Reuses the existing _return_to_menu() path (ui/pause_menu.gd's precedent) rather than a # new screen — confirm _return_to_menu()'s exact name/behavior in main.gd before calling it; # it already does the "sim=null, re-show StartMenu" sweep this needs. _return_to_menu()- Step 4: Run test, confirm it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_mp_client.gd -gexit
Expected: PASS.
- Step 5: Run full suite + determinism baseline
Standard commands. Expected: unchanged.
- Step 6: Commit
git add net/mp_client.gd main.gd tests/test_mp_client.gdgit commit -m "feat(net): client returns to menu on host disconnect (M-B error handling)"Task 11: Full regression pass + real-device verification + docs
Section titled “Task 11: Full regression pass + real-device verification + docs”Files:
-
Modify:
CLAUDE.md(Current status section) -
Step 1: Full suite + count guard
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitscripts/check-test-count.shExpected: exit 0 both, script count matches tests/test_*.gd file count.
- Step 2: Headless boot smoke
godot --headless --path . --quit-after 120Expected: no SCRIPT ERROR in stderr.
- Step 3: Real-device verification — Apple TV as host, iPhone as client
Export/install a dev build to both (reuse the existing bh-deploy-style devicectl install flow for the ATV; a normal Xcode devicectl install or TestFlight-internal build for the iPhone — this task doesn’t need App Store submission, just a device install, per bh-appstore-release’s distinction from a dev install). On the ATV, use the pause-menu-style screen from Task 9 to start hosting and read its displayed IP; on the iPhone, use the join screen to type that IP. Confirm: both ships visible and moving independently, camera on each device follows its own ship, HUD shows each device’s own HP/level/XP, a level-up on either device shows only that device’s panel and doesn’t freeze the other’s play, and killing enemies/collecting gems on one device doesn’t affect the other’s build.
This is the actual “let’s see how network play goes” moment — record what feels off (input lag, jitter, snapshot pop) for a follow-up tuning pass; this task’s job is to get it running and observed, not to fully polish feel.
- Step 4: Update
CLAUDE.md
Add to the “Current status” section, following the existing style (condensed, in-place update per bullet-heaven-claude-md-maintenance convention — do not append a new dated changelog entry):
M-B+M-C (LAN networked multiplayer, 2 players) — implemented in worktree
networked-multiplayer-mb-mc, [not yet / merged to main — fill in based on actual outcome of Task 12]. Host-authority overENetMultiplayerPeer(net/mp_host.gd/net/mp_client.gd/net/mp_protocol.gd), lightweight local-only prediction for the client’s own pilot (no full deterministic resimulation — LAN latency didn’t justify it, see design spec), pilot colour differentiation (P1 cyan/P2 amber) added along the way. Scoped to LAN + exactly 2 players; UDP auto-discovery, couch co-op’s camera, and online/dedicated-server play (M-D) are explicitly deferred.V01_LOCK_COOPunchanged (true) — none of this is reachable from the shipping v0.1 build. Known limitation: boss auxiliary attack systems (missiles/rockets/bombs/zones/webs/funzones) don’t sync to the client this cycle (seenet/mp_protocol.gd’s scope note).
- Step 5: Commit
git add CLAUDE.mdgit commit -m "docs: M-B+M-C networked multiplayer landed (LAN, 2 players)"Task 12: Reconcile back to main (open question — flagged, not resolved by this plan)
Section titled “Task 12: Reconcile back to main (open question — flagged, not resolved by this plan)”This task is deliberately left open-ended. The design spec locked isolation (build in a worktree) but did not lock exactly when or how the merge back to main happens — that depends on how Task 11’s real-device pass feels and what else has landed on main in the meantime (per this repo’s reconcile-isolated-refactor skill, built exactly for “land a large structural change back onto a main someone else is actively committing to”). Whoever executes this task should:
- Step 1: Check what’s landed on
mainsince Task 1’s worktree branch point
git log main --oneline -- sim.gd main.gd | head -20Compare against this worktree’s own changes to sim.gd/main.gd — this plan’s tasks were designed to avoid touching sim.gd at all and to touch main.gd only at well-isolated seams (the tick-call branch in Task 9 Step 3, camera/HUD scoping in Task 9 Step 4), specifically so a main.gd conflict is small and mechanical rather than structural — but verify this holds given whatever else has actually landed by the time this task runs.
-
Step 2: If
mainhas diverged non-trivially, invoke thereconcile-isolated-refactorskill rather than a baregit merge— it’s built for exactly this repo’s pattern of concurrent worktree sessions. -
Step 3: If
mainis unchanged or trivially compatible, merge directly and re-run the full suite + determinism baseline onmainpost-merge before considering this milestone done.
No commit template given here — the right merge commit message depends on which path Step 2/3 takes.
Self-review
Section titled “Self-review”Spec coverage: every locked decision and architecture element from docs/superpowers/specs/2026-07-02-networked-multiplayer-mb-mc-design.md maps to a task — isolation (Task 1), pure protocol (Task 2), host session + input merge + snapshot broadcast (Task 3), client-as-receive-buffer + local prediction (Task 4), dev dual-instance loop (Task 5, verified in Task 6), level-up round trip (Task 7), pilot colour (Task 8, a confirmed real gap folded in during design review), host/join UX + camera/HUD scoping (Task 9), disconnect handling (Task 10, host-side already covered in Task 3), real-device verification + docs (Task 11), and the explicitly-open reconciliation step (Task 12, matching the spec’s own silence on exact merge timing).
Known scope simplification beyond what the spec called out: the spec’s Protocol section says “full-state, not delta” without enumerating exactly which Sim state; Task 2 narrows this concretely to the EntityPool-family pools + pilots, explicitly excluding the Array[Dictionary]-based boss-auxiliary systems (bombs/zones/webs/missiles/rockets/funzones/powerups/drones/weapon_pickups). This is flagged in Task 2’s own scope note and again in Task 11’s CLAUDE.md update — not a silent gap, but worth Chris’s explicit awareness since it wasn’t spelled out at the design-review stage. If this matters more than expected once Task 11’s real-device pass reaches a boss (BOSS_FIRST_TIME = 210s in), extending mp_protocol.gd to cover them is a bounded, mechanical follow-up using the same hand-listed-field pattern as Task 2.
Placeholder scan: no TBD/TODO left unresolved except two explicitly-flagged “read the real code before finalizing this exact line” call-outs (Task 2 Step 1’s enemies.add() signature, Task 3’s stray placeholder line explicitly flagged and corrected within the same step, Task 8 Step 3’s “locate the exact tier-colour application line” and Task 9’s “confirm the real footer-card helper name/tick-call site/_return_to_menu() name”) — these are deliberate “verify against live code, don’t trust a paraphrase” instructions rather than unresolved design gaps, consistent with this project’s own established practice (the M-A plan’s Task 11 has the same style of “confirm the real structure first” instruction for InputRouter).
Type consistency: InputState, PlayerState, EnemyPool/ProjPool/EnemyProjPool/EntityPool, Sim.pilots/add_pilot/apply_upgrade, MpProtocol/MpHost/MpClient method names and signatures are used identically across all tasks that reference them (cross-checked while writing: local_pilot_index is introduced once in Task 9 Step 2 and reused with the same meaning in Steps 3-4; MpProtocol.snapshot_from_sim/apply_snapshot signatures from Task 2 are called unchanged in Tasks 3, 4, 6, 9).