Skip to content

Multiplayer Sim Foundation (M-A) Implementation Plan

Multiplayer Sim Foundation (M-A) Implementation Plan

Section titled “Multiplayer Sim Foundation (M-A) 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: Generalize the sim from a single player (plus a stripped-down player2 co-pilot bolt-on) into an array of N independent, fully-featured “pilots” — each with their own position/HP, weapon arsenal, XP/level/build, dash, and drone — while keeping the existing single-player determinism baseline byte-identical.

Architecture: Grow the existing player2 == null seam into a real pilots: Array[PlayerState] array, one careful step at a time. Each step keeps the sim compiling and the full test suite green (including the pinned determinism/checksum baseline) before moving to the next. Weapon ownership moves from singular Sim fields into a new PilotArsenal object attached to each PlayerState, so every pilot — not just P1 — has an independent build. This plan is transport-agnostic: no networking code is touched. It supersedes and fixes the existing local co-op’s dash/drone bug as a side effect of giving every pilot a real, full-featured InputState.

Tech Stack: Godot 4.6 / GDScript, GUT 9.6.0 test framework (headless).

  • Determinism baseline must stay byte-identical for the single-pilot case at every step. Re-run tests/test_determinism.gd and tests/test_determinism_checksum.gd after every task; the pinned literals (snapshot_string().hash() and state_checksum()) must not change until Task 10, which explicitly re-verifies and (only if genuinely required) re-pins them.
  • /sim stays pure — RefCounted only, no Node/Engine/File/Input APIs. Every new class introduced here (PilotArsenal) follows the same rule as PlayerState/Sim.
  • Two RNG streams stay two RNG streams. rng (spawns/sim) and upgrade_rng (upgrade rolls) must not merge or change draw order for the single-pilot path.
  • Test-count guard: after adding any new test file, run scripts/check-test-count.sh — GUT silently drops mis-named test files from a -gdir run, and only the count guard catches it.
  • File/module locations in this plan describe current responsibilities (e.g. “the Sim core module,” “the PlayerState module”), not guaranteed exact paths — a separate branch is currently modularizing this codebase, so before each task, locate the current file that owns the responsibility described (grep for the class/function names given, which are stable identifiers) rather than assuming today’s sim/sim.gd path.
  • No behavior changes to rendering/UI in this plan. main.gd/render/* wiring is touched only in Task 11, and only for local co-op input plumbing — not for per-pilot camera/HUD, which is explicitly M-C’s job per the design spec.
  • This plan implements only M-A from docs/superpowers/specs/2026-06-30-networked-multiplayer-design.md. M-B (LAN transport), M-C (per-device view/lobby), and M-D (dedicated server) are separate, later plans.

Current-state facts this plan is built on (verified by codebase exploration, 2026-07-02)

Section titled “Current-state facts this plan is built on (verified by codebase exploration, 2026-07-02)”
  • PlayerState (RefCounted, ~49 lines) already holds pos, hp, max_hp, xp, level, xp_to_next, damage_mult, armor, dash_cd, dash_timer, dash_dir, iframe_timer, decoy_power_mult, decoy_life_mult, ... — i.e. it’s already a reasonable per-pilot data container. Its only behavior method is integrate(input, dt).
  • Sim.player: PlayerState is the sole full pilot today. Sim.player2: PlayerState = null is a stripped-down co-pilot bolt-on: it moves and takes contact/enemy-proj damage (via a separate, buggy _check_player2_hit() that skips armor/iframe/dev_invuln), it fires one shared manual “aim” shot (_fire_aim2, reusing global aim_element_idx/AIM_COOLDOWN), and its gem pickups bank into the shared XP pool — it has no independent arsenal, no independent XP, and enemies never target it.
  • All weapon ownership is on Sim, not PlayerState: weapon (pulse), nova, orbit, beam, turret, scatter, blade, active_weapon_ids: Array[String], _weapon_by_id: Dictionary, _weapon_levels: Dictionary, locked_weapons: Dictionary, _thresholds_done: Dictionary are singular Sim fields. This is the single biggest structural change M-A must make.
  • ~90 call sites in the Sim core module read player.pos directly — spawn-ring/spawn-point functions, every enemy chase/dash/skirmish/ghost/rush behavior, and all 5 boss update loops (_update_boss, _update_boss2, _update_funzo, _update_graviton, _update_eye). None reference player2 at all. A _nearest_drone_pos(p: Vector2) helper already exists as a precedent for “nearest X of an arbitrary query point” and is reused at several of those sites for drone-priority targeting.
  • Game-over is two code paths: _check_player_hit sets game_over = true only if player2 == null; a separate branch inside tick() sets it when player.hp <= 0.0 and player2.hp <= 0.0.
  • snapshot_string() and state_checksum() read only player fields, never player2 — both need to loop over pilots for parity, without changing the 1-pilot output.
  • Sim.tick(input: InputState, input2: InputState = null) is the current signature. The full ordered phase list inside tick() (condensed): clear per-tick events → decoy synergy → player(s) integrate → dash/regen (P1-only today) → spawn → enemy movement → boss updates → wall resolve → web drop → hash rebuild → weapon updates (for wid in active_weapon_ids: ... + _fire_aim) → _fire_aim2 (P2-only) → drone update/damage → projectile movement → ranged-enemy updates → collision resolve → status/decay → sweep dead → gem collection (includes P2’s shared-pool pickup) → weapon-pickup/powerup collection → wormhole update → _check_player_hit_check_player2_hit + both-down game-over → decoy recharge.
  • InputState (RefCounted) has move_dir, aim_dir, decoy: bool (edge), dash: bool (edge). InputRouter.poll() is the only path that produces real dash/decoy edges; InputRouter.poll_device(device) — used for P2 — hardcodes decoy=false, dash=false by design (“no dash/decoy in the co-pilot MVP”). main.gd’s co-op join path then overwrites P1’s input with poll_device(0) too once P2 exists, which is the documented bug: P1 permanently loses dash + drone the moment P2 joins.
  • Rendering already has a second PlayerRenderer instance for P2 (player2_renderer in main.gd), driven by a shared-midpoint camera — this camera logic is explicitly superseded by M-C, not touched here.

Task 1: pilots array as a computed view + nearest-pilot helper

Section titled “Task 1: pilots array as a computed view + nearest-pilot helper”

No storage changes yet — this task introduces the query surface everything else will use, backed by the existing player/player2 fields, so it is risk-free to land first.

Files:

  • Modify: the Sim core module (owns player, player2, and will own the new helper)
  • Test: a new sim test file for pilot-query behavior

Interfaces:

  • Produces: Sim.pilots() -> Array[PlayerState] — returns [player] when player2 == null, else [player, player2]. (A method, not a stored field, in this task — Task 9 promotes it to real backing storage.)

  • Produces: Sim._nearest_pilot(from: Vector2) -> PlayerState — returns the single pilot when pilots().size() == 1; otherwise returns whichever alive pilot (hp > 0.0) is closest to from by distance_squared_to; if all pilots are dead, returns pilots()[0] (arbitrary but stable — nothing should be targeting a fully-dead world anyway).

  • Step 1: Write the failing tests

tests/test_pilots_query.gd
extends GutTest
func test_pilots_single_player_returns_just_player() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
assert_eq(sim.pilots().size(), 1)
assert_eq(sim.pilots()[0], sim.player)
func test_pilots_includes_player2_once_added() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.add_player2()
assert_eq(sim.pilots().size(), 2)
assert_eq(sim.pilots()[1], sim.player2)
func test_nearest_pilot_single_pilot_always_returns_it() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.player.pos = Vector2(500, 500)
assert_eq(sim._nearest_pilot(Vector2.ZERO), sim.player)
func test_nearest_pilot_picks_closer_alive_pilot() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.add_player2()
sim.player.pos = Vector2(1000, 0)
sim.player2.pos = Vector2(10, 0)
assert_eq(sim._nearest_pilot(Vector2.ZERO), sim.player2)
func test_nearest_pilot_skips_dead_pilot() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.add_player2()
sim.player.pos = Vector2(1000, 0)
sim.player2.pos = Vector2(10, 0)
sim.player2.hp = 0.0
assert_eq(sim._nearest_pilot(Vector2.ZERO), sim.player)
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_pilots_query.gd -gexit Expected: FAIL — Invalid call. Nonexistent function 'pilots' in base 'Sim' (or similar for _nearest_pilot).

  • Step 3: Implement pilots() and _nearest_pilot()

Add to the Sim core module, near the existing add_player2():

func pilots() -> Array[PlayerState]:
var result: Array[PlayerState] = [player]
if player2 != null:
result.append(player2)
return result
func _nearest_pilot(from: Vector2) -> PlayerState:
var all := pilots()
if all.size() == 1:
return all[0]
var best: PlayerState = null
var best_d := INF
for p in all:
if p.hp <= 0.0:
continue
var d := from.distance_squared_to(p.pos)
if d < best_d:
best_d = d
best = p
return best if best != null else all[0]
  • Step 4: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_pilots_query.gd -gexit Expected: PASS, all 5 tests.

  • Step 5: Run the full suite + determinism baseline, verify untouched

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: all scripts pass, same script count as before (scripts/check-test-count.sh to confirm the new file wasn’t silently dropped), and test_determinism_checksum.gd’s pinned literals unchanged (nothing in tick() was touched).

  • Step 6: Commit
Terminal window
git add tests/test_pilots_query.gd
git commit -m "feat(sim): add pilots() query + nearest-pilot helper (M-A task 1)"

Every spawn-origin function (_spawn_point, _spawn_boss_adds, _spawn_swarm_burst, _spawn_due_elites, _spawn_phase_boss, and any sibling spawn-origin function that reads player.pos) switches to _nearest_pilot(...). For the single-pilot case this is a no-op (Task 1’s _nearest_pilot returns player when pilots().size() == 1), so the determinism baseline is provably unaffected — this is purely a co-op enablement change.

Files:

  • Modify: the Sim core module’s spawn functions

Interfaces:

  • Consumes: Sim._nearest_pilot(from: Vector2) -> PlayerState (Task 1)

  • Produces: no new public interface — behavior-only change to existing private spawn functions

  • Step 1: Write a failing co-op spawn test

# tests/test_pilots_query.gd (append)
func test_spawn_point_targets_nearest_pilot_in_coop() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.add_player2()
sim.player.pos = Vector2(5000, 5000)
sim.player2.pos = Vector2.ZERO
var p := sim._spawn_point()
# The spawn point is on the arena border in the direction rng picked, but
# relative to whichever pilot _spawn_point() used as origin. With player2
# at the origin and player far away, the offset from ORIGIN must be within
# one arena-diagonal's distance if player2 was used; if player.pos (5000,5000)
# was still used as the base, the returned point would be offset from THAT,
# putting it far outside the arena+margin bound around the origin.
var arena_bound := Sim_Const.ARENA_HALF + 200.0 # SPAWN_BORDER_MARGIN plus slack
assert_true(p.length() <= arena_bound * 1.5, "spawn point should be near player2 (nearest pilot), not player (far pilot)")
  • Step 2: Run test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_pilots_query.gd -gexit Expected: FAIL — the returned point is offset from player.pos = (5000,5000), well outside the bound.

  • Step 3: Replace player.pos with _nearest_pilot(...).pos in spawn-origin functions

In each spawn-origin function, replace the base-position read. Example for _spawn_point:

func _spawn_point() -> Vector2:
var edge := Sim_Const.ARENA_HALF + SPAWN_BORDER_MARGIN
var dir := rng.rand_unit_dir()
var p := _nearest_pilot(player.pos).pos # was: var p := player.pos
...

Note the query point passed to _nearest_pilot here is arbitrary when there’s more than one pilot and no obvious “from” — using player.pos as the query anchor is fine because with 2 pilots this just needs a reasonable reference point to rank distance from; it does not affect the 1-pilot path (where _nearest_pilot ignores its argument and returns player directly). Apply the same one-line substitution — player.pos_nearest_pilot(player.pos).pos — at each of: _spawn_boss_adds, _spawn_swarm_burst, _spawn_due_elites, _spawn_phase_boss, and any other function whose sole purpose is choosing a spawn origin around “the player.”

Do not touch _spawn_one(tid, pos) — it already takes an explicit pos and is target-agnostic.

  • Step 4: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_pilots_query.gd -gexit Expected: PASS.

  • Step 5: Run full suite + determinism baseline

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: all green, determinism baseline literals unchanged (1-pilot path takes the same _nearest_pilot early-return as before, same RNG draw order).

  • Step 6: Commit
Terminal window
git add -A
git commit -m "feat(sim): spawn origins target nearest pilot instead of hardcoded player (M-A task 2)"

Task 3: Nearest-pilot enemy chase/dash targeting

Section titled “Task 3: Nearest-pilot enemy chase/dash targeting”

Every enemy-movement function that computes a direction/target toward “the player” (walk-chase, dash-charge, skirmish strafing, ghost teleport-strike, rush lunge, shooter aim-prediction) switches its player.pos read to _nearest_pilot(enemies.pos[i]).pos, mirroring the existing _nearest_drone_pos(...) precedent already used for drone-priority targeting at several of these same sites.

Files:

  • Modify: the Sim core module’s enemy-movement functions (_move_enemies and its behavior-specific helpers)

Interfaces:

  • Consumes: Sim._nearest_pilot(from: Vector2) -> PlayerState (Task 1)

  • Step 1: Write a failing co-op chase test

# tests/test_pilots_query.gd (append)
func test_walk_enemy_chases_nearest_pilot() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.add_player2()
sim.player.pos = Vector2(5000, 0)
sim.player2.pos = Vector2.ZERO
sim.enemies.add(Vector2(100, 0), 0, 10.0, 10.0, 5.0, 5.0) # swarmer-like walk enemy at (100,0)
sim._move_enemies(1.0 / 60.0)
# A walk enemy at (100,0) chasing player2 at (0,0) should move toward -X;
# chasing the far player at (5000,0) would move toward +X.
assert_true(sim.enemies.pos[0].x < 100.0, "enemy should move toward the NEAR pilot (player2), not the far one")

Adjust the enemies.add(...) call’s exact argument list to match the real EnemyPool.add signature (columns: pos, type_id, radius, hp, speed, contact_dmg, … — check the current signature before writing this step, since EnemyPool has grown columns over many cycles; use whichever call already appears in a neighboring test in the same test file for the exact argument order and a TYPE_SWARMER-equivalent walk-behavior enemy).

  • Step 2: Run test to verify it fails

Expected: FAIL — enemy moves toward +X (the far player) because _move_enemies still hardcodes player.pos.

  • Step 3: Replace player.pos with nearest-pilot lookup in enemy-movement code

Example for the walk-behavior branch inside _move_enemies:

# was:
# var target := _nearest_drone_pos(enemies.pos[i]) if not drones.is_empty() else player.pos
var target := _nearest_drone_pos(enemies.pos[i]) if not drones.is_empty() else _nearest_pilot(enemies.pos[i]).pos

Apply the equivalent one-line substitution — player.pos_nearest_pilot(enemies.pos[i]).pos (or _nearest_pilot(pos).pos using whatever the local enemy-position variable is named at that call site) — at every remaining to := player.pos - enemies.pos[i]-shaped direction computation across the dash/skirmish/ghost/rush behavior branches, and at the shooter aim-prediction site that reads player.pos/player_vel. Drone-priority sites that already branch on drones.is_empty() keep that branch, only substituting the else arm.

  • Step 4: Run tests to verify they pass

Expected: PASS.

  • Step 5: Run full suite + determinism baseline

Expected: all green, baseline literals unchanged (single pilot ⇒ _nearest_pilot returns player unconditionally, identical to today’s direct read).

  • Step 6: Commit
Terminal window
git add -A
git commit -m "feat(sim): enemy chase/dash behaviors target nearest pilot (M-A task 3)"

Task 4: Nearest-pilot boss attack targeting

Section titled “Task 4: Nearest-pilot boss attack targeting”

All 5 boss update loops (_update_boss, _update_boss2, _update_funzo, _update_graviton, _update_eye) and the boss-homing-missile function switch their player.pos reads to _nearest_pilot(...).

Files:

  • Modify: the Sim core module’s boss-state update functions

Interfaces:

  • Consumes: Sim._nearest_pilot(from: Vector2) -> PlayerState (Task 1)

  • Step 1: Write a failing co-op boss-targeting test

# tests/test_pilots_query.gd (append)
func test_boss_targets_nearest_pilot() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.add_player2()
sim.player.pos = Vector2(5000, 0)
sim.player2.pos = Vector2.ZERO
sim.run_time = Sim.BOSS_FIRST_TIME # force boss-eligible time so a boss can be spawned
sim._spawn_boss() # or the current boss-spawn entry point; confirm exact name before writing this step
# Advance enough ticks for the boss's approach phase to move it noticeably.
for i in range(60):
sim._update_boss(1.0 / 60.0)
var boss_idx := sim._boss_index()
assert_true(sim.enemies.pos[boss_idx].distance_to(sim.player2.pos) < sim.enemies.pos[boss_idx].distance_to(sim.player.pos),
"boss should have approached the nearer pilot (player2)")

Confirm the exact boss-spawn entry-point name (_spawn_boss vs a wrapper like _maybe_spawn_survival_boss) and BOSS_FIRST_TIME’s exact constant name against the current code before writing this step — CLAUDE.md documents this constant was renamed/retuned across several cycles (BOSS_FIRST_TIME 40 → 210 as of the pacing rework).

  • Step 2: Run test to verify it fails

Expected: FAIL — boss approaches the far player.

  • Step 3: Replace player.pos with nearest-pilot lookup in each boss update function

Same one-line substitution pattern as Task 3, applied inside _update_boss, _update_boss2, _update_funzo, _update_graviton, _update_eye, and the boss-homing-missile movement function (the site using the same _nearest_drone_pos(...)-or-player.pos pattern noted in the exploration). Each boss’s telegraph/attack-direction calculation that reads player.pos gets the identical substitution.

  • Step 4: Run tests to verify they pass

Expected: PASS.

  • Step 5: Run full suite + determinism baseline

Expected: all green, baseline unchanged.

  • Step 6: Commit
Terminal window
git add -A
git commit -m "feat(sim): boss attacks target nearest pilot (M-A task 4)"

Task 5: PilotArsenal — move weapon ownership off Sim and onto each pilot

Section titled “Task 5: PilotArsenal — move weapon ownership off Sim and onto each pilot”

The largest structural task. Introduce a new pure-data-plus-behavior class, PilotArsenal, holding everything that is today a singular Sim field: the weapon instances, active_weapon_ids, and the bookkeeping dictionaries. Attach one PilotArsenal to player. Keep every existing sim.weapon, sim.active_weapon_ids, etc. read/write working via forwarding accessors on Sim, so this task changes where the data lives without changing any external call site yet (those get cleaned up as dead forwarding code only after Task 9, once nothing needs it — out of scope for this plan; leaving the forwarders in place is intentionally low-risk).

Files:

  • Create: a new arsenal module (e.g. sim/pilot_arsenal.gd, class_name PilotArsenal extends RefCounted)
  • Modify: the Sim core module (weapon field declarations + _init)
  • Test: a new arsenal ownership test file

Interfaces:

  • Produces:

    class_name PilotArsenal extends RefCounted
    var blade: WeaponMelee
    var blade_element_idx: int = -1
    var weapon: WeaponPulse # "pulse"
    var pulse_element_idx: int = -1
    var nova: WeaponNova
    var nova_element_idx: int = -1
    var orbit: WeaponOrbit
    var orbit_element_idx: int = -1
    var beam: WeaponBeam
    var beam_element_idx: int = -1
    var turret: WeaponTurret
    var scatter: WeaponScatter
    var scatter_element_idx: int = -1
    var aim_element_idx: int = -1
    var eye_element_idx: int = -1
    var aim_timer: float = 0.0
    var active_weapon_ids: Array[String] = []
    var weapon_by_id: Dictionary = {}
    var locked_weapons: Dictionary = {}
    var weapon_levels: Dictionary = {}
    var thresholds_done: Dictionary = {}
    func _init(content: ContentDB) -> void:
    blade = WeaponMelee.new(content.weapon("blade"))
    weapon = WeaponPulse.new(content.weapon("pulse"))
    # ... construct nova/orbit/beam/turret/scatter identically to today's Sim._init
    weapon_by_id = {"blade": blade, "pulse": weapon, "nova": nova, "orbit": orbit, "beam": beam, "turret": turret, "scatter": scatter}
    active_weapon_ids = ["blade"]
  • Consumes on PlayerState: a new field var arsenal: PilotArsenal (assigned by whoever constructs the pilot — Sim._init for player, and Task 6’s pilot-construction helper for every additional pilot).

  • Step 1: Write a failing test asserting arsenal ownership

tests/test_pilot_arsenal.gd
extends GutTest
func test_player_owns_its_own_arsenal() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
assert_not_null(sim.player.arsenal)
assert_eq(sim.player.arsenal.active_weapon_ids, ["blade"])
assert_eq(sim.player.arsenal.weapon_by_id["pulse"], sim.player.arsenal.weapon)
func test_sim_forwarding_accessors_still_read_player_arsenal() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
# Old call sites throughout render/main.gd read sim.weapon, sim.active_weapon_ids, etc.
# directly — these must keep working, now forwarding to player.arsenal.
assert_eq(sim.weapon, sim.player.arsenal.weapon)
assert_eq(sim.active_weapon_ids, sim.player.arsenal.active_weapon_ids)
  • Step 2: Run tests to verify they fail

Expected: FAIL — PlayerState has no arsenal field yet.

  • Step 3: Create PilotArsenal, add PlayerState.arsenal, construct it in Sim._init

Create the arsenal module with the constructor shown in Interfaces above (copy the exact per-weapon construction lines — WeaponPulse.new(content.weapon("pulse")) etc. — verbatim from Sim._init’s current weapon-construction block; do not re-derive the constructor arguments, since each Weapon* class’s constructor signature is specific to that weapon and any deviation from what Sim._init already does will change spawn-time weapon defaults).

Add to PlayerState:

var arsenal: PilotArsenal = null

In Sim._init, after constructing player = PlayerState.new():

player.arsenal = PilotArsenal.new(content)

Leave every existing Sim-level weapon field declaration (var weapon: WeaponPulse, var active_weapon_ids: Array[String] = [], etc.) in place for now, but stop constructing them directly in _init — instead turn each into a forwarding property:

var weapon: WeaponPulse:
get: return player.arsenal.weapon
var active_weapon_ids: Array[String]:
get: return player.arsenal.active_weapon_ids
set(value): player.arsenal.active_weapon_ids = value
# ... same forwarding pattern for nova, orbit, beam, turret, scatter, blade,
# nova_element_idx, pulse_element_idx, orbit_element_idx, beam_element_idx,
# scatter_element_idx, blade_element_idx, aim_element_idx, eye_element_idx,
# _aim_timer, _weapon_by_id, locked_weapons, _weapon_levels, _thresholds_done

Every one of these needs both a getter and (where the original was ever assigned outside _init, e.g. _weapon_levels[wid] = ... or locked_weapons.erase(id)) a setter, or — simpler and safer for Dictionary/Array fields, which are reference types in GDScript — just the getter, since mutating methods like .erase()/[]= on the returned Dictionary/Array mutate the same underlying object player.arsenal holds. Only genuinely reassigned fields (active_weapon_ids = [...] wholesale, if any such site exists) need an explicit setter forwarding to player.arsenal.active_weapon_ids = value.

  • Step 4: Run tests to verify they pass

Expected: PASS.

  • Step 5: Run full suite + determinism baseline

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: This is the highest-risk step in the whole plan — every existing weapon test, upgrade test, and the determinism baseline must be re-verified byte-for-byte, since weapon construction order (and therefore RNG-adjacent state, if any weapon constructor touches rng) must be identical. If anything diverges, the bug is almost certainly a forwarding accessor that returns a copy instead of the same reference, or a construction-order difference between the old inline _init code and the new PilotArsenal._init — diff the two side-by-side line-by-line rather than guessing.

  • Step 6: Commit
Terminal window
git add sim/pilot_arsenal.gd tests/test_pilot_arsenal.gd
git commit -m "feat(sim): introduce PilotArsenal, move weapon ownership onto PlayerState (M-A task 5)"

Task 6: Every pilot gets a real arsenal — retire the P2 shared-shot special case

Section titled “Task 6: Every pilot gets a real arsenal — retire the P2 shared-shot special case”

add_player2() constructs a fresh PilotArsenal for player2 (instead of leaving it null), and the weapon-update loop in tick() runs for every pilot, not just player. This retires _fire_aim2/_aim_timer2 entirely — P2 becomes a genuine second pilot with its own independently-acquirable weapon build, which is the actual point of this milestone.

Files:

  • Modify: the Sim core module (add_player2, tick()’s weapon-update section, deletion of _fire_aim2/_aim_timer2)
  • Test: extend the arsenal test file

Interfaces:

  • Consumes: PilotArsenal._init(content: ContentDB) (Task 5)

  • Produces: add_player2() now leaves player2.arsenal non-null; the per-pilot weapon-update loop is now for pilot in pilots(): if pilot.hp > 0.0: for wid in pilot.arsenal.active_weapon_ids: pilot.arsenal.weapon_by_id[wid].update(self, pilot, dt) — note the added pilot parameter, since weapon update() methods currently reach into sim.player.pos internally and must instead read whichever pilot they were invoked for.

  • Step 1: Write a failing test — P2 has and can fire its own arsenal

# tests/test_pilot_arsenal.gd (append)
func test_player2_gets_own_arsenal_on_join() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.add_player2()
assert_not_null(sim.player2.arsenal)
assert_eq(sim.player2.arsenal.active_weapon_ids, ["blade"])
assert_ne(sim.player2.arsenal, sim.player.arsenal, "P2 must have an INDEPENDENT arsenal instance, not share P1's")
func test_player2_weapon_fires_from_player2_position() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.add_player2()
sim.player.pos = Vector2(5000, 5000) # far away, keep P1's blade from reaching anything relevant
sim.player2.pos = Vector2.ZERO
sim.enemies.add(Vector2(20, 0), 0, 10.0, 10.0, 5.0, 5.0) # within P2's blade reach, not P1's
var hp_before := sim.enemies.hp[0] if sim.enemies.count > 0 else 0.0
for i in range(10):
sim.tick(InputState.new(Vector2.ZERO), InputState.new(Vector2.ZERO))
assert_true(sim.enemies.count == 0 or sim.enemies.hp[0] < hp_before, "P2's own blade should have damaged the nearby enemy")

Confirm the exact EnemyPool.add(...) argument order against a neighboring existing test before finalizing this step.

  • Step 2: Run tests to verify they fail

Expected: FAIL — player2.arsenal is null (add_player2 doesn’t construct one yet), and no per-pilot weapon-update loop exists yet.

  • Step 3: Update add_player2() to construct an arsenal
func add_player2() -> void:
if player2 != null:
return
player2 = PlayerState.new()
player2.pos = player.pos + Vector2(70, 0)
player2.clamp_arena = player.clamp_arena
player2.arsenal = PilotArsenal.new(content) # new line
  • Step 4: Change weapon update() signatures to take an explicit pilot, and loop over all pilots in tick()

Each Weapon*.update(sim, dt) method’s internal sim.player.pos/sim.player.damage_mult reads become a pilot.pos/pilot.damage_mult parameter instead. Change every weapon class’s signature to func update(sim: Sim, pilot: PlayerState, dt: float) -> void and replace its internal sim.player.* reads with pilot.*.

Replace the single-pilot weapon-update block in tick():

# was:
# if player.hp > 0.0:
# for wid in active_weapon_ids:
# _weapon_by_id[wid].update(self, dt)
# _fire_aim(input, dt)
# (P2 branch further down called _fire_aim2(input2, dt) separately)
# becomes:
var _pilot_inputs := [input, input2] # index-aligned with pilots() for now; Task 9 makes this real
var _pi := 0
for pilot in pilots():
if pilot.hp > 0.0:
for wid in pilot.arsenal.active_weapon_ids:
pilot.arsenal.weapon_by_id[wid].update(self, pilot, dt)
var pilot_input: InputState = _pilot_inputs[_pi] if _pi < _pilot_inputs.size() else null
if pilot_input != null:
_fire_aim(pilot, pilot_input, dt)
_pi += 1

Change _fire_aim to take an explicit pilot: PlayerState first parameter (mirroring the weapon-update change) and replace its internal player.pos/player.damage_mult/effective_fire_rate(player) reads with pilot.*; it also needs its own per-pilot aim_timer — use pilot.arsenal.aim_timer (already declared on PilotArsenal in Task 5) instead of the old singular _aim_timer.

Delete _fire_aim2 and the singular _aim_timer2 field entirely — the generalized _fire_aim(pilot, pilot_input, dt) call inside the loop above now covers P2 for free.

  • Step 5: Run tests to verify they pass

Expected: PASS.

  • Step 6: Run full suite + determinism baseline

Expected: all green. The 1-pilot path must produce byte-identical output — pilots() still returns [player] only, the loop runs exactly once with pilot == player and pilot_input == input, which is what tick() did before, just expressed as a 1-iteration loop instead of inline code.

  • Step 7: Commit
Terminal window
git add -A
git commit -m "feat(sim): every pilot fires from its own arsenal, retire the P2 shared-shot special case (M-A task 6)"

Task 7: Per-pilot dash/regen + unified hit-check and game-over

Section titled “Task 7: Per-pilot dash/regen + unified hit-check and game-over”

Generalize _update_dash, _apply_regen, and the hit-check pair (_check_player_hit / _check_player2_hit) into per-pilot functions run in a loop, fixing the existing bug where _check_player2_hit skips armor/iframe/dev_invuln (P2 currently takes raw, unmitigated damage). Game-over becomes “all pilots down,” not the current P1-only-else-both-down special case.

Files:

  • Modify: the Sim core module (_update_dash, _apply_regen, _check_player_hit, deletion of _check_player2_hit, tick()’s dash/regen/hit-check sections)
  • Test: extend the pilots test file

Interfaces:

  • Produces: _update_dash(pilot: PlayerState, input: InputState, dt: float) -> void, _apply_regen(pilot: PlayerState, input: InputState, dt: float) -> void, _check_pilot_hit(pilot: PlayerState) -> void (replaces both _check_player_hit/_check_player2_hit with one shared, armor/iframe/dev_invuln-aware implementation — the existing _check_player_hit body, parameterized over pilot instead of hardcoding player).

  • Produces: game-over condition becomes pilots().all(func(p): return p.hp <= 0.0).

  • Step 1: Write failing tests for parity + the armor bug fix

# tests/test_pilots_query.gd (append)
func test_player2_armor_now_reduces_damage_like_player1() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.add_player2()
sim.player2.armor = 10.0 # 60% reduction per the 6%/point rule, capped 75%
sim.player2.pos = Vector2.ZERO
sim.enemies.add(Vector2(10, 0), 0, 30.0, 10.0, 5.0, 20.0) # a contact enemy dealing 20 raw
sim._check_pilot_hit(sim.player2)
assert_almost_eq(sim.player2.hp, 100.0 - 20.0 * 0.4, 0.01, "armor should reduce P2 damage the same way it reduces P1's")
func test_game_over_requires_all_pilots_down() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.add_player2()
sim.player.hp = 0.0
sim.player2.hp = 50.0
sim._check_pilot_hit(sim.player)
assert_false(sim.game_over, "one pilot down must not end the run while another is alive")
sim.player2.hp = 0.0
sim._check_pilot_hit(sim.player2)
assert_true(sim.game_over, "all pilots down must end the run")

Confirm the exact _check_player_hit armor-reduction formula (the 6%-per-point, 75%-cap rule documented in the meta-progression section of CLAUDE.md) against the current code before finalizing the expected value in the first test — read _check_player_hit’s current damage-reduction line rather than re-deriving it.

  • Step 2: Run tests to verify they fail

Expected: FAIL — _check_pilot_hit doesn’t exist yet; _check_player2_hit’s current bug means armor is never applied to P2.

  • Step 3: Implement _check_pilot_hit, delete _check_player2_hit

Rename _check_player_hit(dt)’s body into _check_pilot_hit(pilot: PlayerState) -> void, replacing every internal player.* read/write with pilot.* (this is the existing, correct, armor/iframe/dev_invuln-aware damage logic — do not re-derive it, just re-target it). Remove its own game-over side effect (the if player2 == null: game_over = true branch) — game-over is now computed once, after all pilots are checked, in tick().

Delete _check_player2_hit entirely (its simpler, buggy logic is now redundant and wrong).

Generalize _update_dash/_apply_regen the same way: add a pilot: PlayerState first parameter, replace internal player.* reads with pilot.*.

  • Step 4: Update tick()’s dash/regen/hit-check sections to loop over pilots
# was: separate player.integrate + player2.integrate blocks (already loop-friendly since Task 6's arsenal loop pattern), then:
# _update_dash(input, dt) # P1 only
# _apply_regen(input, dt) # P1 only
# ... (later) ...
# _check_player_hit(dt)
# if player2 != null:
# _check_player2_hit()
# if player.hp <= 0.0 and player2.hp <= 0.0:
# game_over = true
# becomes:
var _pi2 := 0
for pilot in pilots():
var pilot_input: InputState = _pilot_inputs[_pi2] if _pi2 < _pilot_inputs.size() else null
if pilot.hp > 0.0 and pilot_input != null:
_update_dash(pilot, pilot_input, dt)
_apply_regen(pilot, pilot_input, dt)
_pi2 += 1
# ... (later, replacing the old hit-check section) ...
for pilot in pilots():
_check_pilot_hit(pilot)
var all_down := true
for pilot in pilots():
if pilot.hp > 0.0:
all_down = false
break
if all_down:
game_over = true

(_pilot_inputs is the same index-aligned [input, input2] array introduced in Task 6 — reuse it rather than rebuilding it twice per tick; hoist its construction to the top of tick() once.)

  • Step 5: Run tests to verify they pass

Expected: PASS.

  • Step 6: Run full suite + determinism baseline

Expected: all green, baseline unchanged (1-pilot loop body is identical to the old inline code, just expressed as a 1-iteration loop).

  • Step 7: Commit
Terminal window
git add -A
git commit -m "fix(sim): unify per-pilot dash/regen/hit-check, fix P2's missing armor/iframe, game-over requires ALL pilots down (M-A task 7)"

Each pilot accrues its own xp/level/xp_to_next (fields already exist on PlayerState — they’ve just never been driven for player2). Gem pickups bank XP to whichever pilot collected the gem, not a shared pool. The sim exposes a per-pilot “has a pending upgrade choice” flag so callers (main.gd, later the multi-device UI in M-C) can tell which pilot needs to pick — without yet building the multi-viewport level-up UI itself, which is M-C’s job.

Files:

  • Modify: the Sim core module (_collect_gems’s P2 gem-pickup branch, the XP/level-up mechanism)
  • Test: extend the pilots test file

Interfaces:

  • Produces: PlayerState.pending_upgrade_choices: Array = [] (empty = no pending choice; populated the same way the existing single-player level-up roll populates today’s equivalent single field — locate that field/mechanism in the current leveling code and mirror its shape per-pilot rather than inventing a new format).

  • Produces: gem pickup now does pilot.xp += gem_value; if pilot.xp >= pilot.xp_to_next: <existing level-up-roll logic, retargeted at pilot> for whichever pilot’s pickup_radius reached the gem, instead of P2’s gems folding into player’s pool.

  • Step 1: Write a failing test — P2 levels up independently

# tests/test_pilots_query.gd (append)
func test_player2_gems_bank_to_player2_own_xp() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.add_player2()
sim.player.pos = Vector2(9999, 9999) # far from any gem
sim.player2.pos = Vector2.ZERO
var xp_before_p1 := sim.player.xp
var xp_before_p2 := sim.player2.xp
sim.gems.add(Vector2(5, 0), 25.0) # confirm exact GemPool.add signature against a neighboring test
sim._collect_gems()
assert_eq(sim.player.xp, xp_before_p1, "P1 must not receive XP from a gem only P2 was near")
assert_true(sim.player2.xp > xp_before_p2, "P2 must bank the gem's XP to its OWN xp, not a shared pool")
  • Step 2: Run test to verify it fails

Expected: FAIL — today’s _collect_gems P2 branch banks to the shared pool (player.xp), per the exploration findings.

  • Step 3: Retarget the gem-pickup XP banking per-pilot

Locate _collect_gems’s existing single-player XP-bank-and-level-up-roll logic (the code that runs when player’s pickup_radius reaches a gem) and extract it into a small helper, e.g. _bank_xp(pilot: PlayerState, amount: float) -> void, containing exactly that existing logic (xp accumulation + the while pilot.xp >= pilot.xp_to_next: <level up, roll choices> loop) but reading/writing pilot.* instead of player.*. Call _bank_xp(player, gem_value) where the loop currently credits P1, and replace the P2 branch’s current shared-pool credit with _bank_xp(player2, gem_value).

Whatever mechanism marks “a level-up choice is pending” for player today (check the existing single-player leveling code for its exact shape — a flag, a stored Array of rolled choices, etc.) gets the same treatment: move it onto PlayerState as pending_upgrade_choices (or reuse the existing field name/shape if PlayerState already has a natural home for it — do not invent a second, differently-shaped mechanism) so _bank_xp populates whichever pilot leveled up, independently.

  • Step 4: Run tests to verify they pass

Expected: PASS.

  • Step 5: Run full suite + determinism baseline

Expected: all green, baseline unchanged (the 1-pilot path’s _bank_xp(player, ...) call is byte-identical to today’s inline logic).

  • Step 6: Commit
Terminal window
git add -A
git commit -m "feat(sim): per-pilot XP/leveling, gems bank to whichever pilot collected them (M-A task 8)"

Task 9: Promote pilots to real backing storage; generalize tick()’s input signature

Section titled “Task 9: Promote pilots to real backing storage; generalize tick()’s input signature”

pilots becomes an actual Array[PlayerState] field (constructed with [player] in _init, appended to by add_player2/a new general add_pilot()), rather than a computed view. player/player2 become thin compatibility accessors (player = pilots[0], player2 = pilots[1] if pilots.size() > 1 else null) so every existing external call site (tests, main.gd, render) keeps compiling untouched. tick() gains an inputs: Array[InputState] parameter, index-aligned with pilots, while the existing two-argument tick(input, input2) form is kept as a compatibility overload that just calls the array form — this is what lets every existing call site in tests/test_determinism.gd, tests/test_determinism_checksum.gd, and everywhere else in the test suite keep working without modification.

Files:

  • Modify: the Sim core module (pilots storage, add_player2/new add_pilot, tick() signature)
  • Test: extend the pilots test file

Interfaces:

  • Produces: Sim.pilots: Array[PlayerState] (now a real field, not a method — remove the Task 1 pilots() method, replace every internal call site that called pilots() with the field pilots)

  • Produces: Sim.add_pilot(offset: Vector2 = Vector2(70, 0)) -> PlayerState — general N-pilot join, replacing add_player2()’s body (keep add_player2() too, as a one-line compatibility wrapper: func add_player2() -> void: add_pilot())

  • Produces: Sim.tick(inputs: Array[InputState]) -> void — the new canonical signature

  • Produces: Sim.tick(input: InputState, input2: InputState = null) -> void — compatibility overload; GDScript doesn’t support true overloading by arity, so implement this as the renamed canonical function taking the array, and turn the old two-arg name into a distinct wrapper function name if a same-named overload isn’t possible — confirm GDScript 4.6’s actual behavior for default-arg functions colliding with a differently-shaped call before choosing between “keep tick as the 2-arg form and add a new tick_multi(inputs)” vs “rename the array form to tick and keep a tick_compat(input, input2) wrapper for old call sites.” Whichever direction is chosen, every existing call site (sim.tick(InputState.new(dir)) in the determinism tests, main.gd’s sim.tick(input, input2)) must keep compiling and behaving identically without modification in THIS task — only Task 11 is allowed to touch call sites, and only for the local co-op wiring specifically.

  • Step 1: Write failing tests for the promoted array + new signature

# tests/test_pilots_query.gd (append)
func test_pilots_is_now_a_real_array_field() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
assert_eq(sim.pilots.size(), 1)
assert_eq(sim.pilots[0], sim.player)
func test_add_pilot_generalizes_add_player2() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
var p2 := sim.add_pilot()
assert_eq(sim.pilots.size(), 2)
assert_eq(sim.pilots[1], p2)
assert_eq(sim.player2, p2, "player2 compatibility accessor must alias pilots[1]")
func test_tick_accepts_inputs_array() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.add_pilot()
sim.tick([InputState.new(Vector2.RIGHT), InputState.new(Vector2.ZERO)])
assert_true(sim.player.pos.x > 0.0, "array-form tick must still drive pilot movement")
func test_tick_two_arg_form_still_works_identically() -> void:
var a := Sim.new(1234, SimContentFixture.db())
var b := Sim.new(1234, SimContentFixture.db())
for i in range(60):
a.tick(InputState.new(Vector2.RIGHT))
b.tick([InputState.new(Vector2.RIGHT)])
assert_eq(a.snapshot_string(), b.snapshot_string(), "old 2-arg tick and new array-form tick must be provably identical for 1 pilot")
  • Step 2: Run tests to verify they fail

Expected: FAIL — pilots is still a method not a field; add_pilot/array-form tick don’t exist.

  • Step 3: Implement the promoted storage + signature change
# field, replacing the Task 1 method:
var pilots: Array[PlayerState] = []
# in _init, after player = PlayerState.new(); player.arsenal = PilotArsenal.new(content):
pilots = [player]
# player2 becomes a compatibility accessor (remove the old `var player2: PlayerState = null` field):
var player2: PlayerState:
get: return pilots[1] if pilots.size() > 1 else null
func add_pilot(offset: Vector2 = Vector2(70, 0)) -> PlayerState:
var p := PlayerState.new()
p.pos = player.pos + offset
p.clamp_arena = player.clamp_arena
p.arsenal = PilotArsenal.new(content)
pilots.append(p)
return p
func add_player2() -> void:
if pilots.size() > 1:
return
add_pilot()

For tick, rename the existing 2-arg function’s body into the canonical array-taking form and add a thin compatibility wrapper (resolve the exact GDScript mechanics — likely two differently-named functions, since GDScript resolves default-arg calls by declared arity, not by argument type, so tick(input) and tick(inputs: Array) cannot coexist as true overloads of the same name):

func tick(inputs: Array[InputState]) -> void:
# ... existing tick() body, with every `_pilot_inputs := [input, input2]` construction
# (introduced ad-hoc in Tasks 6-7) replaced by using `inputs` directly ...
func tick_single(input: InputState, input2: InputState = null) -> void:
var inputs: Array[InputState] = [input]
if input2 != null:
inputs.append(input2)
tick(inputs)

Then update every existing call site that calls the 2-arg form (tests/test_determinism.gd, tests/test_determinism_checksum.gd, main.gd, any other test file) to call tick_single(...) instead of tick(...) — this IS a call-site touch, but it is a pure, mechanical rename (find every .tick( call taking 1-2 InputState args, rename to .tick_single(), not a behavior change, and is necessary because true signature overloading isn’t available. Confirm this rename doesn’t miss a call site by grepping for \.tick\( across tests/ and the root main.gd after the rename and confirming zero remaining hits outside sim.gd itself.

  • Step 4: Run tests to verify they pass

Expected: PASS, including the two-implementation-parity test.

  • Step 5: Run full suite + determinism baseline

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: all green, scripts/check-test-count.sh confirms no test file was silently dropped by the rename, and the pinned determinism/checksum literals are unchanged (this is the step most likely to reveal a subtle draw-order bug from the _pilot_inputs restructuring in Tasks 6-7 — if the baseline moved, bisect by temporarily reverting Task 9’s rename-only changes and confirming the baseline was already stable after Task 8 before assuming Task 9 caused it).

  • Step 6: Commit
Terminal window
git add -A
git commit -m "refactor(sim): promote pilots to real array storage, generalize tick() to accept an inputs array (M-A task 9)"

Task 10: Generalize snapshot_string()/state_checksum() over pilots

Section titled “Task 10: Generalize snapshot_string()/state_checksum() over pilots”

Files:

  • Modify: the Sim core module (snapshot_string, state_checksum)
  • Test: extend tests/test_determinism_checksum.gd

Interfaces:

  • Consumes: Sim.pilots: Array[PlayerState] (Task 9)

  • Produces: no signature change to snapshot_string()/state_checksum() — same zero-arg methods, now internally looping.

  • Step 1: Write a failing test for 2-pilot checksum sensitivity

# tests/test_determinism_checksum.gd (append)
func test_checksum_reflects_second_pilot_state() -> void:
var a := Sim.new(99, SimContentFixture.db())
var b := Sim.new(99, SimContentFixture.db())
a.add_pilot()
b.add_pilot()
b.pilots[1].pos.x += 5.0
assert_ne(a.state_checksum(), b.state_checksum(), "moving pilot 2 must change the checksum — today it is invisible to state_checksum()")
  • Step 2: Run test to verify it fails

Expected: FAIL — state_checksum() today reads only player, so a and b’s checksums are identical despite pilots[1] differing.

  • Step 3: Loop over pilots in both functions
func state_checksum() -> int:
var parts := []
for pilot in pilots:
parts.append(pilot.pos)
parts.append(pilot.hp)
parts.append(pilot.xp)
parts.append(pilot.level)
# ... existing enemies/projectiles/enemy_proj/gems loop, unchanged ...
return hash(parts)
func snapshot_string() -> String:
var s := "t=%.3f" % run_time
for pilot in pilots:
s += " p=(%.2f,%.2f) hp=%.1f xp=%.1f lvl=%d" % [pilot.pos.x, pilot.pos.y, pilot.hp, pilot.xp, pilot.level]
# ... existing enemies.count/aura-count/projectiles.count/gems.count/kills suffix, unchanged ...
return s

Confirm the exact current field order/format string against the real state_checksum/snapshot_string bodies before finalizing — the goal is the SAME output for a single pilot, just looped, so the loop body’s field order for pilots[0] must match today’s hardcoded player.pos/hp/xp/level order exactly (do not reorder fields even if a different order seems more natural).

  • Step 4: Run the new test, verify it passes; re-run the full determinism baseline

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: the new 2-pilot test passes, AND the existing pinned single-pilot baseline (snapshot_string().hash() == <pinned value>, state_checksum() == <pinned value>) is still exactly the same as before this task — a 1-element loop producing the same string/array as the old hardcoded reads is the entire point of this task; if either pinned literal changed, the loop body has a field-order or formatting bug, not a legitimate re-pin.

  • Step 5: Commit
Terminal window
git add -A
git commit -m "feat(sim): snapshot_string/state_checksum loop over all pilots, single-pilot baseline unchanged (M-A task 10)"

Task 11: Local co-op wiring through add_pilot() with full per-device input (fixes the dash/drone bug)

Section titled “Task 11: Local co-op wiring through add_pilot() with full per-device input (fixes the dash/drone bug)”

Rewire main.gd’s local co-op join path to call the new add_pilot() and to give every pilot a full InputState (real dash/decoy edges), removing the poll_device(0)-overwrites-P1 bug this milestone was scoped to fix. This is dev/test-only wiring — V01_LOCK_COOP stays true for the shipping build; this task exists so N-pilot local co-op is genuinely playable and testable, satisfying the “produces working, testable software on its own” bar for this plan, and closing out the spec’s explicit call-out that the dash/drone fix is folded into this milestone rather than done standalone.

Files:

  • Modify: main.gd’s co-op join/_physics_process input-routing section
  • Modify: InputRouter — either extend poll_device(device) to also read real dash/decoy edges for that device, or (if a per-device edge-latch state machine is needed, per the spec’s “own per-device InputState (move + aim + dash + decoy with per-device edge latches)” requirement) add a new method that does so; do not ship a version that still hardcodes decoy=false, dash=false.

Interfaces:

  • Consumes: Sim.add_pilot(offset: Vector2) -> PlayerState (Task 9)

  • Consumes: Sim.tick(inputs: Array[InputState]) -> void (Task 9)

  • Step 1: Write a failing test — P1 keeps dash/decoy after P2 joins

This is a main.gd-level behavior, likely best exercised via a headless SceneTree smoke test if one already exists for input routing (check for a precedent before adding a new harness); at minimum, add a focused GUT test at the InputRouter/Sim boundary:

tests/test_local_coop_input.gd
extends GutTest
func test_p1_input_state_retains_dash_and_decoy_capability_after_p2_joins() -> void:
# InputRouter's per-device method must be able to report dash=true/decoy=true
# for device 0 even when a second device is also being polled — i.e. joining
# P2 must not force P1 onto the stripped no-dash/no-decoy poll_device path.
var router := InputRouter.new()
# Confirm the exact InputRouter API surface (poll_device signature, any
# needed per-device edge-latch reset call) against the current code before
# finalizing this test's exact calls — this is the one area of the plan
# most likely to need adjusting to the real InputRouter shape.
pass

Given InputRouter reads real Input/joypad global state (not a pure /sim class), this test may need to be a documented manual-verification step instead of an automated one if the current InputRouter isn’t structured for headless simulated joypad input — if so, replace this step with: “Manually verify via godot --path . with two connected controllers: press dash/decoy on controller 1 both before and after controller 2 joins; confirm controller 1’s ship still dashes/deploys its drone after join.” Note which path was taken in the commit message.

  • Step 2: Run test (or perform the manual check) to confirm the bug still reproduces

Expected: FAIL (or manually confirmed) — P1 loses dash/decoy the instant P2 joins, per the documented bug.

  • Step 3: Fix InputRouter to give every device real dash/decoy edges

Extend poll_device(device: int) -> InputState to run the same dash-edge and tap-vs-hold decoy-decision state machine poll() already runs for the primary input, parameterized per device (each device needs its own decoy-decision/edge-latch state — if poll()’s state machine is currently stored in single instance fields, it needs to become per-device, e.g. a Dictionary[int, ...] keyed by device id, or an array indexed by device).

  • Step 4: Update main.gd’s join path to use add_pilot() and never overwrite an existing pilot’s input with the stripped path

Replace the join-triggered call to sim.add_player2() with sim.add_pilot(), and replace the post-join input-overwrite block:

# was:
# if sim.player2 != null:
# input = input_router.poll_device(0)
# input2 = input_router.poll_device(1)
# becomes (both devices get the FULL per-device poll, including dash/decoy):
if sim.pilots.size() > 1:
input = input_router.poll_device(0)
input2 = input_router.poll_device(1)

(the fix lives in Step 3 — poll_device itself now returns full-featured InputStates — so this block’s shape is unchanged, only poll_device’s internals changed)

  • Step 5: Run the test (or manual check) to verify the fix; run full suite + determinism baseline

Expected: PASS / manually confirmed P1 keeps dash+decoy after P2 joins. Full suite green, baseline unchanged (this task touches main.gd/InputRouter, neither of which the determinism tests exercise).

  • Step 6: Commit
Terminal window
git add -A
git commit -m "fix(coop): every joined pilot gets full dash/decoy input, fixes P1 losing abilities on P2 join (M-A task 11)"

Task 12: Full regression pass + doc update

Section titled “Task 12: Full regression pass + doc update”

Files:

  • Modify: CLAUDE.md’s “Current status” section (note M-A landed, transport-agnostic N-pilot sim foundation in place, V01_LOCK_COOP still true pending M-B/M-C)

  • Modify: tests/test_determinism.gd/test_determinism_checksum.gd only if the self-review in Step 2 below finds a legitimate need to re-pin (expected: no re-pin needed, since every task above was designed to leave the 1-pilot baseline untouched)

  • Step 1: Run the entire suite once more end-to-end

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: exit 0, and scripts/check-test-count.sh confirms the script count matches tests/test_*.gd’s file count (guards against any test file silently dropped across the many renames in Tasks 9/11).

  • Step 2: Headless boot smoke

Run: godot --headless --path . --quit-after 120 and grep stderr for SCRIPT ERROR. Expected: none — confirms nothing in the class-cache broke from the new PilotArsenal class or the tick/tick_single rename.

  • Step 3: Manual 2-pilot local playtest

Open in the editor, force a local co-op join (two controllers, or the existing test/dev hook if one exists), and play a couple of minutes: confirm both pilots move, both fire from their own independently-acquired weapons, both level up independently (different builds after a few level-ups), both take correctly-armored damage, and the run only ends when both are down.

  • Step 4: Update CLAUDE.md

Add a line to the “Current status” section noting M-A (multiplayer sim foundation) landed: N-pilot Sim, per-pilot PilotArsenal, per-pilot XP/leveling, unified hit-check, determinism baseline unchanged for the single-pilot path; local co-op now gives every joined pilot a full independent build and fixes the dash/drone-loss-on-join bug; V01_LOCK_COOP remains true (M-B LAN transport + M-C per-device view/lobby are still required before co-op ships).

  • Step 5: Commit
Terminal window
git add CLAUDE.md
git commit -m "docs: M-A multiplayer sim foundation landed, N-pilot sim in place (determinism baseline unchanged)"

Spec coverage: M-A’s stated deliverables — “Per-pilot: PlayerState, own arsenal, own XP/level/build, own dash state, own drone charge,” “enemies target the nearest pilot,” “spawns ring around each pilot,” “game-over only when all pilots are down,” and “keep the single-player determinism baseline byte-identical” — are MOSTLY covered by Tasks 1-10, with one gap the final whole-branch review caught: “own drone charge” was NOT delivered. Sim.drone_charge/Sim.drones remain singular fields on Sim, and tick() only ever passes P1’s input to drone_director.update_drones, so P2’s decoy button press (which Task 11 correctly gives real per-device edges) is computed but never reaches the drone system. See CLAUDE.md’s M-A “Known follow-up gaps” (d) for the accurate current status — a per-pilot drone/decoy is a genuinely separate follow-on, not covered here. “Per-pilot level-up flow” (an M-A open problem per the spec) is covered at the sim-mechanism level by Task 8 (pending_upgrade_choices per pilot); the actual multi-viewport UI for presenting that choice per device is explicitly M-C’s job, not duplicated here. The dash/drone bug fix, which the spec folds into this milestone rather than doing standalone, is covered by Task 11. “Whether weapons fire from a per-pilot origin override or per-pilot instances” (the spec’s other open M-A question) is answered by Task 5/6: per-pilot instances, via PilotArsenal.

Not covered by this plan (intentionally, per the spec’s own M-A/M-B/M-C boundary): world-scale/perf when pilots are far apart (spawns/hash/culling — the spec lists this as a risk/watch-item for later, not an M-A deliverable), any networking transport (M-B), per-device camera/lobby UX (M-C), and 3+ pilot support beyond the 2-pilot (player/player2) case specifically — though Tasks 1, 9, and 10 are written generically over pilots: Array[PlayerState] and add_pilot(), so a 3rd/4th local pilot is a small follow-on (mostly M-C input-device plumbing), not a further sim restructuring.