Skip to content

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.

  • /sim files stay extends RefCounted with 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 in net/.
  • 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 existing inputs: 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 against tests/test_*.gd’s file count (or run scripts/check-test-count.sh) — a Parse Error in a new test file silently drops it from the run.
  • main.V01_LOCK_COOP stays true throughout 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 main directly, because main may receive concurrent v0.1-launch-blocker fixes during this cycle (confirmed live: .claude/worktrees/elite-enemy-rework currently 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.

Files: none (git operations only).

  • Step 1: Create the worktree and branch
Terminal window
cd /Users/chris/Claude/bullet-heaven
git worktree add .claude/worktrees/networked-multiplayer-mb-mc -b worktree-networked-multiplayer-mb-mc

Matches 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
Terminal window
cd .claude/worktrees/networked-multiplayer-mb-mc
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit

Expected: 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 — all static funcs on a RefCounted class, 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
tests/test_mp_protocol.gd
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 MpProtocol
extends 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
Terminal window
git add net/mp_protocol.gd tests/test_mp_protocol.gd
git 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() — a Node. 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 — call send_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
tests/test_mp_host.gd
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 MpHost
extends 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 := 3
const DEFAULT_PORT := 8910
var sim: Sim = null
var _tick_count := 0
var _client_peer_id := -1
var _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
Terminal window
git add net/mp_host.gd tests/test_mp_host.gd
git 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 /sim method, sim/player_state.gd:49).

  • Produces: MpClient.new() — a Node. 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

tests/test_mp_client.gd
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 MpClient
extends 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 := 8910
const local_pilot_index := 1 # this cycle: host is always pilots[0], the one joining client is always pilots[1]
var sim: Sim = null
var 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
Terminal window
git add net/mp_client.gd tests/test_mp_client.gd
git 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).

tests/test_mp_dev_autostart.gd
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
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
godot --headless --path . --quit-after 120

Expected: 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
Terminal window
git add main.gd tests/test_mp_dev_autostart.gd
git 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
Terminal window
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).

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 calls UpgradeSystem.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 at sim/upgrade_system.gd:192 — note apply_upgrade lives on UpgradeSystem, not directly on Sim; 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 signal MpClient.levelup_offered(choice_ids: Array[String]) that Task 9’s UI wiring connects to LevelUpPanel.show_choices(choices).

  • Step 1: Write the failing test for the pure dispatch logic

tests/test_mp_levelup_roundtrip.gd
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
Terminal window
git add net/mp_host.gd net/mp_client.gd tests/test_mp_levelup_roundtrip.gd
git commit -m "feat(net): level-up choice round trip over the LAN session (M-B)"

Files:

  • Modify: render/player_renderer.gd
  • Modify: main.gd:1342 area (_spawn_player2_render())
  • Test: tests/test_player_renderer_tint.gd

Interfaces:

  • Produces: PlayerRenderer.tint: Color = Color.WHITE (public var, set before add_child — same pattern as the existing baked_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
tests/test_player_renderer_tint.gd
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 tint field 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.WHITE

Find 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
Terminal window
git add render/player_renderer.gd main.gd tests/test_player_renderer_tint.gd
git 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, gated V01_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.gd if update_hud(sim: Sim)’s signature needs a pilot-index parameter (confirm current signature at ui/hud.gd:225 before deciding — it currently hardcodes sim.player, so it needs to become update_hud(sim: Sim, pilot_index: int = 0) reading sim.pilots[pilot_index] instead)

Interfaces:

  • Consumes: MpHost/MpClient (Tasks 3, 4, 7), StartMenu (existing signals mode_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_COOP

Add 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 = null
var _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 = 1

Add 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].pos
else:
# 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
Terminal window
git add ui/start_menu.gd ui/hud.gd main.gd
git commit -m "feat(ui): host/join screen + per-device camera/HUD scoping for LAN co-op (M-C)"

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_disconnected signal.

  • Step 1: Write the failing test

# append to tests/test_mp_client.gd
func 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
Terminal window
git add net/mp_client.gd main.gd tests/test_mp_client.gd
git 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

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
scripts/check-test-count.sh

Expected: exit 0 both, script count matches tests/test_*.gd file count.

  • Step 2: Headless boot smoke
Terminal window
godot --headless --path . --quit-after 120

Expected: 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 over ENetMultiplayerPeer (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_COOP unchanged (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 (see net/mp_protocol.gd’s scope note).

  • Step 5: Commit
Terminal window
git add CLAUDE.md
git 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 main since Task 1’s worktree branch point
Terminal window
git log main --oneline -- sim.gd main.gd | head -20

Compare 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 main has diverged non-trivially, invoke the reconcile-isolated-refactor skill rather than a bare git merge — it’s built for exactly this repo’s pattern of concurrent worktree sessions.

  • Step 3: If main is unchanged or trivially compatible, merge directly and re-run the full suite + determinism baseline on main post-merge before considering this milestone done.

No commit template given here — the right merge commit message depends on which path Step 2/3 takes.


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).