Local co-op up to 4 pilots (plan)
Local co-op up to 4 pilots Implementation Plan
Section titled “Local co-op up to 4 pilots 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: Let up to four local controllers play one shared-screen run, each pilot with its own movement, colour, tag, HP bar, weapon row and level-up turn. The camera fits the group, and a soft tether keeps the group together.
Architecture: The sim is already N-pilot for weapons, drones, hit checks and checksums. This plan closes the remaining hardcoded player/player2 paths: movement, gems, weapon pickups, level-ups and hard-hit counts. It also adds a pure CoopTether step between movement and warps. main.gd swaps its player2_* fields for an index-aligned Array[PilotView] and an explicit pilot-to-pad binding (PilotBindings). It then feeds sim.tick(inputs) with one input per pilot. The HUD, weapon dock and level-up panel grow per-pilot slots, and each is fit-tested at the three real device profiles.
Tech Stack: Godot 4.7.1, typed GDScript (warnings as errors), GUT, Node test runner for the console/tunable-docs scripts.
Spec: docs/superpowers/specs/2026-10-05-local-coop-4-pilots-design.md
Global Constraints
Section titled “Global Constraints”Sim.MAX_LOCAL_PILOTS = 4. Join offsets from P1: P2(+70, 0), P3(-70, 0), P4(0, +70).- The single-player determinism baselines must stay byte-identical. These are
tests/test_determinism_survival.gdandtests/test_determinism.gd. They must pass unchanged after every task. Never copy hash values into prose, comments outside those files, or commit messages. tests/test_determinism_coop.gdmay move only in Task 4 (the tether). If it moves, re-pin it via skillbh-determinism-repin, in that file only, with a dated comment. Task 1 must leave it unchanged./simstays pure: every new sim fileextends RefCounted, with no Node, Input, Engine or Time API.- The tether acts only with 2 or more live pilots. It scales only the OUTWARD component of a pilot’s movement this tick, and never touches warps.
TETHER_RADIUSandTETHER_DAMPare remote tunables with explicit bounds: radius 160 to 340, damp 0.15 to 0.8. - Pads bind to pilots through an explicit pilot-to-device array. Only
InputRouter.playable_joypads()is consulted, so the Siri Remote can never join or drive a pilot. - Old replays recorded as 2 pilots per row (12 floats) must still load and play.
- HUD, weapon dock and level-up panel are fit-tested with 4 pilots at the
ShopLayoutFixtureprofilesipad(985 wide),iphone(1204) andtv(1280). A 1280-only test is not enough. - Pilot accents are P1 cyan, P2 magenta, P3 amber, P4 green. P1’s single-player look is unchanged: no tint, no tag and no zoom when solo.
- Keep the
Sim.player2andSim.player2_hard_hitsaccessors, which tests, telemetry and soak read. - The Drone Bay and the decoy HUD stay P1-only. Online play, split-screen, per-pilot hulls and a lobby are out of scope.
- Add a
build: nullWhat’s new entry incontrol/src/whats_new.js. Its first line must be exactlybuild: null, date: null,. If an unbuilt entry already sits at the top, append items to it instead of creating a second one. - GDScript: annotate
Dictionary.get()results asVariant, not:=. After adding anyclass_namefile, rungodot --headless --path . --importbefore a headless run. - Tests:
- Run one script with
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=<name>.gd -gexit 2>&1 | tail -30. - Run the whole suite only via
scripts/check-test-count.sh, then grep$TMPDIR/bh-gut-last.log. - Never pull a raw full GUT run into context.
- Run one script with
- Any new
docs/**/*.mdneeds Starlighttitle:frontmatter. - After any windowed
godot --path .run,git statusand restoreproject.godotandaudio/bus_layout.tresbefore committing. - Commit with
git commit --file=<file>. End every message with:Keep apostrophes out of commit messages.Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01BfcSqKg4b4daxk12fDv5KD - Work in your own worktree (
.claude/worktrees/<name>, branched offorigin/main), never in the main checkout.
Where the real code differs from the spec (read before starting)
Section titled “Where the real code differs from the spec (read before starting)”- Gems, weapon pickups and gem level-ups were hardcoded to
player2(Sim._collect_gems,Sim._collect_weapon_pickups). The spec only names movement. A P3 or P4 would never bank a gem, collect a dropped weapon, or level up from gem XP. Task 1 generalises all three overpilots[1..]in join order, so a 2-pilot run is unchanged. - The P2 weapon row already overlaps the drone dock.
WeaponPanelputs P2’s row at absolute y 82 + 80 = 162, which is exactlyDroneDock.DOCK_ORIGIN.y. Task 8 moves co-pilot rows below the dock. ZoneRenderer.web_player_posreads onlypilots[0..1]. A live P3 or P4 never ripples the web. Task 6 changes it to take the first two LIVE pilots out of all pilots.- The level-up header colour roles are pinned by tests. P1 is
gold.mainand the others areaccent.soft("P2 header keeps its role"). The header text already saysPLAYER N. So pilot identity is a separate pilot-colouredPilotTagchip beside the header, not a recoloured header. - The legacy level-up panel hardcodes
"PLAYER 2 LEVEL UP"for every non-P1 pilot. Task 9 fixes it. - The telemetry Worker and D1 schema have only a
level2column. The run body already reportspilots(the count). Levels for P3 and P4 are not added: that is a schema change, out of scope. - Replays do not record WHEN a pilot joined (an existing gap). Playback only drives pilots that already exist. Task 10 records N pilots per row, but join timing stays unrecorded.
- A constant
TETHER_DAMPcannot guarantee the camera fit. A held stick still drifts outward atDAMP x speed, so the group can outrun the 0.7 zoom floor. The spec design is kept as written, and this goes on Chris’s feel-check list. - Existing tests rely on the implicit
pilot k = playable[k]mapping (FakeCoopRouterreturns[0, 1]with no join). SoPilotBindingsfalls back toplayable[k]for a pilot that was never explicitly bound, and binds explicitly on every real join. - The
bh-determinism-repinskill names files that no longer exist (test_determinism_checksum.gd,test_determinism_crystals.gd). The real files aretest_determinism_survival.gd,test_determinism.gdandtest_determinism_coop.gd. Follow the skill’s process against the real files.
File Structure
Section titled “File Structure”| File | Action | Responsibility |
|---|---|---|
sim/sim.gd |
Modify | MAX_LOCAL_PILOTS, JOIN_OFFSETS/join_offset(). add_pilot capped. tick() padded input array and integrate loop. Tether call. Gems, pickups and level-ups over all pilots. player_hard_hits/player2_hard_hits become accessors. |
sim/player_state.gd |
Modify | var hard_hits: int. |
sim/coop_tether.gd |
Create | class_name CoopTether: the two tunables plus capture()/apply(). |
net/tunables_registry.gd |
Modify | Register the two tether keys with bounds. |
tools/tunable_docs/docs.d/coop.json |
Create | Console label/desc/unit/group for the tether keys. |
control/src/tunable_docs.js |
Regenerate | Output of node tools/tunable_docs/generate.mjs. |
render/pilot_view.gd |
Create | class_name PilotView: one pilot’s ship node, renderer, world tag and per-frame edge state. |
render/pilot_palette.gd |
Create | class_name PilotPalette: per-pilot accent and "P1".."P4" tags. |
render/coop_camera.gd |
Create | class_name CoopCamera: centroid, fit zoom, smoothing (pure). |
input/pilot_bindings.gd |
Create | class_name PilotBindings: explicit pilot-to-pad array, join/rebind decision (pure). |
render/player_renderer.gd |
Modify | set_pilot_accent(c). |
render/zone_renderer.gd |
Modify | web_player_pos takes the first two live pilots out of all pilots. |
main.gd |
Modify | _pilot_views, _bindings, _coop_inputs, _handle_join_presses, _join_pilot, _can_join, camera fit, tags, radar, haptics hook. |
ui/hud.gd |
Modify | CoPilotBar slots (one row, up to 3), pilot tags, copilot_slot_rect(). |
ui/weapon_panel.gd |
Modify | CoPilotRow rows below the drone dock, pilot tags. |
ui/level_up_panel.gd |
Modify | PilotTag chip. |
ui/level_up_panel_legacy.gd |
Modify | "PLAYER %d LEVEL UP". |
input/replay_recorder.gd |
Modify | record_inputs(inputs). |
input/replay_loader.gd |
Modify | input_at returns null past a short row. |
tools/soak/soak_run.gd, tools/soak/soak_runner.gd |
Modify | pilots cfg and --pilots=N. |
control/src/whats_new.js |
Modify | Unbuilt entry. |
docs/architecture/coop-and-pilots.md, docs/architecture/remote-config.md |
Modify | Current architecture. |
| Tests | Create/Modify | Listed per task. |
Task 1: Sim handles 4 pilots (movement, gems, pickups, level-ups, hard hits)
Section titled “Task 1: Sim handles 4 pilots (movement, gems, pickups, level-ups, hard hits)”Files:
- Modify:
sim/sim.gd. The changes are:- consts near
var pilots(around L286); player_hard_hits/player2_hard_hits(L381-384);tick()(L846-871);_collect_weapon_pickups()(L1337-1353);add_pilot()(L2695);_collect_gems()(L2992-3024);_hurt_player()hard-hit block (L3177-3180).
- consts near
- Modify:
sim/player_state.gd, adding the field afterpending_levelups. - Create:
tests/test_coop_four_pilots_sim.gd
Interfaces:
-
Consumes: nothing new.
-
Produces:
const Sim.MAX_LOCAL_PILOTS := 4const Sim.JOIN_OFFSETS: Array[Vector2]static func Sim.join_offset(k: int) -> Vector2Sim.add_pilot(offset: Vector2 = Vector2(70, 0)) -> PlayerState, which returnsnullat the cap.Sim.tick(inputs: Array[InputState])accepts fewer inputs than pilots. Missing entries count as null: that pilot does not move, warp or fire.var PlayerState.hard_hits: int(render-only, not checksummed).Sim.player_hard_hits/Sim.player2_hard_hitsbecome get/set accessors ontopilots[0|1].hard_hits.func Sim._copilot_in_pickup_range(pos: Vector2) -> PlayerState
-
Step 1: Write the failing test
tests/test_coop_four_pilots_sim.gd:
extends GutTest
# Local co-op up to 4 pilots, sim side (plan 2026-10-05-local-coop-4-pilots.md, Task 1).# Before this task tick() only integrated player/player2, and gems/weapon pickups/level-ups# only ever checked player2, so a 3rd or 4th pilot ticked everything else but never moved,# banked a gem or collected a weapon.
func _sim(n: int) -> Sim: var s := Sim.new(1234, SimContentFixture.db()) for k in range(1, n): s.add_pilot(Sim.join_offset(k)) return s
func test_add_pilot_caps_at_max_local_pilots() -> void: var s := _sim(4) assert_eq(s.pilots.size(), Sim.MAX_LOCAL_PILOTS, "precondition: four pilots") assert_null(s.add_pilot(), "a fifth pilot is refused") assert_eq(s.pilots.size(), 4, "the refused join did not append")
func test_join_offsets_place_pilots_around_p1() -> void: var s := _sim(4) assert_eq(s.pilots[1].pos - s.player.pos, Vector2(70, 0), "P2 to the right") assert_eq(s.pilots[2].pos - s.player.pos, Vector2(-70, 0), "P3 to the left") assert_eq(s.pilots[3].pos - s.player.pos, Vector2(0, 70), "P4 below") assert_eq(Sim.join_offset(1), Vector2(70, 0), "add_pilot()'s default offset is P2's")
func test_every_pilot_moves_on_its_own_input() -> void: var s := _sim(4) var before: Array[Vector2] = [] for p in s.pilots: before.append(p.pos) var inputs: Array[InputState] = [InputState.new(Vector2.RIGHT), InputState.new(Vector2.LEFT), InputState.new(Vector2.UP), InputState.new(Vector2.DOWN)] s.tick(inputs) assert_gt(s.pilots[0].pos.x, before[0].x, "P1 moved right") assert_lt(s.pilots[1].pos.x, before[1].x, "P2 moved left") assert_lt(s.pilots[2].pos.y, before[2].y, "P3 moved up (was never integrated before)") assert_gt(s.pilots[3].pos.y, before[3].y, "P4 moved down")
func test_missing_inputs_leave_extra_pilots_still() -> void: var s := _sim(4) var p3_before := s.pilots[2].pos var p4_before := s.pilots[3].pos var one: Array[InputState] = [InputState.new(Vector2.RIGHT)] s.tick(one) assert_eq(s.pilots[2].pos, p3_before, "no input for P3 -> holds still, no out-of-range read") assert_eq(s.pilots[3].pos, p4_before, "no input for P4 -> holds still")
func test_third_pilot_banks_gem_xp_to_its_own_pool() -> void: var s := _sim(3) s.player.pos = Vector2(9999, 9999) s.pilots[1].pos = Vector2(-9999, 9999) s.pilots[2].pos = Vector2.ZERO s.gems.add(Vector2(5, 0), Vector2.ZERO, Sim.GEM_RADIUS, 25.0) s._collect_gems() assert_eq(s.player.xp, 0.0, "P1 was nowhere near") assert_eq(s.pilots[1].xp, 0.0, "P2 was nowhere near") assert_gt(s.pilots[2].xp, 0.0, "P3 banks the gem it flew over")
func test_fourth_pilot_levels_up_from_gems() -> void: var s := _sim(4) s.player.pos = Vector2(9999, 9999) s.pilots[1].pos = Vector2(-9999, 9999) s.pilots[2].pos = Vector2(9999, -9999) s.pilots[3].pos = Vector2.ZERO s.pilots[3].xp_to_next = 10.0 s.gems.add(Vector2.ZERO, Vector2.ZERO, Sim.GEM_RADIUS, 12.0) s._collect_gems() assert_eq(s.pilots[3].level, 2, "P4 levels from its own XP") assert_eq(s.pilots[3].pending_levelups, 1, "P4 gets its own pending choice") assert_eq(s.player.pending_levelups, 0, "P1 untouched")
func test_third_pilot_collects_a_dropped_weapon() -> void: var s := _sim(3) s.player.pos = Vector2(9999, 9999) s.pilots[1].pos = Vector2(-9999, 9999) s.pilots[2].pos = Vector2(50, 50) s.drop_weapon_pickup("blade", Vector2(50, 50)) s._collect_weapon_pickups() assert_true(s.upgrade_system.is_weapon_active(s, s.pilots[2], "blade"), "P3 collects the drop") assert_false(s.upgrade_system.is_weapon_active(s, s.player, "blade"), "P1 untouched")
func test_hard_hits_are_counted_per_pilot() -> void: var s := _sim(3) s._hurt_player(10.0, "test", false, false, s.pilots[2]) assert_eq(s.pilots[2].hard_hits, 1, "P3's discrete hit counts on P3") assert_eq(s.player_hard_hits, 0, "P1's accessor reads P1 only") assert_eq(s.player2_hard_hits, 0, "P2's accessor reads P2 only") s.player2_hard_hits += 1 assert_eq(s.pilots[1].hard_hits, 1, "player2_hard_hits writes through to pilots[1]") s.player_hard_hits += 2 assert_eq(s.player.hard_hits, 2, "player_hard_hits writes through to P1")- Step 2: Run the test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop_four_pilots_sim.gd -gexit 2>&1 | tail -30
Expected: FAIL or a parse error. Sim.MAX_LOCAL_PILOTS, Sim.join_offset and PlayerState.hard_hits are not defined yet.
- Step 3: Add
hard_hitstoPlayerState
In sim/player_state.gd, directly under var pending_levelups: int = 0:
# Render-only count of this pilot's discrete (non-DoT) hits; main plays the hurt sound / shakes the# camera only when it moves. Not checksummed. Sim.player_hard_hits / player2_hard_hits forward here.var hard_hits: int = 0- Step 4: Cap and offsets in
sim/sim.gd
Directly under var pilots: Array[PlayerState] = []:
# Local co-op ceiling (spec 2026-10-05-local-coop-4-pilots-design.md). add_pilot() refuses past it.const MAX_LOCAL_PILOTS := 4# Where pilot k drops in, relative to P1: P2 right, P3 left, P4 below. Index 0 is P1 itself.const JOIN_OFFSETS: Array[Vector2] = [Vector2.ZERO, Vector2(70, 0), Vector2(-70, 0), Vector2(0, 70)]
static func join_offset(k: int) -> Vector2: return JOIN_OFFSETS[k] if k >= 0 and k < JOIN_OFFSETS.size() else Vector2(70, 0)Replace add_pilot with this. The body is unchanged except for the cap:
func add_pilot(offset: Vector2 = Vector2(70, 0)) -> PlayerState: if pilots.size() >= MAX_LOCAL_PILOTS: return null var p := PlayerState.new() p.pos = player.pos + offset p.clamp_arena = player.clamp_arena p.arsenal = PilotArsenal.new(content) pilots.append(p) return pThen check that no existing test adds more than 3 extra pilots:
Run: git grep -n "add_pilot" -- tests tools | wc -l and read every hit inside a loop.
Expected: none loops past 3. If one does, cap that loop at Sim.MAX_LOCAL_PILOTS - 1.
- Step 5: Hard-hit accessors
Replace the two fields var player_hard_hits: int = 0 and var player2_hard_hits: int = 0, and their comments, with:
# Render-only discrete-hit counts (main shakes/plays hurt only when one moves; burn/zone/leech/beam# ticks never count). The real counter lives on each PlayerState.hard_hits (every pilot, 2026-10-05);# these two accessors keep the P1/P2 call sites and tests working. Not checksummed.var player_hard_hits: int: get: return player.hard_hits set(v): player.hard_hits = vvar player2_hard_hits: int: get: return pilots[1].hard_hits if pilots.size() > 1 else 0 set(v): if pilots.size() > 1: pilots[1].hard_hits = vIn _hurt_player, replace:
if not is_dot and pilot == player: player_hard_hits += 1 elif not is_dot and pilot == player2: player2_hard_hits += 1with:
if not is_dot: pilot.hard_hits += 1- Step 6: Padded inputs and the integrate loop in
tick()
In tick(), delete these two lines:
var input: InputState = inputs[0] if inputs.size() > 0 else null var input2: InputState = inputs[1] if inputs.size() > 1 else nullThen replace this whole block, from var _pilot_inputs := [input, input2] through the end of the P2 integrate call:
var _pilot_inputs := [input, input2] player.move_mult = elemental_system.web_slow_mult(self, player) * dimension_hazards.speed_mult_at(player.pos) # TODO(future N-pilot work): integrate() is still hardcoded to player/player2, NOT looped # ... if player.hp > 0.0: player.integrate(input, dt) # ... # Player 2 (co-pilot) moves on its own input. ... if player2 != null and player2.hp > 0.0 and input2 != null: player2.move_mult = elemental_system.web_slow_mult(self, player2) * dimension_hazards.speed_mult_at(player2.pos) player2.integrate(input2, dt)with:
# Index-aligned with `pilots`: _pilot_inputs[k] drives pilots[k]; a pilot past the end of # `inputs` gets null (holds still, no warp, no fire). Built ONCE here and reused by the # dash/regen loop below AND the weapon-fire/drone loops later this tick. var _pilot_inputs: Array[InputState] = [] for k in pilots.size(): _pilot_inputs.append(inputs[k] if k < inputs.size() else null) # Per-pilot movement, P1 first then co-pilots in join order, so 1- and 2-pilot runs are # byte-identical to the old hardcoded player/player2 calls. move_mult (web slow x dimension # hazard) is a pure read that only integrate() consumes. for k in pilots.size(): var mover := pilots[k] mover.move_mult = elemental_system.web_slow_mult(self, mover) * dimension_hazards.speed_mult_at(mover.pos) if mover.hp > 0.0 and _pilot_inputs[k] != null: mover.integrate(_pilot_inputs[k], dt)Then confirm that nothing else in tick() still reads the deleted locals:
Run: awk '/^func tick\(inputs/,/^func [a-z_]+\(/' sim/sim.gd | grep -nE '\binput2?\b' | grep -v '_input'
Expected: no output.
- Step 7: Gems for every pilot
In _collect_gems(), replace the loop body’s P2 branch and the magnet branch:
elif player2 != null and player2.hp > 0.0 and player2.pos.distance_squared_to(gems.pos[i]) <= player2.pickup_radius * player2.pickup_radius: # Co-op: P2 grabs the gem ... fx_events.append({"kind": "pickup", "pos": gems.pos[i], "element": -1}) _bank_xp(player2, gems.data[i]) gems.remove_at(i) elif d2 <= mag2:with:
elif _copilot_in_pickup_range(gems.pos[i]) != null: # Co-op: the first live co-pilot (join order) whose pickup radius covers the gem banks # it to its OWN pool. 1P: no co-pilots -> null -> skipped (baseline-safe); 2P: identical # to the old player2-only branch. fx_events.append({"kind": "pickup", "pos": gems.pos[i], "element": -1}) _bank_xp(_copilot_in_pickup_range(gems.pos[i]), gems.data[i]) gems.remove_at(i) elif d2 <= mag2:Replace the tail:
_process_levelups(player) if player2 != null: _process_levelups(player2)with:
_process_levelups(player) for k in range(1, pilots.size()): _process_levelups(pilots[k])Add this helper directly above _collect_gems():
# The first live co-pilot (pilots[1..], join order) whose pickup radius covers `pos`, or null.# Join order keeps a 2-pilot run byte-identical to the old player2-only checks.func _copilot_in_pickup_range(pos: Vector2) -> PlayerState: for k in range(1, pilots.size()): var p := pilots[k] if p.hp > 0.0 and p.pos.distance_squared_to(pos) <= p.pickup_radius * p.pickup_radius: return p return null- Step 8: Weapon pickups for every pilot
Replace the body of the while i >= 0: loop in _collect_weapon_pickups(), from if player.pos.distance_to(...) through the elif player2 ... branch, with:
var taker: PlayerState = null if player.pos.distance_to(pk["pos"]) <= WEAPON_PICKUP_RADIUS + player.radius: taker = player else: # Co-op: the first live co-pilot in join order (2P identical to the old player2 branch). for k in range(1, pilots.size()): var p := pilots[k] if p.hp > 0.0 and p.pos.distance_to(pk["pos"]) <= WEAPON_PICKUP_RADIUS + p.radius: taker = p break if taker != null: upgrade_system.grant_weapon(self, taker, String(pk["weapon"])) fx_events.append({"kind": "reaction", "pos": pk["pos"], "element": int(pk["element_idx"]), "name": String(pk["weapon"]).to_upper()}) weapon_pickups.remove_at(i) i -= 1Keep the tutorial pickup_allowed early-continue above it exactly as it is.
- Step 9: Run the new test and the determinism tests
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop_four_pilots_sim.gd -gexit 2>&1 | tail -30
Expected: all 8 tests PASS.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism -gexit 2>&1 | tail -30
Expected: PASS, with no change to any pinned number in test_determinism_survival.gd, test_determinism.gd or test_determinism_coop.gd. If ANY of them moves, stop. This task must be behaviour-neutral for 1 and 2 pilots, so a moved number is a bug in Steps 6-8, not a re-pin.
- Step 10: Full suite
Run: scripts/check-test-count.sh
Expected: exit 0. On failure, grep -n "FAILED\|Failed" "$TMPDIR/bh-gut-last.log" | head -40 and fix.
- Step 11: Commit
cat > "$TMPDIR/bh-msg" <<'EOF'feat(coop): sim moves, banks gems, collects pickups and counts hits for up to 4 pilots
tick() pads inputs to pilots and integrates every pilot; gems, weapon pickups andgem level-ups loop over co-pilots in join order; add_pilot caps at MAX_LOCAL_PILOTS.1P and 2P determinism baselines unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01BfcSqKg4b4daxk12fDv5KDEOFgit add sim/sim.gd sim/player_state.gd tests/test_coop_four_pilots_sim.gdgit commit --file="$TMPDIR/bh-msg"Task 2: main.gd goes N-wide (pilot views, input array), still a 2-pilot join
Section titled “Task 2: main.gd goes N-wide (pilot views, input array), still a 2-pilot join”Files:
- Create:
render/pilot_view.gd - Modify:
main.gd. The changes are:- fields L9-12 and L141-145;
_new_runL978-979 and L1499-1502;- join and input block L1565-1611;
- dash audio L1627-1633;
- co-pilot render and camera L2055-2070;
- co-pilot hurt L2196-2202;
_spawn_player2_renderL2495-2508.
- Modify:
input/replay_loader.gd(input_atbounds check) - Create:
tests/test_coop_main_views.gd - Modify:
tests/test_replay.gd(one new test)
Interfaces:
-
Consumes (Task 1):
Sim.tick(inputs: Array[InputState]),PlayerState.hard_hits,Sim.add_pilot(...). -
Produces:
class_name PilotView extends RefCounted. Fields:node: Node2D,renderer: PlayerRenderer,tag: Label(null until Task 6);facing: float,last_pos: Vector2,last_dash_cd: float,last_hp: float,last_hard_hits: int.
- Statics and methods:
static func make(n: Node2D, r: PlayerRenderer) -> PilotView,func seed_from(p: PlayerState) -> void. main._pilot_views: Array[PilotView], index-aligned withsim.pilots. Entry 0 wrapsplayer_node/player_renderer.func main._ensure_pilot_views() -> voidcreates the missing views in order.func main._spawn_pilot_view(k: int) -> voidfunc main._spawn_player2_render() -> voidstays as a compat wrapper.func main._coop_inputs(fallback_p1: InputState, playable: Array) -> Array[InputState]func main._coop_cam_points() -> PackedVector2Arraystatic func ReplayLoader.input_at(...)returnsnullwhen the row has no slot forpilot_idx.
-
Step 1: Write the failing tests
tests/test_coop_main_views.gd:
extends GutTest
# main.gd N-wide (plan 2026-10-05-local-coop-4-pilots.md, Task 2): every co-pilot gets a# PilotView (ship node + renderer + per-frame edge state), every pilot reads its own pad, and# P3/P4 hits play the hurt sound like P2's do.
class FakeQuadRouter extends InputRouter: var pads: Array = [0, 1, 2, 3] func playable_joypads() -> Array: return pads
func _main_with(n: int) -> Node: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") for k in range(1, n): m.sim.add_pilot(Sim.join_offset(k)) return m
func test_every_pilot_gets_a_view_that_follows_it() -> void: var m = await _main_with(4) m._process(1.0 / 60.0) assert_eq(m._pilot_views.size(), 4, "one view per pilot") assert_eq(m._pilot_views[0].node, m.player_node, "view 0 wraps P1's existing node") for k in range(1, 4): assert_eq(m._pilot_views[k].node.position, m.sim.pilots[k].pos, "view %d sits on pilot %d" % [k, k])
func test_downed_copilot_view_dims() -> void: var m = await _main_with(3) m.sim.pilots[2].hp = 0.0 m._process(1.0 / 60.0) assert_almost_eq(m._pilot_views[2].node.modulate.a, 0.3, 0.001, "a downed P3 is dimmed like a downed P2")
func test_third_pilot_hard_hit_plays_hurt_sound() -> void: var m = await _main_with(3) m._process(1.0 / 60.0) # creates and seeds the views var seq_before: int = m.audio._play_seq m.sim.pilots[2].hp -= 5.0 m.sim.pilots[2].hard_hits += 1 m._process(1.0 / 60.0) var played_hurt := false for j in AudioManager.POOL_SIZE: if m.audio._voice_seq[j] > seq_before and m.audio._pool[j].stream == m.audio._streams["king_hit"]: played_hurt = true assert_true(played_hurt, "P3's hard hit plays the king_hit sound")
func test_coop_inputs_one_per_pilot_from_playable_pads() -> void: var m = await _main_with(3) m.input_router = FakeQuadRouter.new() var fallback := InputState.new(Vector2.RIGHT) var inputs: Array[InputState] = m._coop_inputs(fallback, [0, 1, 2, 3]) assert_eq(inputs.size(), 3, "index-aligned with sim.pilots") for k in 3: assert_not_null(inputs[k], "pilot %d has a pad" % k) var short: Array[InputState] = m._coop_inputs(fallback, [0, 1]) assert_null(short[2], "P3 with no pad gets no input (holds still), not P1's")
func test_coop_inputs_p1_keeps_keyboard_touch_input_without_pads() -> void: var m = await _main_with(2) var fallback := InputState.new(Vector2.RIGHT) var inputs: Array[InputState] = m._coop_inputs(fallback, []) assert_eq(inputs[0], fallback, "no pads: P1 keeps the keyboard/touch input, as before") assert_null(inputs[1], "no pads: P2 holds still, as before")
func test_camera_centres_on_live_pilots_centroid() -> void: var m = await _main_with(3) m.sim.player.pos = Vector2(-300, 0) m.sim.pilots[1].pos = Vector2(300, 0) m.sim.pilots[2].pos = Vector2(0, 600) m._process(1.0 / 60.0) assert_almost_eq(m.camera.global_position.x, 0.0, 0.5) assert_almost_eq(m.camera.global_position.y, 200.0, 0.5, "centroid of all three live pilots") m.sim.pilots[2].hp = 0.0 m._process(1.0 / 60.0) assert_almost_eq(m.camera.global_position.y, 0.0, 0.5, "a downed pilot drops out of the centroid")Append to tests/test_replay.gd:
# 4-pilot co-op (2026-10-05): a 2-pilot (12-float) row has no slot for P3/P4 -- input_at must# return null for them instead of indexing past the row, so an old replay still plays.func test_input_at_returns_null_past_a_short_row() -> void: var data := {"ticks": [[1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0]]} assert_not_null(ReplayLoader.input_at(data, 0, 1), "P2's slot exists in a 2-pilot row") assert_null(ReplayLoader.input_at(data, 0, 2), "no P3 slot -> null, not an index error") assert_null(ReplayLoader.input_at(data, 0, 3), "no P4 slot -> null")- Step 2: Run the tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop_main_views.gd -gexit 2>&1 | tail -30
Expected: FAIL (_pilot_views / _coop_inputs not found).
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_replay.gd -gexit 2>&1 | tail -30
Expected: the new test FAILS with an out-of-range index error.
- Step 3: Create
render/pilot_view.gd
class_name PilotViewextends RefCounted
# One pilot's on-screen presence in main.gd (local co-op, 2026-10-05): its ship node + renderer,# its world tag, and the per-frame edge state main diffs against the sim (facing, last position,# dash cooldown, hp, hard hits). main._pilot_views is index-aligned with sim.pilots; entry 0 wraps# P1's existing player_node/player_renderer, whose own edge state still lives in main's _last_*# fields (tests read those), so view 0's edge fields are unused.
const TAG_OFFSET := Vector2(-20, -60) # world units from the ship centre to the tag's top-leftconst TAG_SIZE := Vector2(40, 20)
var node: Node2Dvar renderer: PlayerRenderervar tag: Label = nullvar facing: float = 0.0var last_pos: Vector2 = Vector2.ZEROvar last_dash_cd: float = 0.0var last_hp: float = 0.0var last_hard_hits: int = 0
static func make(n: Node2D, r: PlayerRenderer) -> PilotView: var v := PilotView.new() v.node = n v.renderer = r return v
# Start the edge detectors from the pilot's current state, so a fresh view never fires a stale# hurt/dash edge on its first frame.func seed_from(p: PlayerState) -> void: last_pos = p.pos last_dash_cd = p.dash_cd last_hp = p.hp last_hard_hits = p.hard_hits if node != null: node.position = p.posRun: godot --headless --path . --import > /dev/null 2>&1; git status --short and restore project.godot / audio/bus_layout.tres if they are listed.
- Step 4: Bounds-check
ReplayLoader.input_at
Replace the body of input_at in input/replay_loader.gd:
static func input_at(data: Dictionary, tick: int, pilot_idx: int) -> InputState: var row: Array = (data.ticks as Array)[tick] var o := pilot_idx * 6 if o + 5 >= row.size(): return null # no slot for this pilot (e.g. P3 in a 2-pilot recording): holds still return InputState.new(Vector2(row[o], row[o + 1]), Vector2(row[o + 2], row[o + 3]), int(row[o + 4]) == 1, int(row[o + 5]) == 1)- Step 5: Replace the P2 fields in
main.gd
Delete these fields:
player2_node,player2_renderer,_player2_facing,_last_player2_pos(L9-12);_last_hp2,_last_hard_hits2,_last_dash_cd2(L141-145, with their comments).
Add next to the remaining P1 render fields:
# Local co-op (2026-10-05): one PilotView per pilot, index-aligned with sim.pilots. Entry 0 wraps# player_node/player_renderer; entries 1..3 are the co-pilots' ships. Rebuilt lazily by# _ensure_pilot_views() so pilots added by tests/tools (not the join path) still get a ship.var _pilot_views: Array[PilotView] = []Confirm nothing else references the deleted names:
Run: git grep -n "player2_node\|player2_renderer\|_player2_facing\|_last_player2_pos\|_last_hp2\|_last_hard_hits2\|_last_dash_cd2" -- main.gd tools marketing
Expected: only the lines this task rewrites in Steps 6-10. Fix any other hit the same way.
- Step 6: Run reset
In _new_run, replace:
player2_node = null # co-op P2 ship was swept above; a fresh run starts solo until P2 re-joins player2_renderer = nullwith:
_pilot_views.clear() # co-pilot ships were swept above; a fresh run starts solo until someone re-joinsDelete these two lines from the later reset block (around L1501):
_last_hp2 = 0.0 _last_hard_hits2 = 0- Step 7: View creation
Replace _spawn_player2_render() with:
# Make sure every pilot has a PilotView, in pilot order (view 0 wraps P1's node). Called from the# join path and once per frame before the co-pilot render loop, so pilots added outside the join# path (tests, dev tools) still get a ship.func _ensure_pilot_views() -> void: if sim == null: return if _pilot_views.is_empty(): _pilot_views.append(PilotView.make(player_node, player_renderer)) while _pilot_views.size() < sim.pilots.size(): _spawn_pilot_view(_pilot_views.size())
# Create co-pilot k's ship visual (k >= 1, k == _pilot_views.size()).func _spawn_pilot_view(k: int) -> void: var n := Node2D.new() add_child(n) var r := PlayerRenderer.new() n.add_child(r) var v := PilotView.make(n, r) v.seed_from(sim.pilots[k]) _pilot_views.append(v)
# Compatibility wrapper (tests call it after sim.add_player2()).func _spawn_player2_render() -> void: if sim == null or sim.pilots.size() < 2: return _ensure_pilot_views()- Step 8: Per-pilot inputs, replay and tick
In the join block (L1565-1578), replace _spawn_player2_render() with _ensure_pilot_views(). Leave everything else in that block alone: _can_p2_join, playable[1] and sim.add_pilot() are Task 3’s job.
Replace the input block, from var input2: InputState = null through the replay override:
var input2: InputState = null if sim.pilots.size() > 1: if playable.size() > 0: input = input_router.poll_device(playable[0]) if playable.size() > 1: input2 = input_router.poll_device(playable[1]) ... input = ReplayLoader.input_at(_replay_play, _replay_tick, 0) input2 = ReplayLoader.input_at(_replay_play, _replay_tick, 1) if sim.pilots.size() > 1 else nullwith:
# One input per pilot, index-aligned with sim.pilots. Solo keeps the merged keyboard/touch/any-pad # input; co-op reads each pilot's own pad (P1 keeps keyboard/touch when no pad is connected). var inputs: Array[InputState] = [input] if sim.pilots.size() > 1: inputs = _coop_inputs(input, playable) if inputs[0] != null: input = inputs[0]and the replay override lines become:
input = ReplayLoader.input_at(_replay_play, _replay_tick, 0) inputs[0] = input for k in range(1, inputs.size()): inputs[k] = ReplayLoader.input_at(_replay_play, _replay_tick, k)In the steps loop, replace:
if _replay_rec: _replay_rec.record(input, input2) sim.tick_single(input, input2)with:
if _replay_rec: _replay_rec.record(inputs[0], inputs[1] if inputs.size() > 1 else null) sim.tick(inputs)(Task 10 swaps the recorder call for record_inputs(inputs).)
Add the helper near _pausing_pilot:
# Co-op: each pilot reads its own pad, index-aligned with sim.pilots. Pilot k reads playable[k]# (Task 3 replaces this implicit mapping with explicit PilotBindings). A pilot with no pad gets# null (holds still) -- except P1, who keeps `fallback_p1` (keyboard/touch), matching the old# 2-pilot behaviour exactly.func _coop_inputs(fallback_p1: InputState, playable: Array) -> Array[InputState]: var out: Array[InputState] = [] for k in sim.pilots.size(): if k < playable.size(): out.append(input_router.poll_device(int(playable[k]))) else: out.append(fallback_p1 if k == 0 else null) return out- Step 9: Co-pilot dash audio
Replace the if sim.player2 != null: dash block (L1627-1633) with:
# Co-pilots: the same dash_cd edge detect per pilot, so every co-pilot's warp plays a sound. # Visual warp_fx stays P1-only (it is tied to P1's input direction). for k in range(1, mini(_pilot_views.size(), sim.pilots.size())): var dv: PilotView = _pilot_views[k] var cd := sim.pilots[k].dash_cd if dv.last_dash_cd <= 0.0 and cd > 0.1: audio.dash() dv.last_dash_cd = cd- Step 10: Co-pilot render, camera and hurt
Replace the if sim.player2 != null and player2_node != null: render and camera block (L2055-2070) with:
# Co-pilots: render each ship, then centre the camera on the live pilots. _ensure_pilot_views() for k in range(1, _pilot_views.size()): var v: PilotView = _pilot_views[k] var p: PlayerState = sim.pilots[k] v.node.position = p.pos var dk: Vector2 = p.pos - v.last_pos if dk.length_squared() > 1.0: v.facing = dk.angle() + PI / 2.0 v.node.rotation = lerp_angle(v.node.rotation, v.facing, 0.25) v.renderer.set_facing(v.node.rotation) v.renderer.update_visual(p.level, delta) v.node.modulate.a = 1.0 if p.hp > 0.0 else 0.3 # dim when down v.last_pos = p.pos if sim.pilots.size() > 1: var pts := _coop_cam_points() var c := Vector2.ZERO for q in pts: c += q camera.global_position = c / float(pts.size())Add the helper next to _coop_inputs:
# The positions the co-op camera frames: every live pilot, or P1 alone when none is alive. With 2# pilots this reproduces the old midpoint / survivor / P1 cases exactly.func _coop_cam_points() -> PackedVector2Array: var pts := PackedVector2Array() for p in sim.pilots: if p.hp > 0.0: pts.append(p.pos) if pts.is_empty(): pts.append(sim.player.pos) return ptsReplace the if sim.player2 != null: hurt block (L2196-2202) with:
# Co-pilots: the same discrete-hit detect per pilot, so every co-pilot's hit plays audio.hurt(). # Screen flash/punch stay P1-only (they belong to the one shared camera). for k in range(1, mini(_pilot_views.size(), sim.pilots.size())): var hv: PilotView = _pilot_views[k] var hp_now := sim.pilots[k].hp var hits_now := sim.pilots[k].hard_hits if hp_now < hv.last_hp - 0.001 and hits_now != hv.last_hard_hits: audio.hurt() hv.last_hard_hits = hits_now hv.last_hp = hp_now- Step 11: Run the tests
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop_main_views.gd -gexit 2>&1 | tail -30
Expected: PASS.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_main.gd -gexit 2>&1 | tail -30
Expected: PASS. In particular test_p2_hard_hit_plays_hurt_sound_in_coop, test_visible_world_rect_centers_on_camera_midpoint_in_coop and the pause/level-up co-op tests are all unchanged.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_replay.gd -gexit 2>&1 | tail -30
Expected: PASS.
- Step 12: Full suite and determinism
Run: scripts/check-test-count.sh
Expected: exit 0. All three determinism files pass unchanged (main does not touch the sim’s tick order).
- Step 13: Commit
cat > "$TMPDIR/bh-msg" <<'EOF'refactor(coop): main renders and feeds every pilot through PilotView and an input array
player2_* fields become an index-aligned Array[PilotView]; co-pilot render, dash audioand hurt audio loop over all co-pilots; main calls sim.tick(inputs). Join is still2-pilot. ReplayLoader.input_at returns null past a short row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01BfcSqKg4b4daxk12fDv5KDEOFgit add render/pilot_view.gd* main.gd input/replay_loader.gd tests/test_coop_main_views.gd tests/test_replay.gdgit commit --file="$TMPDIR/bh-msg"Task 3: Join up to 4 pads with explicit bindings (rebind on reconnect, haptics hook)
Section titled “Task 3: Join up to 4 pads with explicit bindings (rebind on reconnect, haptics hook)”Files:
- Create:
input/pilot_bindings.gd - Create:
tests/test_pilot_bindings.gd - Modify:
main.gd:_can_p2_join→_can_join(L958);- the join block (L1565-1578);
_coop_inputs(Task 2);_pausing_pilot(L541);_leveling_pilot_device(L2222);_new_run.
- Modify:
tests/test_main.gd. The threetest_can_p2_join_*tests (L1172-1189) become_can_jointests. - Create:
tests/test_coop_join.gd
Interfaces:
-
Consumes:
- Task 1:
Sim.MAX_LOCAL_PILOTS,Sim.join_offset(k),Sim.add_pilot(offset) -> PlayerState(which may be null). - Task 2:
_ensure_pilot_views(),_coop_inputs(...).
- Task 1:
-
Produces:
class_name PilotBindings extends RefCounted:const UNBOUND := -1var devices: Array[int]func reset() -> voidfunc bind(pilot_idx: int, device: int) -> voidfunc device_for(k: int, playable: Array) -> intfunc pilot_for_device(device: int, playable: Array, pilot_count: int) -> intfunc orphans(playable: Array, pilot_count: int) -> Array[int]func resolve(playable: Array, pressed_a: Array, pilot_count: int, can_add: bool) -> Dictionary, returning one of:{"action": "none"}{"action": "join", "device": int}{"action": "rebind", "pilot": int, "device": int}
func commit_join(new_idx: int, device: int, playable: Array) -> void
- In
main:var _bindings: PilotBindingsfunc _can_join(sim: Sim, mode: String) -> boolfunc _handle_join_presses(playable: Array, pressed: Array) -> voidfunc _join_pilot(device: int, playable: Array) -> voidfunc _pilot_device(k: int) -> int
-
Step 1: Write the failing pure tests
tests/test_pilot_bindings.gd:
extends GutTest
# PilotBindings (plan 2026-10-05-local-coop-4-pilots.md, Task 3): which pad drives which pilot.# Explicit once a pilot joins; implicit playable[k] only for pilots never bound (tests/tools).# `playable` is always InputRouter.playable_joypads(), so the Siri Remote is never in it.
func test_solo_second_pad_pressing_a_joins() -> void: var b := PilotBindings.new() assert_eq(b.resolve([0, 1], [1], 1, true), {"action": "join", "device": 1})
func test_solo_p1s_own_pad_pressing_a_does_not_join() -> void: var b := PilotBindings.new() assert_eq(b.resolve([0, 1], [0], 1, true), {"action": "none"}, "P1 implicitly owns playable[0]")
func test_a_pad_outside_the_playable_list_never_joins() -> void: var b := PilotBindings.new() assert_eq(b.resolve([0, 1], [7], 1, true), {"action": "none"}, "device 7 (e.g. the Siri Remote) is not playable")
func test_join_refused_at_cap() -> void: var b := PilotBindings.new() assert_eq(b.resolve([0, 1, 2, 3, 4], [4], 4, false), {"action": "none"})
func test_commit_join_freezes_p1_and_binds_the_new_pilot() -> void: var b := PilotBindings.new() b.commit_join(1, 3, [0, 1, 3]) assert_eq(b.device_for(0, [0, 1, 3]), 0, "P1 frozen to the pad it implicitly had") assert_eq(b.device_for(1, [0, 1, 3]), 3, "P2 bound to the pad that pressed A, not playable[1]") assert_eq(b.pilot_for_device(3, [0, 1, 3], 2), 1) assert_eq(b.pilot_for_device(1, [0, 1, 3], 2), -1, "pad 1 belongs to nobody")
func test_four_pads_join_in_press_order() -> void: var b := PilotBindings.new() var pads := [10, 11, 12, 13] var n := 1 for d: int in [12, 11, 13]: var r := b.resolve(pads, [d], n, n < Sim.MAX_LOCAL_PILOTS) assert_eq(String(r["action"]), "join") b.commit_join(n, int(r["device"]), pads) n += 1 assert_eq(b.device_for(1, pads), 12) assert_eq(b.device_for(2, pads), 11) assert_eq(b.device_for(3, pads), 13) assert_eq(b.resolve(pads, [10, 11, 12, 13], 4, false), {"action": "none"}, "every pad is claimed")
func test_disconnected_pad_orphans_its_pilot_and_a_new_pad_takes_it_back() -> void: var b := PilotBindings.new() b.commit_join(1, 1, [0, 1, 2]) b.commit_join(2, 2, [0, 1, 2]) var after := [0, 2, 9] # pad 1 dropped, pad 9 appeared assert_eq(b.device_for(1, after), PilotBindings.UNBOUND, "P2's pad is gone") var orph := b.orphans(after, 3) assert_eq(orph.size(), 1, "exactly one orphan") assert_eq(orph[0], 1, "and it is P2") assert_eq(b.resolve(after, [9], 3, true), {"action": "rebind", "pilot": 1, "device": 9}, "a fresh pad re-claims the orphan before it would ever add a pilot") b.bind(1, 9) assert_eq(b.device_for(1, after), 9)
func test_orphans_ignore_solo() -> void: var b := PilotBindings.new() assert_eq(b.orphans([], 1).size(), 0, "solo P1 polls every pad; never an orphan")
func test_implicit_mapping_for_never_bound_pilots() -> void: var b := PilotBindings.new() assert_eq(b.device_for(1, [0, 1]), 1, "unbound pilot k reads playable[k] (FakeCoopRouter tests)") assert_eq(b.device_for(2, [0, 1]), PilotBindings.UNBOUND) assert_eq(b.pilot_for_device(1, [0, 1], 2), 1)- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_pilot_bindings.gd -gexit 2>&1 | tail -30
Expected: FAIL (PilotBindings not found).
- Step 3: Create
input/pilot_bindings.gd
class_name PilotBindingsextends RefCounted
# Which joypad drives which pilot in local co-op (plan 2026-10-05-local-coop-4-pilots.md).# devices[k] is pilot k's pad. Every real join binds explicitly (commit_join), so a pad's slot in# InputRouter.playable_joypads() can shift (a pad connects/drops) without swapping pilots. A pilot# never bound falls back to playable[k] -- the old implicit mapping, still used by tests and tools# that add pilots directly. `playable` is always the PLAYABLE list (Siri Remote filtered out).# Pure: no Input calls, so every decision is unit-testable.
const UNBOUND := -1
var devices: Array[int] = []
func reset() -> void: devices.clear()
func bind(pilot_idx: int, device: int) -> void: while devices.size() <= pilot_idx: devices.append(UNBOUND) devices[pilot_idx] = device
# Pilot k's pad right now, or UNBOUND. An explicit binding whose pad is no longer playable is# UNBOUND (that pilot holds still until a pad re-claims it), never a fallback to someone else's pad.func device_for(k: int, playable: Array) -> int: if k < devices.size() and devices[k] != UNBOUND: return devices[k] if playable.has(devices[k]) else UNBOUND return int(playable[k]) if k < playable.size() else UNBOUND
func pilot_for_device(device: int, playable: Array, pilot_count: int) -> int: for k in pilot_count: if device_for(k, playable) == device: return k return -1
# Pilots whose pad has gone (co-op only: a solo P1 reads every pad through InputRouter.poll()).func orphans(playable: Array, pilot_count: int) -> Array[int]: var out: Array[int] = [] if pilot_count <= 1: return out for k in pilot_count: if device_for(k, playable) == UNBOUND: out.append(k) return out
# Decide what an A press on `pressed_a` pads means. An unclaimed playable pad first re-claims the# lowest orphaned pilot (reconnect), else joins as a new pilot when `can_add`. Pads already driving# a pilot, and pads outside `playable`, are ignored.func resolve(playable: Array, pressed_a: Array, pilot_count: int, can_add: bool) -> Dictionary: var claimed: Array[int] = [] for k in pilot_count: var d := device_for(k, playable) if d != UNBOUND: claimed.append(d) for dv: Variant in pressed_a: var d := int(dv) if not playable.has(d) or claimed.has(d): continue var orph := orphans(playable, pilot_count) if not orph.is_empty(): return {"action": "rebind", "pilot": orph[0], "device": d} if can_add: return {"action": "join", "device": d} return {"action": "none"}
# Record a join: freeze every existing pilot's current (possibly implicit) pad, then bind the new# pilot to the pad that pressed A.func commit_join(new_idx: int, device: int, playable: Array) -> void: for k in new_idx: if k >= devices.size() or devices[k] == UNBOUND: var d := device_for(k, playable) if d != UNBOUND: bind(k, d) bind(new_idx, device)Run: godot --headless --path . --import > /dev/null 2>&1; git status --short (restore project.godot / audio/bus_layout.tres if listed).
- Step 4: Run the pure tests
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_pilot_bindings.gd -gexit 2>&1 | tail -30
Expected: PASS.
- Step 5: Write the failing main tests
In tests/test_main.gd, replace the three test_can_p2_join_* functions (L1172-1189) with:
# ── Stage 0: co-op join blocked during Tutorial (2026-08-10); cap raised to 4 pilots (2026-10-05) ──func test_can_join_false_in_tutorial(): var sim := Sim.new(1, SimContentFixture.db()) var m = preload("res://main.gd").new() assert_false(m._can_join(sim, "tutorial"), "join is blocked for the duration of Tutorial") m.free()
func test_can_join_true_in_survival_below_the_cap(): var sim := Sim.new(1, SimContentFixture.db()) sim.add_pilot() sim.add_pilot(Sim.join_offset(2)) var m = preload("res://main.gd").new() assert_true(m._can_join(sim, "survival"), "a 4th pilot may still join with 3 present") m.free()
func test_can_join_false_at_max_local_pilots(): var sim := Sim.new(1, SimContentFixture.db()) for k in range(1, Sim.MAX_LOCAL_PILOTS): sim.add_pilot(Sim.join_offset(k)) var m = preload("res://main.gd").new() assert_false(m._can_join(sim, "survival"), "no 5th pilot") m.free()Create tests/test_coop_join.gd:
extends GutTest
# main.gd join path for up to 4 pilots (plan 2026-10-05-local-coop-4-pilots.md, Task 3). Real# joypads are not available headless, so these drive _handle_join_presses(playable, pressed)# directly -- the same seam _physics_process feeds from Input.is_joy_button_pressed.
class FakePadsRouter extends InputRouter: var pads: Array = [0, 1, 2, 3] func playable_joypads() -> Array: return pads
func _main() -> Node: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") m.input_router = FakePadsRouter.new() return m
func test_four_pads_join_up_to_four_pilots() -> void: var m = await _main() var pads: Array = [0, 1, 2, 3] m._handle_join_presses(pads, [1]) m._handle_join_presses(pads, [2]) m._handle_join_presses(pads, [3]) assert_eq(m.sim.pilots.size(), 4, "three extra pads -> four pilots") m._handle_join_presses(pads, [3]) assert_eq(m.sim.pilots.size(), 4, "a bound pad pressing A again does nothing") assert_eq(m.sim.pilots[2].pos - m.sim.player.pos, Sim.join_offset(2), "P3 drops in on the left") assert_eq(m.sim.pilots[3].pos - m.sim.player.pos, Sim.join_offset(3), "P4 drops in below") assert_eq(m._pilot_views.size(), 4, "every joiner gets a ship") assert_false(m._auto, "a join hands the run to humans")
func test_joiner_shares_p1_locked_weapons() -> void: var m = await _main() m.sim.player.arsenal.locked_weapons = {"blade": true} # PilotArsenal.locked_weapons is a Dictionary m._handle_join_presses([0, 1, 2, 3], [2]) assert_true(m.sim.pilots[1].arsenal.locked_weapons.has("blade"), "one save, one shop")
func test_join_binds_the_pressing_pad_not_the_slot() -> void: var m = await _main() m.input_router.pads = [0, 1, 5] m._handle_join_presses([0, 1, 5], [5]) assert_eq(m._pilot_device(1), 5, "P2 reads the pad that pressed A (implicit would say 1)") m.sim.pilots[1].pending_levelups = 1 m._leveling_pilot = m.sim.pilots[1] assert_eq(m._leveling_pilot_device(), 5, "P2's level-up panel is gated to P2's own pad")
func test_pause_from_p3_binds_p3_stats() -> void: var m = await _main() m._handle_join_presses([0, 1, 2, 3], [1]) m._handle_join_presses([0, 1, 2, 3], [2]) m._toggle_pause(2) assert_eq(m.pause_menu._sheet._pilot, m.sim.pilots[2], "P3 pausing binds P3's stats")
func test_reconnected_pad_takes_its_pilot_back() -> void: var m = await _main() m._handle_join_presses([0, 1, 2], [1]) m._handle_join_presses([0, 1, 2], [2]) m.input_router.pads = [0, 2, 9] # P2's pad dropped, a new pad 9 appeared var inputs: Array[InputState] = m._coop_inputs(InputState.new(), [0, 2, 9]) assert_null(inputs[1], "P2 holds still while its pad is gone") m._handle_join_presses([0, 2, 9], [9]) assert_eq(m.sim.pilots.size(), 3, "re-claim, not a 4th pilot") assert_eq(m._pilot_device(1), 9, "pad 9 now drives P2")
func test_new_run_forgets_bindings() -> void: var m = await _main() m._handle_join_presses([0, 1, 2, 3], [3]) m._on_mode_chosen("survival") assert_eq(m._bindings.devices.size(), 0, "a fresh run starts solo with no bindings")- Step 6: Run to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop_join.gd -gexit 2>&1 | tail -30
Expected: FAIL (_handle_join_presses not found).
- Step 7: Implement in
main.gd
Add the field next to _pilot_views:
var _bindings := PilotBindings.new() # pilot k -> joypad (explicit once joined); reset per runIn _new_run, next to _pilot_views.clear(), add _bindings.reset().
Replace _can_p2_join and keep its comment block, changing only the last sentence:
func _can_join(sim: Sim, mode: String) -> bool: return not V01_LOCK_COOP and mode != "tutorial" and sim.pilots.size() < Sim.MAX_LOCAL_PILOTSReplace the whole join block (from var playable := input_router.playable_joypads() through the _ensure_pilot_views() that Task 2 left in it) with:
# Drop-in join / reconnect: any PLAYABLE pad (Siri Remote filtered out) not already driving a # pilot that presses A either re-claims an orphaned pilot (its pad dropped) or joins as the next # pilot, up to Sim.MAX_LOCAL_PILOTS. var playable := input_router.playable_joypads() var pressed: Array = [] for d: Variant in playable: if Input.is_joy_button_pressed(int(d), JOY_BUTTON_A): pressed.append(int(d)) if not pressed.is_empty(): _handle_join_presses(playable, pressed)Add the helpers next to _coop_inputs:
func _handle_join_presses(playable: Array, pressed: Array) -> void: var r := _bindings.resolve(playable, pressed, sim.pilots.size(), _can_join(sim, _mode)) match String(r["action"]): "join": _join_pilot(int(r["device"]), playable) "rebind": _bindings.bind(int(r["pilot"]), int(r["device"]))
# A new pilot drops in beside P1 (Sim.join_offset), mirrors P1's hull stats and locked weapons# (one save, one shop), and is bound to the pad that pressed A.func _join_pilot(device: int, playable: Array) -> void: var p := sim.add_pilot(Sim.join_offset(sim.pilots.size())) if p == null: return _auto = false p.arsenal.locked_weapons = sim.player.arsenal.locked_weapons.duplicate() # Base stats + headline stat bonus only; the shared-Sim.mods branch (e.g. Prism lifesteal) # deliberately does NOT re-apply -- see ShipBonuses.apply_stat_bonus_to. if meta != null: ShipBonuses.apply_base_stats_to(meta.selected_ship, p) ShipBonuses.apply_stat_bonus_to(meta.selected_ship, p) _bindings.commit_join(sim.pilots.size() - 1, device, playable) _ensure_pilot_views()
# Pilot k's pad right now, or PilotBindings.UNBOUND (-1).func _pilot_device(k: int) -> int: return _bindings.device_for(k, input_router.playable_joypads())Replace _coop_inputs from Task 2 with the bindings version:
# Co-op: each pilot reads its own bound pad, index-aligned with sim.pilots. A pilot whose pad is# gone gets null (holds still) until a pad re-claims it -- except P1 with no pad at all, who keeps# `fallback_p1` (keyboard/touch), matching the old 2-pilot behaviour.func _coop_inputs(fallback_p1: InputState, playable: Array) -> Array[InputState]: var out: Array[InputState] = [] for k in sim.pilots.size(): var dev := _bindings.device_for(k, playable) if dev != PilotBindings.UNBOUND: out.append(input_router.poll_device(dev)) else: out.append(fallback_p1 if k == 0 else null) return outReplace the body of _pausing_pilot:
func _pausing_pilot(device: int) -> PlayerState: if sim == null or sim.pilots.size() <= 1 or device < 0: return sim.player if sim != null else null var idx := _bindings.pilot_for_device(device, input_router.playable_joypads(), sim.pilots.size()) return sim.pilots[idx] if idx >= 0 else sim.playerReplace the body of _leveling_pilot_device:
func _leveling_pilot_device() -> int: if sim.pilots.size() <= 1: return -1 var idx := _leveling_pilot_idx() return _pilot_device(idx) if idx >= 0 else -1 # UNBOUND (-1) = pad gone: ungated, so the run never deadlocksCheck for leftovers:
Run: git grep -n "_can_p2_join\|playable\[1\]" -- main.gd tests
Expected: no output.
- Step 8: Haptics hook (conditional, plan must not depend on it)
Run: git grep -n "func _drive_haptics" main.gd
- If there is no output, the haptics plan has not merged. Skip this step.
- If there is output, then in
_drive_haptics, directly after theroutes = HapticsOutput.routes_for(...)assignment in the non-override branch, add:
# Local co-op: route pilot k's buzz to its BOUND pad (PilotBindings), not playable[k], # so a pad that joined out of slot order -- or re-claimed a pilot -- rumbles the right hands. if n > 1: for k in mini(n, routes.size()): var dev := _pilot_device(k) (routes[k] as Dictionary)["joypads"] = [dev] if dev != PilotBindings.UNBOUND else []Then add to tests/test_coop_join.gd:
func test_haptic_routes_follow_bindings() -> void: var m = await _main() if not m.has_method("_drive_haptics"): pass_test("haptics plan not merged") return m._handle_join_presses([0, 1, 5], [5]) m.input_router.pads = [0, 1, 5] m._drive_haptics([]) assert_eq((m.haptics.output.routes[1] as Dictionary)["joypads"], [5], "P2 rumbles the pad it joined with")- Step 9: Run the tests
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop_join.gd -gexit 2>&1 | tail -30
Expected: PASS.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_main.gd -gexit 2>&1 | tail -30
Expected: PASS. The FakeCoopRouter pause tests still pass through the implicit fallback.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_local_coop_input.gd -gexit 2>&1 | tail -30
Expected: PASS.
- Step 10: Full suite
Run: scripts/check-test-count.sh
Expected: exit 0.
- Step 11: Commit
cat > "$TMPDIR/bh-msg" <<'EOF'feat(coop): up to 4 pads join with explicit pilot bindings and reconnect re-claim
PilotBindings maps pilot to pad explicitly on join; an orphaned pilot is re-claimed bythe next unbound pad that presses A. Pause and level-up gating read the binding.Only playable joypads count, so the Siri Remote never joins.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01BfcSqKg4b4daxk12fDv5KDEOFgit add input/pilot_bindings.gd* main.gd tests/test_pilot_bindings.gd tests/test_coop_join.gd tests/test_main.gdgit commit --file="$TMPDIR/bh-msg"Task 4: Soft co-op tether (sim, remote tunables, co-op determinism check)
Section titled “Task 4: Soft co-op tether (sim, remote tunables, co-op determinism check)”Files:
- Create:
sim/coop_tether.gd - Modify:
sim/sim.gd, thetick()integrate loop from Task 1 - Modify:
net/tunables_registry.gd:- a script const next to
const SIM := "res://sim/sim.gd"; - two
ENTRIESrows after theSpawnTable.STEP_Srow.
- a script const next to
- Create:
tools/tunable_docs/docs.d/coop.json - Regenerate:
control/src/tunable_docs.js - Create:
tests/test_coop_tether.gd - Possibly modify:
tests/test_determinism_coop.gd(Step 9 decides)
Interfaces:
- Consumes (Task 1): the per-pilot integrate loop in
Sim.tick(). - Produces:
class_name CoopTether extends RefCountedwith:static var TETHER_RADIUS: float = 300.0static var TETHER_DAMP: float = 0.35static func capture(pilots: Array[PlayerState]) -> PackedVector2Arraystatic func apply(pilots: Array[PlayerState], pre: PackedVector2Array) -> void- Remote keys
CoopTether.TETHER_RADIUS(160..340) andCoopTether.TETHER_DAMP(0.15..0.8).
Why 340 is the radius ceiling: on iPhone (553.8 canvas units tall) at the 0.7 zoom floor from Task 5, the visible half-height is 553.8 / (2 x 0.7) ≈ 396 world units. At radius 340 the tethered pilot still sits about 56 units (more than a ship radius of 16) inside the screen edge.
- Step 1: Write the failing test
tests/test_coop_tether.gd:
extends GutTest
# Soft co-op tether (plan 2026-10-05-local-coop-4-pilots.md, Task 4). Past TETHER_RADIUS from the# live centroid, the OUTWARD part of a pilot's movement this tick is scaled by TETHER_DAMP; inward# movement, downed pilots and solo play are untouched. Both knobs are remote tunables.
func after_each() -> void: Tunables.reset()
func _pair(post_b: Vector2) -> Array[PlayerState]: var a := PlayerState.new() a.pos = Vector2.ZERO var b := PlayerState.new() b.pos = post_b var ps: Array[PlayerState] = [a, b] return ps
func test_outward_motion_past_the_radius_is_damped() -> void: var ps := _pair(Vector2(710, 0)) var pre := PackedVector2Array([Vector2.ZERO, Vector2(700, 0)]) CoopTether.apply(ps, pre) # centroid (355, 0); B is 355 out (> 300) and moved 10 outward -> keeps 10 x 0.35 assert_almost_eq(ps[1].pos.x, 703.5, 0.001, "outward step scaled by TETHER_DAMP") assert_eq(ps[0].pos, Vector2.ZERO, "the still pilot is untouched")
func test_inward_motion_is_never_damped() -> void: var ps := _pair(Vector2(700, 0)) var pre := PackedVector2Array([Vector2.ZERO, Vector2(710, 0)]) CoopTether.apply(ps, pre) assert_eq(ps[1].pos, Vector2(700, 0), "moving back toward the group is free")
func test_inside_the_radius_nothing_changes() -> void: var ps := _pair(Vector2(210, 0)) var pre := PackedVector2Array([Vector2.ZERO, Vector2(200, 0)]) CoopTether.apply(ps, pre) assert_eq(ps[1].pos, Vector2(210, 0))
func test_needs_two_live_pilots() -> void: var ps := _pair(Vector2(710, 0)) ps[0].hp = 0.0 var pre := PackedVector2Array([Vector2.ZERO, Vector2(700, 0)]) CoopTether.apply(ps, pre) assert_eq(ps[1].pos, Vector2(710, 0), "one live pilot -> no tether")
func test_downed_pilot_is_not_pulled() -> void: var a := PlayerState.new() var b := PlayerState.new() b.pos = Vector2(-50, 0) var c := PlayerState.new() c.pos = Vector2(1210, 0) c.hp = 0.0 var ps: Array[PlayerState] = [a, b, c] var pre := PackedVector2Array([Vector2.ZERO, Vector2(-50, 0), Vector2(1200, 0)]) CoopTether.apply(ps, pre) assert_eq(ps[2].pos, Vector2(1210, 0), "a downed pilot neither counts nor gets pulled")
# Liveness through the REAL path: Tunables.apply + a real Sim.tick, not a hand-set static var.func _outward_step(patch: Dictionary, p2_offset: Vector2) -> float: Tunables.reset() if not patch.is_empty(): assert_eq(Tunables.apply(patch).size(), 0, "patch accepted: %s" % str(patch)) var s := Sim.new(1234, SimContentFixture.db()) var p2 := s.add_pilot(p2_offset) var x0 := p2.pos.x var inputs: Array[InputState] = [InputState.new(Vector2.LEFT), InputState.new(Vector2.RIGHT)] s.tick(inputs) return p2.pos.x - x0
func test_damp_is_live_in_a_real_tick() -> void: var damped := _outward_step({}, Vector2(800, 0)) var looser := _outward_step({"CoopTether.TETHER_DAMP": 0.8}, Vector2(800, 0)) assert_gt(damped, 0.0, "P2 still drifts outward (soft, not a wall)") assert_gt(looser, damped, "a higher TETHER_DAMP keeps more of the outward step")
func test_radius_is_live_in_a_real_tick() -> void: # ~324 apart after the step: past the default 300, inside a patched 340. var damped := _outward_step({}, Vector2(640, 0)) var free := _outward_step({"CoopTether.TETHER_RADIUS": 340.0}, Vector2(640, 0)) assert_gt(free, damped, "a larger radius stops damping at this distance")
func test_out_of_range_values_are_rejected() -> void: assert_gt(Tunables.apply({"CoopTether.TETHER_RADIUS": 1000.0}).size(), 0, "radius above 340 refused") assert_gt(Tunables.apply({"CoopTether.TETHER_DAMP": 0.0}).size(), 0, "damp below 0.15 refused")
func test_solo_tick_never_tethers() -> void: var s := Sim.new(1234, SimContentFixture.db()) var x0 := s.player.pos.x var one: Array[InputState] = [InputState.new(Vector2.RIGHT)] s.tick(one) assert_almost_eq(s.player.pos.x - x0, s.player.speed * Sim_Const.DT, 0.001, "solo moves at full speed")- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop_tether.gd -gexit 2>&1 | tail -30
Expected: FAIL (CoopTether not found).
- Step 3: Create
sim/coop_tether.gd
class_name CoopTetherextends RefCounted
# Soft co-op tether (spec 2026-10-05-local-coop-4-pilots-design.md). Pure sim logic, run by# Sim.tick() between movement and warps, and only with 2+ pilots. Once a live pilot is farther# than TETHER_RADIUS from the live centroid, the OUTWARD part of its movement this tick is scaled# by TETHER_DAMP. Inward and sideways movement, warps (applied after this), downed pilots and solo# play are untouched. It is soft by design: a held stick still drifts out at TETHER_DAMP x speed.# Both knobs are remote tunables (net/tunables_registry.gd, bounds 160-340 / 0.15-0.8).
static var TETHER_RADIUS: float = 300.0static var TETHER_DAMP: float = 0.35
# Every pilot's position before this tick's movement, index-aligned with `pilots`.static func capture(pilots: Array[PlayerState]) -> PackedVector2Array: var out := PackedVector2Array() for p in pilots: out.append(p.pos) return out
static func apply(pilots: Array[PlayerState], pre: PackedVector2Array) -> void: var centre := Vector2.ZERO var alive := 0 for p in pilots: if p.hp > 0.0: centre += p.pos alive += 1 if alive < 2: return centre /= float(alive) var radius := TETHER_RADIUS # hoisted: one static read per tick, not per pilot var shed := 1.0 - TETHER_DAMP for k in mini(pilots.size(), pre.size()): var p := pilots[k] if p.hp <= 0.0: continue var off := p.pos - centre var d := off.length() if d <= radius: continue var n := off / d var outward := (p.pos - pre[k]).dot(n) if outward <= 0.0: continue p.pos -= n * outward * shed if p.clamp_arena: p.pos.x = clampf(p.pos.x, -Sim_Const.ARENA_HALF, Sim_Const.ARENA_HALF) p.pos.y = clampf(p.pos.y, -Sim_Const.ARENA_HALF, Sim_Const.ARENA_HALF)Run: godot --headless --path . --import > /dev/null 2>&1; git status --short (restore project.godot / audio/bus_layout.tres if listed).
- Step 4: Call it from
Sim.tick()
Wrap Task 1’s integrate loop:
# Soft co-op tether: positions before movement, so only THIS tick's outward step is damped. # Solo never captures or applies (single-player baseline untouched). var _tether_pre := CoopTether.capture(pilots) if pilots.size() > 1 else PackedVector2Array() for k in pilots.size(): var mover := pilots[k] mover.move_mult = elemental_system.web_slow_mult(self, mover) * dimension_hazards.speed_mult_at(mover.pos) if mover.hp > 0.0 and _pilot_inputs[k] != null: mover.integrate(_pilot_inputs[k], dt) if pilots.size() > 1: CoopTether.apply(pilots, _tether_pre) # before the dash loop: warps are never damped- Step 5: Register the tunables
In net/tunables_registry.gd, next to const SIM := "res://sim/sim.gd":
const COOP_TETHER := "res://sim/coop_tether.gd"After the SpawnTable.STEP_S row in ENTRIES:
# Local co-op tether (2026-10-05). Radius ceiling 340: at the 0.7 co-op zoom floor the iPhone # shows ~396 world units above/below the centroid, so a tethered pilot stays on screen. {"key": "CoopTether.TETHER_RADIUS", "script": COOP_TETHER, "name": "TETHER_RADIUS", "min": 160.0, "max": 340.0}, {"key": "CoopTether.TETHER_DAMP", "script": COOP_TETHER, "name": "TETHER_DAMP", "min": 0.15, "max": 0.8},- Step 6: Console docs
Create tools/tunable_docs/docs.d/coop.json:
{ "CoopTether.TETHER_RADIUS": {"label": "Co-op tether radius", "desc": "Distance from the group centre where a co-op pilot starts to feel the tether. Past it, moving further out is slowed.", "unit": "px", "src": "code", "group": "Player & warp"}, "CoopTether.TETHER_DAMP": {"label": "Co-op tether outward speed", "desc": "Share of outward movement a co-op pilot keeps once past the tether radius. Lower holds the group tighter. Warps are never slowed.", "unit": "frac", "src": "code", "group": "Player & warp"}}Run: node tools/tunable_docs/generate.mjs && node --test control/test/tunable-docs.test.mjs 2>&1 | tail -15
Expected: the generator rewrites control/src/tunable_docs.js, and the test passes. If a unit is rejected, read the UNITS list in control/test/tunable-docs.test.mjs and use the closest listed unit.
- Step 7: Run the tether and registry tests
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop_tether.gd -gexit 2>&1 | tail -30
Expected: PASS.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_tunables_registry.gd -gexit 2>&1 | tail -30
Expected: PASS. Both new static vars are registered, with usable ranges.
- Step 8: Liveness probe (record the result, do not gate on it)
Run: godot --headless --path . -s res://tools/remote_config/liveness_probe.gd -- --keys=CoopTether.TETHER_RADIUS,CoopTether.TETHER_DAMP --coop=coop 2>&1 | tail -20
- If both keys are LIVE, note that in the commit message.
- If either is NO-DIFF, the two autopiloted pilots never drifted past the radius in that run. That is a scenario limit, not a dead key.
test_damp_is_live_in_a_real_tickandtest_radius_is_live_in_a_real_tickare the real-path evidence (theremote-config.mdstep 5 “trace by hand” case). Record “probe NO-DIFF (scenario-limited), GUT liveness tests pass” in the commit message.
Then git status --short and restore project.godot / audio/bus_layout.tres if listed.
- Step 9: Co-op determinism: check, then handle either outcome
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism -gexit 2>&1 | tail -40
-
test_determinism_survival.gdortest_determinism.gdfails: this is a bug. The tether ran in solo. Fix Step 4 and do not re-pin. -
All pass: the pilots in
_coop_run()never got past the radius moving outward. Nothing to re-pin. Go to Step 10. -
Only
test_determinism_coop.gdfails: confirm the tether is the cause.- Temporarily (do NOT commit) set
static var TETHER_RADIUS: float = 100000.0insim/coop_tether.gd. - Re-run
-gselect=test_determinism_coop.gd.- If it passes, the tether is confirmed as the only cause.
- If it still fails, something in Tasks 1-4 changed 2-pilot behaviour. Stop and find it.
- Restore
300.0. - Re-pin per skill
bh-determinism-repin, intests/test_determinism_coop.gdONLY. (The skill namestest_determinism_checksum.gd/test_determinism_crystals.gd. Those files no longer exist, so apply its process to this file.) Concretely:- read the actual values from the GUT failure output;
- move the current pinned pair into a new
# Prior: <old checksum> / <old snapshot hash>.line, matching the file’s existing trail; - add above it
# Re-pinned 2026-10-05 (local co-op soft tether, plan 2026-10-05-local-coop-4-pilots.md Task 4): P1 and P2 drift past TETHER_RADIUS from their centroid in this window, so the outward part of their movement is damped. Spec-approved re-pin of the 2-pilot baseline only; the single-pilot baselines did not move.; - note whether only the checksum moved, or the snapshot hash too;
- update the two literals.
- Re-run
-gselect=test_determinismand expect all PASS.
- Temporarily (do NOT commit) set
-
Step 10: Full suite
Run: scripts/check-test-count.sh
Expected: exit 0.
Run: node --test scripts/test/*.test.mjs 2>&1 | tail -5
Expected: pass. The skills-parity and docs tests are unaffected.
- Step 11: Commit
cat > "$TMPDIR/bh-msg" <<'EOF'feat(coop): soft tether damps outward drift past a remote-tunable radius
CoopTether runs between movement and warps with 2+ live pilots only. TETHER_RADIUS(160-340) and TETHER_DAMP (0.15-0.8) are registered tunables with console docs.<one line: co-op baseline unchanged | co-op baseline re-pinned (tether), solo unchanged><one line: liveness probe result>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01BfcSqKg4b4daxk12fDv5KDEOFgit add sim/coop_tether.gd* sim/sim.gd net/tunables_registry.gd tools/tunable_docs/docs.d/coop.json control/src/tunable_docs.js tests/test_coop_tether.gd tests/test_determinism_coop.gdgit commit --file="$TMPDIR/bh-msg"(Replace the two <...> lines with the real outcomes before committing. They report results only and contain no hash values.)
Task 5: Co-op camera fits the group (zoom floor 0.7) and the radar follows it
Section titled “Task 5: Co-op camera fits the group (zoom floor 0.7) and the radar follows it”Files:
- Create:
render/coop_camera.gd - Create:
tests/test_coop_camera.gd - Modify:
main.gd:- the co-op camera lines Task 2 added in the render block;
_new_run;_radar_view_pilot(L3372-3376).
Interfaces:
-
Consumes (Task 2):
main._coop_cam_points() -> PackedVector2Array. -
Produces:
class_name CoopCamera extends RefCounted:const ZOOM_MIN := 0.7,const MARGIN := 120.0,const SMOOTH_RATE := 4.0static func centroid(points: PackedVector2Array) -> Vector2static func fit_zoom(points: PackedVector2Array, centre: Vector2, vp: Vector2, margin: float = MARGIN) -> floatstatic func smooth(cur: float, target: float, delta: float) -> float
main._cam_zoom: float
-
Step 1: Write the failing tests
tests/test_coop_camera.gd:
extends GutTest
# Co-op camera (plan 2026-10-05-local-coop-4-pilots.md, Task 5): centre on the live pilots, zoom# out (never past 0.7x) to keep them on screen; solo never touches zoom.
func test_centroid() -> void: var c := CoopCamera.centroid(PackedVector2Array([Vector2(-300, 0), Vector2(300, 0), Vector2(0, 600)])) assert_almost_eq(c.x, 0.0, 0.001) assert_almost_eq(c.y, 200.0, 0.001)
func test_close_group_keeps_full_zoom() -> void: var pts := PackedVector2Array([Vector2(-100, 0), Vector2(100, 0)]) assert_eq(CoopCamera.fit_zoom(pts, Vector2.ZERO, Vector2(1280, 720)), 1.0)
func test_wide_group_zooms_out_to_fit() -> void: var pts := PackedVector2Array([Vector2(-600, 0), Vector2(600, 0)]) # 1280 / (2 x (600 + 120)) = 0.8889 assert_almost_eq(CoopCamera.fit_zoom(pts, Vector2.ZERO, Vector2(1280, 720)), 0.8889, 0.001)
func test_tall_group_fits_on_height() -> void: var pts := PackedVector2Array([Vector2(0, -250), Vector2(0, 250)]) # 720 / (2 x (250 + 120)) = 0.973 assert_almost_eq(CoopCamera.fit_zoom(pts, Vector2.ZERO, Vector2(1280, 720)), 0.973, 0.001)
func test_zoom_never_below_the_floor() -> void: var pts := PackedVector2Array([Vector2(-1900, 0), Vector2(1900, 0)]) assert_eq(CoopCamera.fit_zoom(pts, Vector2.ZERO, Vector2(1203, 554)), CoopCamera.ZOOM_MIN)
func test_smooth_approaches_without_overshoot() -> void: var z := 1.0 for i in 600: z = CoopCamera.smooth(z, 0.7, 1.0 / 60.0) assert_gte(z, 0.7, "never overshoots the target") assert_almost_eq(z, 0.7, 0.001, "converges within 10 s")
func _main_with(n: int) -> Node: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") for k in range(1, n): m.sim.add_pilot(Sim.join_offset(k)) return m
func test_solo_camera_zoom_is_untouched() -> void: var m = await _main_with(1) for i in 30: m._process(1.0 / 60.0) assert_eq(m.camera.zoom, Vector2.ONE, "single-player look unchanged")
func test_spread_coop_group_zooms_out_and_radar_uses_the_camera() -> void: var m = await _main_with(2) m.sim.player.pos = Vector2(-1500, 0) m.sim.pilots[1].pos = Vector2(1500, 0) for i in 120: m._process(1.0 / 60.0) assert_lt(m.camera.zoom.x, 1.0, "zoomed out to frame both pilots") assert_gte(m.camera.zoom.x, CoopCamera.ZOOM_MIN, "never past the floor") assert_almost_eq(m.camera.global_position.x, 0.0, 0.5, "centred on the group") var view: PlayerState = m._radar_view_pilot() assert_almost_eq(view.pos.x, m.camera.global_position.x, 0.5, "radar measures from the camera centre in co-op")
func test_four_pilot_radar_view_uses_camera() -> void: var m = await _main_with(4) m._process(1.0 / 60.0) assert_ne(m._radar_view_pilot(), m.sim.player, "3+ pilots: radar centre is the camera, not P1")- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop_camera.gd -gexit 2>&1 | tail -30
Expected: FAIL (CoopCamera not found).
- Step 3: Create
render/coop_camera.gd
class_name CoopCameraextends RefCounted
# Local co-op camera maths (plan 2026-10-05-local-coop-4-pilots.md). Pure, so it is unit-tested# without a viewport. main.gd applies it only while sim.pilots.size() > 1; solo never writes zoom.# Camera2D zoom < 1 shows MORE world: visible world = viewport / zoom.
const ZOOM_MIN := 0.7 # legibility floor on iPhone; also bounds CoopTether.TETHER_RADIUS (max 340)const MARGIN := 120.0 # world units kept between the outermost pilot and the screen edgeconst SMOOTH_RATE := 4.0 # 1/s exponential approach, so a join or a death does not snap the view
static func centroid(points: PackedVector2Array) -> Vector2: if points.is_empty(): return Vector2.ZERO var c := Vector2.ZERO for p in points: c += p return c / float(points.size())
# The largest zoom (<= 1) at which every point sits MARGIN inside the screen around `centre`,# clamped to ZOOM_MIN.static func fit_zoom(points: PackedVector2Array, centre: Vector2, vp: Vector2, margin: float = MARGIN) -> float: var hx := 0.0 var hy := 0.0 for p in points: hx = maxf(hx, absf(p.x - centre.x)) hy = maxf(hy, absf(p.y - centre.y)) var z := minf(1.0, minf(vp.x / (2.0 * (hx + margin)), vp.y / (2.0 * (hy + margin)))) return clampf(z, ZOOM_MIN, 1.0)
static func smooth(cur: float, target: float, delta: float) -> float: return lerpf(cur, target, 1.0 - exp(-SMOOTH_RATE * delta))Run: godot --headless --path . --import > /dev/null 2>&1; git status --short (restore if listed).
- Step 4: Apply it in
main.gd
Add the field next to _pilot_views:
var _cam_zoom := 1.0 # co-op fit zoom (smoothed); solo never writes camera.zoomIn _new_run, next to _pilot_views.clear(), add _cam_zoom = 1.0. The camera is rebuilt per run, so its zoom starts at ONE anyway.
Replace the camera lines Task 2 added:
if sim.pilots.size() > 1: var pts := _coop_cam_points() var c := Vector2.ZERO for q in pts: c += q camera.global_position = c / float(pts.size())with:
if sim.pilots.size() > 1: var pts := _coop_cam_points() var target := CoopCamera.centroid(pts) camera.global_position = target var want := CoopCamera.fit_zoom(pts, target, get_viewport().get_visible_rect().size) _cam_zoom = CoopCamera.smooth(_cam_zoom, want, delta) camera.zoom = Vector2(_cam_zoom, _cam_zoom)Replace _radar_view_pilot:
func _radar_view_pilot() -> PlayerState: if sim.pilots.size() <= 1 or camera == null: return sim.player _radar_view.pos = camera.global_position return _radar_viewthreat_radar.cam_zoom = camera.zoom (L2140) and _visible_world_rect() already read camera.zoom, so radar culling and audio/telegraph culling follow the zoom without further change.
- Step 5: Run the tests
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop_camera.gd -gexit 2>&1 | tail -30
Expected: PASS.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_main.gd -gexit 2>&1 | tail -30
Expected: PASS (test_visible_world_rect_centers_on_camera_midpoint_in_coop still centres at x 0).
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_radar -gexit 2>&1 | tail -30
Expected: PASS.
- Step 6: Full suite
Run: scripts/check-test-count.sh
Expected: exit 0.
- Step 7: Commit
cat > "$TMPDIR/bh-msg" <<'EOF'feat(coop): camera frames every live pilot and zooms out to a 0.7 floor
CoopCamera centroid + fit zoom + smoothing, applied only with 2+ pilots; radarmeasures from the camera centre whenever more than one pilot is present.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01BfcSqKg4b4daxk12fDv5KDEOFgit add render/coop_camera.gd* main.gd tests/test_coop_camera.gdgit commit --file="$TMPDIR/bh-msg"Task 6: Pilot identity (palette, ship accent, world tags, web ripple for all pilots)
Section titled “Task 6: Pilot identity (palette, ship accent, world tags, web ripple for all pilots)”Files:
- Create:
render/pilot_palette.gd - Modify:
render/player_renderer.gd:- a new field next to
var _tier := -1; _rebuild()L277;- a new
set_pilot_accent().
- a new field next to
- Modify:
render/zone_renderer.gd,web_player_pos(L273-280) - Modify:
main.gd:_spawn_pilot_view,_ensure_pilot_views, and the co-pilot render loop. - Create:
tests/test_pilot_identity.gd - Modify:
tests/test_web_look_switch.gd(one new test)
Interfaces:
-
Consumes (Task 2):
PilotView.tag,PilotView.TAG_OFFSET,PilotView.TAG_SIZE,_ensure_pilot_views(),_spawn_pilot_view(k). -
Produces:
class_name PilotPalette extends RefCounted:const ACCENTS: Array[Color]static func accent(k: int) -> Colorstatic func tag(k: int) -> String
PlayerRenderer.set_pilot_accent(c: Color) -> void(alpha 0 = tier hue)func main._make_world_tag(k: int) -> Label
-
Step 1: Write the failing tests
tests/test_pilot_identity.gd:
extends GutTest
# Pilot identity (plan 2026-10-05-local-coop-4-pilots.md, Task 6): cyan / magenta / amber / green# accents and P1-P4 tags. P1's single-player look is unchanged (no tint, no tag).
func test_palette_is_four_distinct_accents_with_p1_cyan() -> void: assert_eq(PilotPalette.ACCENTS.size(), Sim.MAX_LOCAL_PILOTS) assert_eq(PilotPalette.accent(0), PlayerRenderer.TIER_ACCENT[0], "P1 cyan = the solo tier-0 hue") for i in 4: for j in range(i + 1, 4): assert_ne(PilotPalette.accent(i), PilotPalette.accent(j), "accents %d and %d differ" % [i, j]) assert_eq(PilotPalette.tag(0), "P1") assert_eq(PilotPalette.tag(3), "P4")
func test_renderer_takes_the_pilot_accent() -> void: var r := PlayerRenderer.new() add_child_autofree(r) await get_tree().process_frame r.update_visual(1, 1.0 / 60.0) var solo := r._halo.modulate assert_almost_eq(solo.r, PlayerRenderer.TIER_ACCENT[0].r, 0.001, "default = tier hue") r.set_pilot_accent(PilotPalette.accent(1)) assert_almost_eq(r._halo.modulate.r, PilotPalette.accent(1).r, 0.001, "magenta halo after set") assert_almost_eq(r._halo.modulate.b, PilotPalette.accent(1).b, 0.001)
func test_accent_set_before_ready_applies_on_first_build() -> void: var r := PlayerRenderer.new() r.set_pilot_accent(PilotPalette.accent(2)) add_child_autofree(r) await get_tree().process_frame r.update_visual(1, 1.0 / 60.0) assert_almost_eq(r._halo.modulate.g, PilotPalette.accent(2).g, 0.001, "amber from the first build")
func _main_with(n: int) -> Node: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") for k in range(1, n): m.sim.add_pilot(Sim.join_offset(k)) return m
func test_solo_has_no_world_tag() -> void: var m = await _main_with(1) m._process(1.0 / 60.0) assert_null(m._pilot_views[0].tag, "single-player P1 shows no tag")
func test_coop_tags_every_pilot_in_its_colour() -> void: var m = await _main_with(4) m._process(1.0 / 60.0) for k in 4: var t: Label = m._pilot_views[k].tag assert_not_null(t, "pilot %d has a tag" % k) assert_eq(t.text, "P%d" % (k + 1)) assert_eq(t.get_theme_color("font_color"), PilotPalette.accent(k), "tag %d in its pilot colour" % k) assert_eq(t.position, m.sim.pilots[k].pos + PilotView.TAG_OFFSET, "tag %d follows its ship" % k)
func test_copilot_ships_wear_their_accent_and_p1_keeps_the_tier_hue() -> void: var m = await _main_with(3) m._process(1.0 / 60.0) assert_eq(m._pilot_views[2].renderer.pilot_accent, PilotPalette.accent(2), "P3 ship is amber") assert_eq(m.player_renderer.pilot_accent.a, 0.0, "P1 ship keeps the tier hue in co-op too")Append to tests/test_web_look_switch.gd:
# 4-pilot co-op: the web ripples for the first two LIVE pilots out of ALL pilots, not just P1/P2.func test_venom_look_player_pos_reaches_p3_when_p2_is_down() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.player.pos = Vector2(10.0, 20.0) var p2 := sim.add_pilot(Vector2(70, 0)) sim.add_pilot(Vector2(-70, 0)) p2.hp = 0.0 assert_eq(ZoneRenderer.web_player_pos(sim), Vector4(10, 20, -60, 20), "P2 down: zw = P3")- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_pilot_identity.gd -gexit 2>&1 | tail -30
Expected: FAIL (PilotPalette not found).
- Step 3: Create
render/pilot_palette.gd
class_name PilotPaletteextends RefCounted
# Per-pilot identity colours and tags for local co-op (spec 2026-10-05-local-coop-4-pilots-design.md).# Gameplay content colours (pilot identity tints), not UI chrome, so they are not tokens. P1's cyan# equals PlayerRenderer.TIER_ACCENT[0]; P1's ship is never re-tinted, so the solo look is unchanged.
const ACCENTS: Array[Color] = [ Color(0.40, 0.85, 1.00), # P1 cyan # content-colour: pilot identity tint Color(1.00, 0.38, 0.86), # P2 magenta # content-colour: pilot identity tint Color(1.00, 0.72, 0.25), # P3 amber # content-colour: pilot identity tint Color(0.45, 1.00, 0.50), # P4 green # content-colour: pilot identity tint]
static func accent(k: int) -> Color: return ACCENTS[clampi(k, 0, ACCENTS.size() - 1)]
static func tag(k: int) -> String: return "P%d" % (k + 1)Run: godot --headless --path . --import > /dev/null 2>&1; git status --short (restore if listed).
- Step 4:
PlayerRenderer.set_pilot_accent
In render/player_renderer.gd, under var _tier := -1:
# Co-op identity tint (PilotPalette). Alpha 0 = none: the tier hue drives halo/thruster/glow as in# single-player. Only co-pilots (P2-P4) ever set it; P1 keeps the tier progression.var pilot_accent: Color = Color.TRANSPARENT
func set_pilot_accent(c: Color) -> void: if c == pilot_accent: return pilot_accent = c if _tier >= 0: _rebuild(_tier) # re-tint now; no promotion flash (that only fires on a tier rise)In _rebuild, replace var accent: Color = TIER_ACCENT[tier] with:
var accent: Color = TIER_ACCENT[tier] if pilot_accent.a <= 0.0 else pilot_accent- Step 5:
web_player_posover all pilots
In render/zone_renderer.gd, replace the collection loop:
for i in mini(sim.pilots.size(), 2): if sim.pilots[i].hp > 0.0: live.append(sim.pilots[i].pos)with:
for p in sim.pilots: if p.hp > 0.0: live.append(p.pos) if live.size() == 2: break # the shader carries two ripple sources; first two LIVE pilots in join orderUpdate the comment above it to “the first LIVE pilot … the second live pilot (any of P2-P4) …”.
- Step 6: Tags and accent in
main.gd
In _spawn_pilot_view(k), after var r := PlayerRenderer.new() and before n.add_child(r), add:
r.set_pilot_accent(PilotPalette.accent(k))At the end of _ensure_pilot_views(), add:
if sim.pilots.size() > 1: for k in _pilot_views.size(): if _pilot_views[k].tag == null: _pilot_views[k].tag = _make_world_tag(k)Add:
# The "P1".."P4" label that floats above each ship in co-op (never in solo). World-space, so it# shrinks with the co-op zoom like the ships do. Not parented to the ship node, which rotates.func _make_world_tag(k: int) -> Label: var t := Label.new() t.text = PilotPalette.tag(k) t.size = PilotView.TAG_SIZE t.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER t.add_theme_font_override("font", NeonTheme.title_font()) TokenBind.theme_font_size(t, &"font_size", &"font.sm") t.add_theme_color_override("font_color", PilotPalette.accent(k)) TokenBind.theme_color(t, &"font_outline_color", &"surface.scrim") TokenBind.theme_constant(t, &"outline_size", &"outline.md") t.mouse_filter = Control.MOUSE_FILTER_IGNORE t.z_index = 5 add_child(t) return tIn the render block, after the co-pilot loop and before the if sim.pilots.size() > 1: camera block, add:
for k in mini(_pilot_views.size(), sim.pilots.size()): var tv: PilotView = _pilot_views[k] if tv.tag != null: tv.tag.position = sim.pilots[k].pos + PilotView.TAG_OFFSET tv.tag.modulate.a = 1.0 if sim.pilots[k].hp > 0.0 else 0.3- Step 7: Run the tests and the literal ratchet
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_pilot_identity.gd -gexit 2>&1 | tail -30
Expected: PASS.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_web_look_switch.gd -gexit 2>&1 | tail -30
Expected: PASS. The existing test_venom_look_player_pos_coop_skips_downed_pilots is unchanged.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ui_literal_ratchet.gd -gexit 2>&1 | tail -30
Expected: PASS. main.gd gains no colour literal or numeric font size, since the tag reads PilotPalette and tokens.
- Step 8: Full suite and commit
Run: scripts/check-test-count.sh
Expected: exit 0.
cat > "$TMPDIR/bh-msg" <<'EOF'feat(coop): pilot colours and P1-P4 tags; web ripples for any live pilot
PilotPalette cyan/magenta/amber/green; co-pilot ships wear their accent, P1 keepsthe tier hue; world tags only in co-op. ZoneRenderer.web_player_pos takes thefirst two live pilots out of all pilots (was P1/P2 only).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01BfcSqKg4b4daxk12fDv5KDEOFgit add render/pilot_palette.gd* render/player_renderer.gd render/zone_renderer.gd main.gd tests/test_pilot_identity.gd tests/test_web_look_switch.gdgit commit --file="$TMPDIR/bh-msg"Task 7: HUD co-pilot HP slots and tags, fit-tested at iPad / iPhone / TV
Section titled “Task 7: HUD co-pilot HP slots and tags, fit-tested at iPad / iPhone / TV”Files:
- Modify:
ui/hud.gd:- consts L10-16;
- the P2 fields L53-66 and L225;
- the P2 build in
_readyL116-152; - the P2 update block L284-306;
- the P2 seams L350-360;
_refresh_hp_fillsL410-415.
- Create:
tests/test_coop_hud_fit.gd
Interfaces:
- Consumes (Task 6):
PilotPalette.accent(k),PilotPalette.tag(k). - Produces:
const Hud.COPILOT_GAP: float = 34.0const Hud.PILOT_TAG_W: float = 26.0const Hud.PILOT_TAG_H: float = 18.0- inner
class Hud.CoPilotBar extends RefCounted, with fieldsgroup,backing,fill,fill_sb,label,tag,bar_w,last_hp,flash_t,col_frac,label_key,layout_n,tinted static func Hud.copilot_slot_rect(slot: int, copilots: int) -> Rect2, relative to the centre-top anchorfunc Hud.copilot_bar(slot: int) -> CoPilotBarvar Hud._p1_tag: Label- The
hp_group2_visible()/hp_fill_width2()/hp_fill_color2()/hp_group2_alpha()seams keep working and read slot 0.
Layout: every co-pilot bar shares ONE row at HP2_GROUP_Y (42). A second row would cross BOSS_BAR_Y (88): rows at 42/62/82 end at 98. The 460-wide span splits into equal slots with a COPILOT_GAP between them. Each slot’s P2..P4 tag sits in the gap to its left, and P1’s tag sits left of P1’s bar. With one co-pilot, the slot is exactly today’s 460-wide P2 bar (fill BAR_W).
- Step 1: Write the failing fit test
tests/test_coop_hud_fit.gd:
extends GutTest
# Co-op HUD fit (plan 2026-10-05-local-coop-4-pilots.md, Task 7): with 4 pilots every HP bar and# P-tag stays on screen, clear of each other and of the boss bar, at the REAL device profiles# (iPad 985 / iPhone 1204 / TV 1280 logical units), not just 1280.
const DEVICES: Array[String] = ["ipad", "iphone", "tv"]var _saved_platform := ""
func before_each() -> void: _saved_platform = LiveTokens.platform()
func after_each() -> void: LiveTokens.set_platform(_saved_platform) TokenBind.flush_now()
func _hud_at(dev: String, n: int) -> Array: LiveTokens.set_platform("tv" if dev == "tv" else _saved_platform) TokenBind.flush_now() var pr := ShopLayoutFixture.profile(dev) var vp := SubViewport.new() vp.size = Vector2i((pr["vp"] as Vector2).round()) add_child_autofree(vp) var hud := Hud.new() vp.add_child(hud) await get_tree().process_frame var sim := Sim.new(1, SimContentFixture.db()) for k in range(1, n): sim.add_pilot(Sim.join_offset(k)) hud.update_hud(sim) await get_tree().process_frame return [hud, pr, sim]
func test_one_copilot_slot_is_the_old_p2_bar() -> void: assert_eq(Hud.copilot_slot_rect(0, 1), Rect2(-Hud.HP_BACKING_W * 0.5, Hud.HP2_GROUP_Y, Hud.HP_BACKING_W, Hud.HP_BACKING_H), "2-pilot layout unchanged")
func test_four_pilot_hp_bars_fit_every_device() -> void: for dev in DEVICES: var r: Array = await _hud_at(dev, 4) var hud: Hud = r[0] var vp_rect := Rect2(Vector2.ZERO, r[1]["vp"]) var p1_bar: Rect2 = (hud._hp_group.get_child(0) as Control).get_global_rect() var rects: Array[Rect2] = [p1_bar] var tags: Array[Rect2] = [hud._p1_tag.get_global_rect()] for slot in 3: var b: Hud.CoPilotBar = hud.copilot_bar(slot) assert_true(b.group.visible, "%s slot %d shown" % [dev, slot]) var br := b.backing.get_global_rect() assert_true(vp_rect.encloses(br), "%s slot %d inside the viewport" % [dev, slot]) assert_gte(br.position.y, p1_bar.end.y, "%s slot %d below P1's bar" % [dev, slot]) assert_lte(br.end.y, Hud.BOSS_BAR_Y, "%s slot %d above the boss bar" % [dev, slot]) rects.append(br) tags.append(b.tag.get_global_rect()) for t in tags: assert_true(vp_rect.encloses(t), "%s tag inside the viewport" % dev) for bar in rects: assert_false(t.intersects(bar), "%s tag %s clear of bar %s" % [dev, str(t), str(bar)]) for i in range(1, rects.size()): for j in range(i + 1, rects.size()): assert_false(rects[i].intersects(rects[j]), "%s co-pilot bars %d/%d do not overlap" % [dev, i, j])
func test_fill_spans_the_narrower_slot() -> void: var r: Array = await _hud_at("tv", 4) var hud: Hud = r[0] var w := Hud.copilot_slot_rect(0, 3).size.x assert_almost_eq(hud.copilot_bar(0).fill.size.x, w - 6.0, 0.5, "full HP fills the slot, not the old 454")
func test_each_slot_reads_its_own_pilot() -> void: var r: Array = await _hud_at("tv", 4) var hud: Hud = r[0] var sim: Sim = r[2] sim.pilots[3].hp = sim.pilots[3].max_hp * 0.2 hud.update_hud(sim) assert_lt(hud.copilot_bar(2).fill.size.x, hud.copilot_bar(0).fill.size.x * 0.5, "P4's bar reads P4")
func test_tags_are_tinted_only_in_coop() -> void: var solo: Array = await _hud_at("tv", 1) var h1: Hud = solo[0] assert_false(h1._p1_tag.visible, "no P1 tag solo") assert_false(h1._p1_tag.has_theme_color_override("font_color"), "solo stays token-clean") var coop: Array = await _hud_at("tv", 3) var h3: Hud = coop[0] assert_true(h3._p1_tag.visible) assert_eq(h3._p1_tag.get_theme_color("font_color"), PilotPalette.accent(0)) assert_eq(h3.copilot_bar(1).tag.text, "P3") assert_eq(h3.copilot_bar(1).tag.get_theme_color("font_color"), PilotPalette.accent(2)) assert_false(h3.copilot_bar(2).group.visible, "no P4 slot with 3 pilots")- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop_hud_fit.gd -gexit 2>&1 | tail -30
Expected: FAIL (copilot_slot_rect not found).
- Step 3: Constants and the inner class
In ui/hud.gd, keep HP2_GROUP_Y and add after it:
# Local co-op, up to 3 co-pilot bars (2026-10-05): one row at HP2_GROUP_Y (a 2nd row would cross# BOSS_BAR_Y), the 460 span split into equal slots. Each slot's P-tag sits in the gap to its left;# P1's tag sits left of P1's bar. One co-pilot = exactly the old full-width P2 bar.const COPILOT_GAP: float = 34.0 # PILOT_TAG_W + 8: room for the next slot's tagconst PILOT_TAG_W: float = 26.0const PILOT_TAG_H: float = 18.0
class CoPilotBar extends RefCounted: var group: Control var backing: Panel var fill: Panel var fill_sb: StyleBoxFlat var label: Label var tag: Label var bar_w: float = 0.0 var last_hp: float = -1.0 var flash_t: float = 9999.0 var col_frac: float = 1.0 var label_key := Vector2i(-1, -1) var layout_n: int = -1 var tinted: bool = falseDelete the P2 fields _hp_group2, _hp_fill2, _hp_fill_sb2, _hp_label2, _last_hp2, _hp_flash_t2, _hp_col_frac2 and _hp_label_key2, and add:
var _copilot_bars: Array[CoPilotBar] = [] # slot s = pilot s + 1var _p1_tag: Labelvar _p1_tag_tinted := false- Step 4: Build the slots
Replace the whole P2 build in _ready (from the “Hidden by default (single-player, or before P2 has joined)” comment through bar_backing2.add_child(_hp_label2)) with:
# Co-pilot HP bars (hidden until a co-pilot exists), built with the exact same style helpers as # P1's so they read as one HUD language. Tags get their pilot colour lazily, on first co-op # frame, so a solo HUD carries no non-token colour override. for i in Sim.MAX_LOCAL_PILOTS - 1: _copilot_bars.append(_make_copilot_bar()) _p1_tag = _make_pilot_tag() _p1_tag.visible = false _hp_group.add_child(_p1_tag)Add these private helpers:
func _make_copilot_bar() -> CoPilotBar: var b := CoPilotBar.new() b.group = Control.new() b.group.set_anchors_preset(Control.PRESET_CENTER_TOP) b.group.mouse_filter = Control.MOUSE_FILTER_IGNORE b.group.visible = false add_child(b.group) b.backing = Panel.new() b.backing.position = Vector2.ZERO b.backing.size = Vector2(HP_BACKING_W, HP_BACKING_H) TokenBind.stylebox(b.backing, &"panel", _hp_track_style) b.backing.mouse_filter = Control.MOUSE_FILTER_IGNORE b.group.add_child(b.backing) b.fill = Panel.new() b.fill.position = Vector2(3, 3) b.fill.size = Vector2(BAR_W, BAR_H) b.fill_sb = _bar_fill_style(Color.TRANSPARENT, 2) b.fill.add_theme_stylebox_override("panel", b.fill_sb) b.fill.mouse_filter = Control.MOUSE_FILTER_IGNORE b.backing.add_child(b.fill) b.label = Label.new() b.label.size = Vector2(HP_BACKING_W, HP_BACKING_H) b.label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER b.label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER b.label.add_theme_font_override("font", NeonTheme.mono_font()) TokenBind.theme_font_size(b.label, &"font_size", &"font.xs") TokenBind.theme_color(b.label, &"font_color", &"text.body") b.label.mouse_filter = Control.MOUSE_FILTER_IGNORE b.label.visible = false # matches P1's hidden HP number (2026-07-16, Chris) b.backing.add_child(b.label) b.tag = _make_pilot_tag() b.group.add_child(b.tag) return b
# "P1".."P4", right-aligned just left of its bar (in the slot gap). Colour is set on first co-op use.func _make_pilot_tag() -> Label: var t := Label.new() t.name = "PilotTag" t.position = Vector2(-PILOT_TAG_W - 4.0, -1.0) t.size = Vector2(PILOT_TAG_W, PILOT_TAG_H) t.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT t.vertical_alignment = VERTICAL_ALIGNMENT_CENTER t.add_theme_font_override("font", NeonTheme.title_font()) TokenBind.theme_font_size(t, &"font_size", &"font.xs") t.mouse_filter = Control.MOUSE_FILTER_IGNORE return t
# Slot geometry relative to the centre-top anchor. One co-pilot: the old 460-wide P2 bar.static func copilot_slot_rect(slot: int, copilots: int) -> Rect2: var n := maxi(copilots, 1) var w := (HP_BACKING_W - COPILOT_GAP * float(n - 1)) / float(n) return Rect2(-HP_BACKING_W * 0.5 + float(slot) * (w + COPILOT_GAP), HP2_GROUP_Y, w, HP_BACKING_H)
func _layout_copilot_bar(b: CoPilotBar, slot: int, copilots: int) -> void: var r := copilot_slot_rect(slot, copilots) b.group.position = r.position b.backing.size = r.size b.label.size = r.size b.bar_w = r.size.x - 6.0 b.layout_n = copilots- Step 5: Update the slots each frame
Replace the whole if sim.player2 != null: ... else: _hp_group2.visible = false block in update_hud with:
_update_copilot_bars(sim, delta)Add:
# Co-pilot HP bars: each mirrors P1's bar logic 1:1 against its own pilot (no poison-debt branch;# that debt is tracked once on Sim, not per pilot).func _update_copilot_bars(sim: Sim, delta: float) -> void: var n_co := sim.pilots.size() - 1 _p1_tag.visible = n_co > 0 if n_co > 0 and not _p1_tag_tinted: _p1_tag.text = PilotPalette.tag(0) _p1_tag.add_theme_color_override("font_color", PilotPalette.accent(0)) _p1_tag_tinted = true for slot in _copilot_bars.size(): var b: CoPilotBar = _copilot_bars[slot] if slot >= n_co: b.group.visible = false continue var p: PlayerState = sim.pilots[slot + 1] if b.layout_n != n_co: _layout_copilot_bar(b, slot, n_co) if not b.tinted: b.tag.text = PilotPalette.tag(slot + 1) b.tag.add_theme_color_override("font_color", PilotPalette.accent(slot + 1)) b.tinted = true b.group.visible = true var frac: float = clampf(p.hp / maxf(p.max_hp, 1.0), 0.0, 1.0) b.fill.size.x = maxf(b.bar_w * frac, 8.0 if frac > 0.0 else 0.0) b.col_frac = frac _apply_hp_fill(b.fill_sb, frac) b.fill.modulate.a = (0.65 + 0.35 * sin(sim.run_time * 9.0)) if frac < 0.3 else 1.0 if b.last_hp >= 0.0 and p.hp < b.last_hp - 0.001: b.flash_t = 0.0 else: b.flash_t += delta b.last_hp = p.hp var above_half: bool = p.hp / maxf(p.max_hp, 1.0) > HP_LOW_HP_FRAC + 0.001 b.group.modulate.a = 1.0 if not above_half else _hp_flash_alpha(b.flash_t) if b.label.visible: var hk := Vector2i(int(p.hp), int(p.max_hp)) if hk != b.label_key: b.label_key = hk b.label.text = "HP %d / %d" % [hk.x, hk.y]Replace the four P2 seams with:
func copilot_bar(slot: int) -> CoPilotBar: return _copilot_bars[slot]
func hp_group2_visible() -> bool: return _copilot_bars[0].group.visible
func hp_fill_width2() -> float: return _copilot_bars[0].fill.size.x
func hp_fill_color2() -> Color: return _copilot_bars[0].fill_sb.bg_color
func hp_group2_alpha() -> float: return _copilot_bars[0].group.modulate.aIn _refresh_hp_fills, replace _apply_hp_fill(_hp_fill_sb2, _hp_col_frac2) with:
for b in _copilot_bars: _apply_hp_fill(b.fill_sb, b.col_frac)Check for leftovers:
Run: grep -n "_hp_group2\|_hp_fill2\|_hp_fill_sb2\|_hp_label2\|_last_hp2\|_hp_flash_t2\|_hp_col_frac2\|_hp_label_key2" ui/hud.gd
Expected: no output.
- Step 6: Run the tests
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop_hud_fit.gd -gexit 2>&1 | tail -30
Expected: PASS.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_hud.gd -gexit 2>&1 | tail -30
Expected: PASS. All the hp_*2 P2 tests and the solo RebindProbe test pass unchanged.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ui_literal_ratchet.gd -gexit 2>&1 | tail -30
Expected: PASS. No new colour literal or numeric font size: Color.TRANSPARENT moved, it was not added.
- Step 7: Full suite and commit
Run: scripts/check-test-count.sh
Expected: exit 0.
cat > "$TMPDIR/bh-msg" <<'EOF'feat(coop): HUD shows an HP slot and P-tag for every co-pilot
Up to three co-pilot bars share one row under P1, tagged P2-P4 in pilot colour;one co-pilot keeps the old full-width bar. Fit-tested at iPad, iPhone and TV.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01BfcSqKg4b4daxk12fDv5KDEOFgit add ui/hud.gd tests/test_coop_hud_fit.gdgit commit --file="$TMPDIR/bh-msg"Task 8: Weapon dock rows for every co-pilot, below the drone dock
Section titled “Task 8: Weapon dock rows for every co-pilot, below the drone dock”Files:
- Modify:
ui/weapon_panel.gd:P2_ROW_Yconst L15-16;- the P2 fields L32-49;
- the P2 build in
_readyL101-135; _build2L163-185;- the P2 block in
update_panelL240-263.
- Modify:
tests/test_weapon_panel.gd(the P2 tests L147-202 and_unbound_uiL241-244) - Create:
tests/test_coop_weapon_panel_fit.gd
Interfaces:
- Consumes (Task 6):
PilotPalette.accent(k),PilotPalette.tag(k). - Produces:
const WeaponPanel.COPILOT_ROW_Y: float = 144.0const WeaponPanel.COPILOT_ROW_PITCH: float = 64.0- inner
class WeaponPanel.CoPilotRow extends RefCounted, with fieldsroot,tiles,built,aim_root,aim_icon,aim_stat,tag,ids_shown,aim_key func WeaponPanel.copilot_row(slot: int) -> CoPilotRow(null until first shown)func WeaponPanel.visible_copilot_rows() -> intvar WeaponPanel._p1_tag: Label(null until co-op)
Layout: the dock root sits at DOCK_ORIGIN (24, 82) plus the safe insets. P1’s row and stat label take relative y 0-59. DroneDock sits at ABSOLUTE (24, 162-202). Co-pilot rows start at relative 144, so absolute 226 / 290 / 354 plus the safe top inset, each 64 apart (40 tile + 3 + 16 stat label + 5 gap). This also fixes the existing P2 row at absolute 162, which sat exactly on the drone dock.
- Step 1: Write the failing tests
tests/test_coop_weapon_panel_fit.gd:
extends GutTest
# Co-op weapon dock (plan 2026-10-05-local-coop-4-pilots.md, Task 8): a row per co-pilot, all# below the P1-only drone dock (the old P2 row sat exactly on it), inside the viewport and the# notch insets at the real device profiles.
const DEVICES: Array[String] = ["ipad", "iphone", "tv"]var _saved_platform := ""var _saved_insets := Vector4(-1, -1, -1, -1)var _saved_scale := 0.0var _saved_nova := true
func before_each() -> void: _saved_platform = LiveTokens.platform() _saved_insets = Platform.force_safe_insets_pt _saved_scale = Platform.force_screen_scale _saved_nova = VisualLook.nova
func after_each() -> void: LiveTokens.set_platform(_saved_platform) TokenBind.flush_now() Platform.force_safe_insets_pt = _saved_insets Platform.force_screen_scale = _saved_scale VisualLook.nova = _saved_nova
func _panel_at(dev: String, n: int) -> Array: LiveTokens.set_platform("tv" if dev == "tv" else _saved_platform) TokenBind.flush_now() var pr := ShopLayoutFixture.profile(dev) Platform.force_safe_insets_pt = ShopLayoutFixture.DEVICES[dev]["insets_pt"] Platform.force_screen_scale = pr["upt"] var vp := SubViewport.new() vp.size = Vector2i((pr["vp"] as Vector2).round()) add_child_autofree(vp) var p := WeaponPanel.new() vp.add_child(p) await get_tree().process_frame var sim := Sim.new(1, SimContentFixture.db()) for k in range(1, n): sim.add_pilot(Sim.join_offset(k)) p.update_panel(sim) await get_tree().process_frame return [p, pr, sim]
func _row_rects(row: WeaponPanel.CoPilotRow) -> Array[Rect2]: var out: Array[Rect2] = [] for t in row.tiles: out.append((t["backing"] as Control).get_global_rect()) out.append((row.aim_root.get_child(0) as Control).get_global_rect()) # the Aim tile backing out.append(row.aim_stat.get_global_rect()) out.append(row.tag.get_global_rect()) return out
func test_four_pilot_rows_fit_and_clear_the_drone_dock() -> void: for nova: bool in [false, true]: VisualLook.nova = nova for dev in DEVICES: var r: Array = await _panel_at(dev, 4) var p: WeaponPanel = r[0] var vp_rect := Rect2(Vector2.ZERO, r[1]["vp"]) var dock := Rect2(DroneDock.DOCK_ORIGIN, Vector2(vp_rect.size.x, DroneDock.TILE_C)) assert_eq(p.visible_copilot_rows(), 3, "%s: three co-pilot rows" % dev) var bottoms: Array[float] = [] for slot in 3: var row := p.copilot_row(slot) var top := INF var bottom := -INF for rc in _row_rects(row): assert_true(vp_rect.encloses(rc), "%s nova=%s row %d rect %s inside viewport" % [dev, nova, slot, str(rc)]) assert_false(rc.intersects(dock), "%s row %d clear of the drone dock" % [dev, slot]) top = minf(top, rc.position.y) bottom = maxf(bottom, rc.end.y) if slot > 0: assert_gte(top, bottoms[slot - 1], "%s row %d below row %d" % [dev, slot, slot - 1]) bottoms.append(bottom)
func test_rows_read_their_own_pilot() -> void: var r: Array = await _panel_at("tv", 4) var p: WeaponPanel = r[0] var sim: Sim = r[2] sim.pilots[3].arsenal.aim.pierce = 9 p.update_panel(sim) assert_true(p.copilot_row(2).aim_stat.text.contains("prc 9"), "P4's row reads P4's arsenal") assert_false(p.copilot_row(0).aim_stat.text.contains("prc 9"), "P2's row does not")
func test_tags_name_and_colour_each_row() -> void: var r: Array = await _panel_at("tv", 3) var p: WeaponPanel = r[0] assert_eq(p.copilot_row(1).tag.text, "P3") assert_eq(p.copilot_row(1).tag.get_theme_color("font_color"), PilotPalette.accent(2)) assert_true(p._p1_tag.visible, "P1 row tagged in co-op") assert_eq(p.visible_copilot_rows(), 2)
func test_solo_builds_no_copilot_row_and_no_tag() -> void: var r: Array = await _panel_at("tv", 1) var p: WeaponPanel = r[0] assert_eq(p.visible_copilot_rows(), 0) assert_null(p.copilot_row(0), "rows are built lazily, so a solo dock stays token-clean") assert_true(p._p1_tag == null or not p._p1_tag.visible)In tests/test_weapon_panel.gd, make these mechanical replacements in the P2 tests:
| Old | New |
|---|---|
assert_false(p._p2_root.visible, "no P2 dock when there's no P2") |
assert_eq(p.visible_copilot_rows(), 0, "no P2 dock when there's no P2") |
p._p2_root.visible (in the “P2 joins” test) |
p.copilot_row(0).root.visible |
p._tiles2 |
p.copilot_row(0).tiles |
p._aim_root2 |
p.copilot_row(0).aim_root |
p._aim_stat2 |
p.copilot_row(0).aim_stat |
Replace _unbound_ui with:
# Live tokens: the slot backings (both looks) and the hint label are bound. The Aim stat labels are# tinted with the Aim weapon's own hue (WeaponIcon._COLOR, gameplay content) and the co-op P-tags# with the pilot's identity colour (PilotPalette), so those are the only exceptions.func _unbound_ui(p: WeaponPanel) -> Array: var aim := [str(p.get_path_to(p._aim_stat)) + ":theme_override_colors/font_color"] return Array(RebindProbe.unbound(p)).filter(func(k: String) -> bool: if k in aim: return false var leaf := k.get_slice(":", 0).get_file() return not (leaf.begins_with("AimStat") or leaf.begins_with("PilotTag")))- Step 2: Run to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop_weapon_panel_fit.gd -gexit 2>&1 | tail -30
Expected: FAIL (copilot_row not found).
- Step 3: Implement in
ui/weapon_panel.gd
Replace the P2_ROW_Y const and its comment with:
# Local co-op (2026-10-05): one row per co-pilot, BELOW the P1-only DroneDock (absolute 162-202).# Relative to the dock root (DOCK_ORIGIN.y 82 + safe top): rows at 144 / 208 / 272 = absolute# 226 / 290 / 354. The old P2 row at relative 80 (absolute 162) sat exactly on the drone dock.const COPILOT_ROW_Y: float = 144.0const COPILOT_ROW_PITCH: float = TILE_C + 24.0 # tile + 3 + 16 stat label + 5 gap = 64
class CoPilotRow extends RefCounted: var root: Control var tiles: Array[Dictionary] = [] # [{root, backing, icon}] var built: int = -1 var aim_root: Control var aim_icon: WeaponIcon var aim_stat: Label var tag: Label var ids_shown: Array[String] = [] var aim_key := Vector3(-1, -1, -1)Delete the P2 fields _p2_root, _tiles2, _built2, _aim_root2, _aim_icon2, _aim_stat2, _ids_shown2 and _aim_key2, and add:
# Co-pilot rows, slot s = pilot s + 1. Built lazily on first need so a solo dock carries no# pilot-colour override (the RebindProbe solo test stays clean).var _rows: Array[CoPilotRow] = []var _p1_tag: Label = nullDelete the whole P2 build from _ready (from the “P2’s own dock (co-op)” comment through _aim_root2.add_child(_aim_stat2)). Replace _build2 with:
func _make_row(slot: int) -> CoPilotRow: var row := CoPilotRow.new() row.root = Control.new() row.root.set_anchors_preset(Control.PRESET_TOP_LEFT) row.root.position = Vector2(0, COPILOT_ROW_Y + COPILOT_ROW_PITCH * float(slot)) row.root.mouse_filter = Control.MOUSE_FILTER_IGNORE _root.add_child(row.root)
row.aim_root = Control.new() row.aim_root.mouse_filter = Control.MOUSE_FILTER_IGNORE row.root.add_child(row.aim_root) var aim_backing := Panel.new() aim_backing.mouse_filter = Control.MOUSE_FILTER_IGNORE aim_backing.size = Vector2(TILE_C, TILE_C) TokenBind.stylebox(aim_backing, &"panel", slot_style) row.aim_root.add_child(aim_backing) row.aim_icon = WeaponIcon.new() row.aim_icon.position = Vector2(TILE_C / 2.0, TILE_C / 2.0) row.aim_icon.kind = "aim" row.aim_root.add_child(row.aim_icon) row.aim_stat = Label.new() row.aim_stat.name = "AimStat" row.aim_stat.set_anchors_preset(Control.PRESET_TOP_LEFT) row.aim_stat.position = Vector2(0, TILE_C + 3) row.aim_stat.size = Vector2(170, 16) row.aim_stat.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT row.aim_stat.add_theme_font_override("font", NeonTheme.title_font()) TokenBind.theme_font_size(row.aim_stat, &"font_size", &"font.2xs") var aim_col: Color = WeaponIcon._COLOR["aim"] # content-colour: the Aim weapon's own hue row.aim_stat.add_theme_color_override("font_color", Color(aim_col.r, aim_col.g, aim_col.b, 0.7)) # content-colour: Aim weapon hue row.aim_stat.mouse_filter = Control.MOUSE_FILTER_IGNORE row.aim_root.add_child(row.aim_stat)
row.tag = _make_tag(slot + 1) row.root.add_child(row.tag) return row
# "P1".."P4" under the row's first tile, where P1's (hidden) WEAPONS hint would sit.func _make_tag(k: int) -> Label: var t := Label.new() t.name = "PilotTag" t.set_anchors_preset(Control.PRESET_TOP_LEFT) t.position = Vector2(0, TILE_C + 3) t.size = Vector2(TILE_C, 16) t.add_theme_font_override("font", NeonTheme.title_font()) TokenBind.theme_font_size(t, &"font_size", &"font.2xs") t.text = PilotPalette.tag(k) t.add_theme_color_override("font_color", PilotPalette.accent(k)) t.mouse_filter = Control.MOUSE_FILTER_IGNORE return t
func _build_row_tiles(row: CoPilotRow, n: int) -> void: for t in row.tiles: t["root"].queue_free() row.tiles.clear() for i in range(n): var tile := Control.new() tile.mouse_filter = Control.MOUSE_FILTER_IGNORE tile.position = Vector2(i * (TILE_C + GAP_C), 0) row.root.add_child(tile) var backing := Panel.new() backing.mouse_filter = Control.MOUSE_FILTER_IGNORE backing.size = Vector2(TILE_C, TILE_C) TokenBind.stylebox(backing, &"panel", slot_style) tile.add_child(backing) var icon := WeaponIcon.new() icon.position = Vector2(TILE_C / 2.0, TILE_C / 2.0) tile.add_child(icon) row.tiles.append({"root": tile, "backing": backing, "icon": icon}) row.built = n
func copilot_row(slot: int) -> CoPilotRow: return _rows[slot] if slot < _rows.size() else null
func visible_copilot_rows() -> int: var n := 0 for row in _rows: if row.root.visible: n += 1 return nThe tag’s text and colour are set at build time. That is still lazy, because rows are only ever built in co-op.
Replace the P2 block at the end of update_panel (from the “P2’s own dock (co-op only)” comment to the end of the function) with:
# Co-pilot rows (co-op only) — each mirrors the P1 block above 1:1 against its own pilot. # sim.max_weapon_slots is shared across arsenals (same cap upgrade_system uses for all pilots). var n_co := sim.pilots.size() - 1 if n_co > 0 and _p1_tag == null: _p1_tag = _make_tag(0) _root.add_child(_p1_tag) if _p1_tag != null: _p1_tag.visible = n_co > 0 while _rows.size() < n_co: _rows.append(_make_row(_rows.size())) for slot in _rows.size(): var row: CoPilotRow = _rows[slot] row.root.visible = slot < n_co if slot >= n_co: continue var pilot: PlayerState = sim.pilots[slot + 1] if row.built != sim.max_weapon_slots: _build_row_tiles(row, sim.max_weapon_slots) row.ids_shown.clear() row.ids_shown.append("<rebuilt>") if pilot.arsenal.active_weapon_ids != row.ids_shown: row.ids_shown = pilot.arsenal.active_weapon_ids.duplicate() _refresh_glyphs(sim, pilot, row.tiles) if sim.tutorial_locks(TutorialLessons.F_AIM): row.aim_root.visible = false else: row.aim_root.visible = true row.aim_root.position = Vector2(row.tiles.size() * (TILE_C + GAP_C) + AIM_GAP, 0) var aim := pilot.arsenal.aim var key := Vector3(aim.damage, aim.pierce, aim.burst_radius) if key != row.aim_key: row.aim_key = key row.aim_stat.text = "AIM dmg %.1f prc %d aoe %.0f" % [aim.damage, aim.pierce, aim.burst_radius]Check for leftovers:
Run: git grep -n "P2_ROW_Y\|_p2_root\|_tiles2\|_built2\|_aim_root2\|_aim_icon2\|_aim_stat2\|_ids_shown2\|_aim_key2\|_build2" -- '*.gd'
Expected: no output.
- Step 4: Run the tests
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop_weapon_panel_fit.gd -gexit 2>&1 | tail -30
Expected: PASS.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_weapon_panel.gd -gexit 2>&1 | tail -30
Expected: PASS. This includes test_dock_chrome_follows_live_tokens in both looks.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ui_literal_ratchet.gd -gexit 2>&1 | tail -30
Expected: PASS. The Aim colour literal moved from _ready to _make_row with its content-colour escape, so the count is unchanged.
- Step 5: Full suite and commit
Run: scripts/check-test-count.sh
Expected: exit 0.
cat > "$TMPDIR/bh-msg" <<'EOF'feat(coop): a weapon row per co-pilot, below the drone dock
CoPilotRow per co-pilot with a pilot-coloured P-tag, built lazily; rows start atabsolute y 226, fixing the old P2 row that sat on the drone dock. Fit-tested atiPad, iPhone (notch insets) and TV in both looks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01BfcSqKg4b4daxk12fDv5KDEOFgit add ui/weapon_panel.gd tests/test_weapon_panel.gd tests/test_coop_weapon_panel_fit.gdgit commit --file="$TMPDIR/bh-msg"Task 9: Level-up panel names all four pilots, and the 4-pilot chain
Section titled “Task 9: Level-up panel names all four pilots, and the 4-pilot chain”Files:
- Modify:
ui/level_up_panel.gd: the field block near L83, the_headerbuild in_readyL154-165, andshow_forL231-271. - Modify:
ui/level_up_panel_legacy.gd:show_forL98 and_build_leftL153-158. - Modify:
tests/test_level_up_panel.gd:_unbound_chromeL1086, plus new tests after the device-profile block. - Modify:
tests/test_level_up_panel_legacy.gd: a new test after L90. - Modify:
tests/test_main.gd: a new test aftertest_coop_simultaneous_levelup_chains_p1_panel_then_p2_panel(L827).
Interfaces:
- Consumes (Task 6):
PilotPalette.accent(k),PilotPalette.tag(k). Consumes (Task 1):Sim.join_offset(k),Sim.add_pilot. - Produces:
var LevelUpPanel._pilot_tag: Label, named “PilotTag”, a child of_headerconst LevelUpPanel.PILOT_TAG_W: float = 56.0var LegacyLevelUpPanel._pilot_idx: int
The header colour roles stay as they are. P1 uses gold.main and every other pilot uses accent.soft, both pinned by the existing dE tests. Pilot identity is carried by a separate pilot-coloured “P1”..“P4” chip left of the header, which already reads “PLAYER N: LEVEL L” for any N.
- Step 1: Write the failing tests
Append to tests/test_level_up_panel.gd, after the device-profile tests:
# ── Co-op pilot tag (plan 2026-10-05-local-coop-4-pilots.md, Task 9) ─────────
func _open_coop_at(dev: String, n: int, idx: int) -> Array: LiveTokens.set_platform("tv" if dev == "tv" else _saved_platform) TokenBind.flush_now() var pr := ShopLayoutFixture.profile(dev) var vp := SubViewport.new() vp.size = Vector2i((pr["vp"] as Vector2).round()) add_child_autofree(vp) var sim := Sim.new(1, SimContentFixture.db()) sim.enable_survival() for k in range(1, n): sim.add_pilot(Sim.join_offset(k)) var panel := LevelUpPanel.new() vp.add_child(panel) await get_tree().process_frame panel.show_for(sim, sim.pilots[idx], ["armor", "speed", "magnet"], 7, idx) for f in 6: await get_tree().process_frame return [sim, panel, pr]
func test_coop_p4_header_and_tag_fit_every_device() -> void: for dev in DEVICES: var r: Array = await _open_coop_at(dev, 4, 3) var panel: LevelUpPanel = r[1] var vp_rect := Rect2(Vector2.ZERO, r[2]["vp"]) assert_true(panel._header.text.begins_with("PLAYER 4:"), "%s header names P4" % dev) assert_true(panel._pilot_tag.visible, "%s tag shown in co-op" % dev) assert_eq(panel._pilot_tag.text, "P4") assert_eq(panel._pilot_tag.get_theme_color("font_color"), PilotPalette.accent(3), "P4 in P4's colour") var tag := panel._pilot_tag.get_global_rect() var head := panel._header.get_global_rect() assert_true(vp_rect.encloses(tag), "%s tag %s inside the viewport" % [dev, str(tag)]) assert_true(vp_rect.encloses(head), "%s header inside the viewport" % dev) for i in panel._cards.size(): assert_false(tag.intersects((panel._cards[i] as Control).get_global_rect()), "%s tag clear of card %d" % [dev, i])
func test_solo_header_has_no_pilot_tag() -> void: var r: Array = await _open(["armor"]) var panel: LevelUpPanel = r[1] assert_false(panel._pilot_tag.visible) assert_false(panel._pilot_tag.has_theme_color_override("font_color"), "solo stays token-clean")
func test_header_role_unchanged_for_p3() -> void: var r: Array = await _open_coop_at("tv", 3, 2) var panel: LevelUpPanel = r[1] assert_eq(panel._header_role, &"accent.soft", "non-P1 header keeps the pinned accent.soft role") assert_eq(panel._pilot_tag.text, "P3")In _unbound_chrome, insert this at the top of the filter lambda, before the ends_with(":modulate") check:
if k.get_slice(":", 0).get_file() == "PilotTag": return false # pilot identity colour (PilotPalette), co-op onlyAppend to tests/test_level_up_panel_legacy.gd:
func test_coop_p4_panel_says_player_4() -> void: var sim := Sim.new(1, SimContentFixture.db()); sim.enable_survival() for k in range(1, 4): sim.add_pilot(Sim.join_offset(k)) var panel := LegacyLevelUpPanel.new() add_child_autofree(panel) await get_tree().process_frame panel.show_for(sim, sim.pilots[3], ["armor"], 9, 3) await get_tree().process_frame var texts: Array[String] = [] for c in panel._left.get_children(): if c is Label: texts.append(String((c as Label).text)) assert_has(texts, "PLAYER 4 LEVEL UP", "not the old hardcoded PLAYER 2") panel.hide_panel()Append to tests/test_main.gd, after test_coop_simultaneous_levelup_chains_p1_panel_then_p2_panel:
func test_coop_four_pilot_levelup_chain_runs_in_join_order() -> void: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") for k in range(1, 4): m.sim.add_pilot(Sim.join_offset(k)) for p: PlayerState in m.sim.pilots: p.pending_levelups = 1 m._physics_process(1.0 / 60.0) assert_true(m._paused_for_levelup) for k in 4: assert_eq(m._leveling_pilot, m.sim.pilots[k], "panel %d belongs to P%d" % [k, k + 1]) m._on_upgrade_chosen(m._current_choice_ids[0]) assert_eq(m.sim.pilots[k].pending_levelups, 0, "P%d's pick spent only P%d's choice" % [k + 1, k + 1]) assert_null(m._leveling_pilot, "chain done after P4") for p: PlayerState in m.sim.pilots: assert_gt(p.iframe_timer, 0.0, "every pilot gets the resume i-frames")- Step 2: Run to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_level_up_panel.gd -gexit 2>&1 | tail -30
Expected: FAIL (_pilot_tag not found).
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_level_up_panel_legacy.gd -gexit 2>&1 | tail -30
Expected: FAIL (PLAYER 4 LEVEL UP missing).
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_main.gd -gexit 2>&1 | tail -30
Expected: either PASS, because Task 1 made _process_levelups and _next_leveling_pilot N-wide, or FAIL at the P3 or P4 handoff. If it passes, it is a regression pin, and you continue.
- Step 3: Implement in
ui/level_up_panel.gd
Next to var _header: Label, add:
# Co-op only: a "P1".."P4" chip in the pilot's identity colour, left of the header (the header's own# colour roles are pinned: gold.main P1 / accent.soft others). No colour override until a co-op open,# so a solo panel stays token-clean.const PILOT_TAG_W: float = 56.0var _pilot_tag: LabelIn _ready, directly after add_child(_header), add:
_pilot_tag = Label.new() _pilot_tag.name = "PilotTag" _pilot_tag.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT _pilot_tag.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _pilot_tag.add_theme_font_override("font", NeonTheme.title_font()) TokenBind.theme_font_size(_pilot_tag, &"font_size", &"font.2xl") TokenBind.theme_color(_pilot_tag, &"font_outline_color", func() -> Color: return Tokens.c(&"accent.main", 0.35)) TokenBind.theme_constant(_pilot_tag, &"outline_size", &"outline.md") _pilot_tag.mouse_filter = Control.MOUSE_FILTER_IGNORE _pilot_tag.visible = false _header.add_child(_pilot_tag)In show_for, directly after _header.visible = true, add:
_pilot_tag.visible = coop if coop: _pilot_tag.text = PilotPalette.tag(pilot_idx) _pilot_tag.add_theme_color_override("font_color", PilotPalette.accent(pilot_idx)) # Left of the header's own (text-sized, centred) rect, vertically matched to it. _pilot_tag.position = Vector2(-(PILOT_TAG_W + 16.0), 0.0) _pilot_tag.size = Vector2(PILOT_TAG_W, _header.size.y)Then update the stale comment on _header_role at L119:
var _header_role: StringName = &"text.body" # per open: solo text.body, co-op P1 gold / others accent.soft- Step 4: Implement in
ui/level_up_panel_legacy.gd
Next to the _gate_p1 field, add:
var _pilot_idx: int = 0In show_for, next to where _gate_p1 is set, add:
_pilot_idx = pilot_idxIn _build_left, replace:
pilot_lbl.text = "PLAYER 1 LEVEL UP" if _gate_p1 else "PLAYER 2 LEVEL UP"with:
pilot_lbl.text = "PLAYER %d LEVEL UP" % (_pilot_idx + 1)- Step 5: If the main chain test failed, fix the chain
main._next_leveling_pilot() must return the first pilot in sim.pilots order with pending_levelups > 0, and _on_upgrade_chosen must reopen for it while any remain. Make the scan in _next_leveling_pilot a plain loop:
func _next_leveling_pilot() -> PlayerState: for p: PlayerState in sim.pilots: if p.pending_levelups > 0: return p return nullThe resume i-frame grant must also loop for p in sim.pilots, not P1 + player2. Grep for leftovers:
Run: grep -n "player2" main.gd | grep -i "level\|iframe"
Expected: no output.
- Step 6: Run the tests
Run each of test_level_up_panel.gd, test_level_up_panel_legacy.gd, test_main.gd with -gselect.
Expected: all PASS, including the existing dE header-role tests and test_role_chips_wrap_inside_every_card_on_every_device.
- Step 7: Full suite and commit
Run: scripts/check-test-count.sh
Expected: exit 0.
cat > "$TMPDIR/bh-msg" <<'EOF'feat(coop): level-up panel tags P1-P4 and chains all four pilots
A pilot-coloured P-tag sits left of the header in co-op; the legacy panel saysPLAYER N, not PLAYER 2, for every co-pilot. Fit-tested at iPad, iPhone and TV.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01BfcSqKg4b4daxk12fDv5KDEOFgit add ui/level_up_panel.gd ui/level_up_panel_legacy.gd main.gd tests/test_level_up_panel.gd tests/test_level_up_panel_legacy.gd tests/test_main.gdgit commit --file="$TMPDIR/bh-msg"Task 10: Replays, soak and telemetry carry N pilots
Section titled “Task 10: Replays, soak and telemetry carry N pilots”Files:
- Modify:
input/replay_recorder.gd(recordL19-27) - Modify:
main.gd(the recorder call written in Task 2, around L1610) - Modify:
tools/soak/soak_run.gd(L82-85, L117) - Modify:
tools/soak/soak_runner.gd(the cfg dict at L81, plus the usage comment at L5) - Modify:
tests/test_replay.gd,tests/test_soak_run.gd - Create:
tests/test_gameplay_telemetry_coop.gd
Interfaces:
- Consumes (Task 1):
Sim.tick(inputs: Array[InputState]), which accepts null entries for pad-less pilots;Sim.join_offset(k). Consumes (Task 2):ReplayLoader.input_at(data, tick, k)returns null past a short row. - Produces:
func ReplayRecorder.record_inputs(inputs: Array) -> void- the soak cfg key
"pilots": int - the soak runner flag
--pilots=N(co-op only, default 2)
Replay format: a row is 6 numbers per pilot, and at least 2 pilots are always written (12 numbers), so solo and 2-pilot files are byte-identical to today’s. A 4-pilot row is 24 numbers. Old 12-number files load unchanged, because input_at returns null for k ≥ 2 (Task 2).
- Step 1: Write the failing tests
Append to tests/test_replay.gd:
func test_record_inputs_writes_every_pilot_and_pads_to_two() -> void: var rec := ReplayRecorder.new(7) var a := InputState.new(Vector2(1, 0), Vector2(0, 1), false, true) rec.record_inputs([a]) assert_eq((rec.ticks[0] as Array).size(), 12, "solo rows stay 12 wide (old format)") var four: Array = [a, null, InputState.new(Vector2(0, -1), Vector2.ZERO, true, false), a] rec.record_inputs(four) var row: Array = rec.ticks[1] assert_eq(row.size(), 24, "4 pilots -> 24 numbers") assert_eq(row.slice(6, 12), [0, 0, 0, 0, 0, 0], "a pad-less pilot records as zeros") assert_eq(float(row[13]), -1.0, "P3 move.y") assert_eq(int(row[16]), 1, "P3 decoy")
func test_four_pilot_replay_round_trips() -> void: var rec := ReplayRecorder.new(7) var p4 := InputState.new(Vector2(-1, 0), Vector2(0, 1), false, true) rec.record_inputs([InputState.new(), InputState.new(), InputState.new(), p4]) var data := {"seed": 7, "ticks": rec.ticks} var got := ReplayLoader.input_at(data, 0, 3) assert_eq(got.move_dir, p4.move_dir) assert_true(got.dash)
func test_record_wrapper_is_unchanged() -> void: var a := ReplayRecorder.new(7) var b := ReplayRecorder.new(7) var i1 := InputState.new(Vector2(1, 0), Vector2(0, 1), true, false) var i2 := InputState.new(Vector2(0, 1), Vector2(1, 0), false, true) a.record(i1, i2) b.record_inputs([i1, i2]) assert_eq(a.ticks, b.ticks) a.record(i1, null) b.record_inputs([i1]) assert_eq(a.ticks, b.ticks)Append to tests/test_soak_run.gd:
func test_coop_run_ticks_four_pilots() -> void: var report := SoakRun.new(_cfg({"coop": true, "pilots": 4})).execute() assert_eq(report.invariant_failures.size(), 0, str(report.invariant_failures)) assert_eq(report.pilots, 4)
func test_coop_default_is_still_two_pilots() -> void: var report := SoakRun.new(_cfg({"coop": true})).execute() assert_eq(report.pilots, 2, "existing --coop=coop soaks keep their meaning")
func test_four_pilot_soak_is_deterministic() -> void: var a := SoakRun.new(_cfg({"coop": true, "pilots": 4})).execute() var b := SoakRun.new(_cfg({"coop": true, "pilots": 4})).execute() assert_eq(a.final_checksum, b.final_checksum)Create tests/test_gameplay_telemetry_coop.gd:
extends GutTest
# Run-end telemetry with 4 pilots (plan 2026-10-05-local-coop-4-pilots.md, Task 10). D1 has one# `pilots` count and one `level2` column, so no schema change: pin that both stay correct at N=4.
func test_run_body_reports_four_pilots() -> void: var sim := Sim.new(1, SimContentFixture.db()) for k in range(1, 4): sim.add_pilot(Sim.join_offset(k)) sim.pilots[1].level = 5 sim.pilots[3].level = 9 var body := GameplayTelemetry.run_body(sim, "survival", "home", true, [], false, 0, "", "") assert_eq(int(body["pilots"]), 4) assert_eq(int(body["level2"]), 5, "level2 stays P2's level, not the last pilot's")
func test_run_body_solo_level2_is_null() -> void: var sim := Sim.new(1, SimContentFixture.db()) var body := GameplayTelemetry.run_body(sim, "survival", "home", true, [], false, 0, "", "") assert_eq(int(body["pilots"]), 1) assert_null(body["level2"])- Step 2: Run to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_replay.gd -gexit 2>&1 | tail -30
Expected: FAIL (record_inputs not found).
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_soak_run.gd -gexit 2>&1 | tail -30
Expected: FAIL (report.pilots is 2, expected 4).
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_gameplay_telemetry_coop.gd -gexit 2>&1 | tail -30
Expected: PASS. This is a pin, not a change. If it fails, run_body reads level2 from somewhere other than sim.player2. Fix it to sim.pilots[1].level if sim.pilots.size() > 1 else null.
- Step 3: Implement the recorder
Replace record in input/replay_recorder.gd with:
# One row per tick: 6 numbers per pilot (move.xy, aim.xy, decoy, dash), index-aligned with# sim.pilots. Always at least 2 pilots wide so solo / 2-pilot files are byte-identical to the# pre-2026-10-05 format; a null input (a pad-less co-pilot) records as zeros.func record_inputs(inputs: Array) -> void: var row := [] for k in maxi(inputs.size(), 2): var inp: InputState = inputs[k] if k < inputs.size() else null if inp != null: row.append_array([inp.move_dir.x, inp.move_dir.y, inp.aim_dir.x, inp.aim_dir.y, 1 if inp.decoy else 0, 1 if inp.dash else 0]) else: row.append_array([0, 0, 0, 0, 0, 0]) ticks.append(row)
# Pre-co-op call shape, kept for existing callers and tests.func record(input: InputState, input2: InputState) -> void: record_inputs([input, input2])In main.gd, replace:
if _replay_rec: _replay_rec.record(inputs[0], inputs[1] if inputs.size() > 1 else null)with:
if _replay_rec: _replay_rec.record_inputs(inputs)- Step 4: Implement soak
In tools/soak/soak_run.gd, replace:
if bool(cfg.coop): sim.add_player2() ShipBonuses.apply_base_stats_to(String(cfg.ship), sim.pilots[1]) ShipBonuses.apply_stat_bonus_to(String(cfg.ship), sim.pilots[1])with:
# Co-op soaks default to 2 pilots (the pre-2026-10-05 meaning of --coop=coop); cfg.pilots up to # Sim.MAX_LOCAL_PILOTS adds more at the real join offsets. var n_pilots := int(cfg.get("pilots", 2 if bool(cfg.coop) else 1)) for k in range(1, mini(n_pilots, Sim.MAX_LOCAL_PILOTS)): var p := sim.add_pilot(Sim.join_offset(k)) ShipBonuses.apply_base_stats_to(String(cfg.ship), p) ShipBonuses.apply_stat_bonus_to(String(cfg.ship), p)The 2-pilot path is unchanged. add_player2() is add_pilot() at the default offset, and Sim.join_offset(1) returns that same Vector2(70, 0) (Task 1 pins this). That keeps test_run_is_deterministic_per_seed and any recorded co-op soak checksums stable.
Replace the tick line:
sim.tick_single(_input_for(sim.player), _input_for(sim.pilots[1]) if sim.pilots.size() > 1 else null)with:
var inputs: Array[InputState] = [] for pilot in sim.pilots: inputs.append(_input_for(pilot)) sim.tick(inputs)In tools/soak/soak_runner.gd, add "pilots" to the cfg dict:
var rep := SoakRun.new({"seed": seed0 + r, "ship": ship, "coop": coop, "pilots": int(args.get("pilots", "2")) if coop else 1, "max_ticks": ticks, "content": content, "invuln": invuln, "player": player, "upgrades": upgrades, "maximize": maximize, "meta": shop, "profile": profile}).execute()Extend the usage comment at L5 with --pilots=4 (co-op pilot count, default 2).
- Step 5: Run the tests
Run each of test_replay.gd, test_soak_run.gd, test_gameplay_telemetry_coop.gd with -gselect.
Expected: all PASS.
Run: godot --headless --path . -s tools/soak/soak_runner.gd -- --runs=1 --ticks=3600 --coop=coop --pilots=4 2>&1 | grep "\[soak\]"
Expected: one line with coop=true and fails=0.
Run: git status --short project.godot audio/bus_layout.tres
Expected: no output. If either file is listed, run git checkout -- project.godot audio/bus_layout.tres.
- Step 6: Full suite and commit
Run: scripts/check-test-count.sh
Expected: exit 0.
cat > "$TMPDIR/bh-msg" <<'EOF'feat(coop): replays, soak and telemetry carry up to 4 pilots
ReplayRecorder.record_inputs writes 6 numbers per pilot (min 2, so old files areunchanged); soak gains cfg.pilots / --pilots=N; run_body pinned at 4 pilots.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01BfcSqKg4b4daxk12fDv5KDEOFgit add input/replay_recorder.gd main.gd tools/soak/soak_run.gd tools/soak/soak_runner.gd tests/test_replay.gd tests/test_soak_run.gd tests/test_gameplay_telemetry_coop.gd*git commit --file="$TMPDIR/bh-msg"Task 11: Docs, What’s new, full gate, and Chris’s on-device checklist
Section titled “Task 11: Docs, What’s new, full gate, and Chris’s on-device checklist”Files:
- Modify:
docs/architecture/coop-and-pilots.md(the## Sim.pilotssection; the bullet at L27-31) - Modify:
docs/architecture/remote-config.md(a note at the end of “Converting a const to a tunable”) - Modify:
control/src/whats_new.js(the top ofWHATS_NEW)
Interfaces:
-
Consumes: everything above. Produces: nothing new in code.
-
Step 1: Rewrite the pilot-cap bullet in
coop-and-pilots.md
Replace the bullet that begins “Sim.tick(inputs: Array[InputState]) is the canonical signature” (it ends “flagged inline at the integrate() call sites.”) with:
- `Sim.tick(inputs: Array[InputState])` is the canonical signature, index-aligned with `sim.pilots`; a short array or a null entry means that pilot holds still. `tick_single(input, input2=null)` is a compatibility wrapper. Every per-pilot phase (integrate, dash, gem pickup, weapon pickups, level-ups, hits) loops `pilots` in join order, P1 first, so a solo or 2-pilot run draws in exactly the old order (the solo baselines did not move). **`Sim.MAX_LOCAL_PILOTS = 4`**; `add_pilot()` returns null at the cap. Join offsets: `Sim.join_offset(k)` (k=1 is the old `Vector2(70, 0)`).- **Pilot to device binding is explicit** (`input/pilot_bindings.gd`, `PilotBindings`): an unused playable pad pressing A joins as the next pilot; a pilot whose pad disconnects keeps its slot and null input until any unbound playable pad presses A, which rebinds it (orphans rebind before new joins). The Siri Remote is never in the playable list, so it can never join or rebind. Unbound pilots fall back to `playable[k]` so pre-binding tests and tools keep working.- **Soft tether** (`sim/coop_tether.gd`, `CoopTether`): runs only with 2+ live pilots; a pilot past `TETHER_RADIUS` from the live centroid has only the OUTWARD component of that tick's movement damped by `TETHER_DAMP` (inward and tangential movement are untouched, dashes are untouched). Both are bounded remote tunables (group `coop`); the radius max is set so the group always fits the 0.7 camera zoom floor on iPhone. Any co-op baseline re-pin goes through `bh-determinism-repin`.- **Render/UI per pilot:** `PilotView` per pilot in `main._pilot_views`; `CoopCamera` frames the live pilots' centroid and zooms down to 0.7; `PilotPalette` gives P1-P4 cyan / magenta / amber / green (P1's solo look unchanged, tints applied only in co-op so solo UI stays token-clean). HUD: one row of co-pilot HP slots under P1. Weapon dock: one row per co-pilot BELOW the P1-only drone dock (absolute y 226+). Level-up: a P-tag left of the header; panels chain in join order. All three are fit-tested at iPad 985 / iPhone 1204 / TV 1280 with 4 pilots.- **Replays** write 6 numbers per pilot per tick, at least 2 pilots wide, so old files load unchanged. Join timing is not recorded: replay the pilot count the run started with.- **Telemetry:** `pilots` reports the count; `level2` stays P2's level (no per-pilot D1 columns).Also change the section heading ## \Sim.pilots: Array[PlayerState]` (M-A, DONE 2026-07-02, co-op still gated off)` to:
## `Sim.pilots: Array[PlayerState]` (M-A 2026-07-02; up to 4 local pilots 2026-10-05)Run: grep -n "CAPPED AT 2\|still gated off" docs/architecture/coop-and-pilots.md
Expected: no output.
- Step 2: Add the tether note to
remote-config.md
At the end of the “Converting a const to a tunable” section, add:
**Co-op-only tunables** (`CoopTether.TETHER_RADIUS` / `TETHER_DAMP`, group `coop`) are dead in everysolo run, so the liveness probe only sees them with `--coop=coop`. Their bounds are load-bearing forfeel, not just safety: the radius max keeps 4 pilots inside the 0.7 camera zoom floor on iPhone, andthe damp min keeps the tether from becoming a wall. Do not widen either without re-running thecamera fit test (`tests/test_coop_camera.gd`).- Step 3: Add the What’s new entry
Open control/src/whats_new.js. If the first entry already starts build: null, date: null,, append the two items below to its items array. Otherwise insert this as the first element of WHATS_NEW, keeping its first line exactly build: null, date: null,:
{ build: null, date: null, title: 'Local co-op for up to 4 pilots', items: [ { text: 'Up to four controllers can play local co-op: any unused pad that presses A joins as the next pilot (P2, P3, P4). Each pilot has its own colour (cyan, magenta, amber, green) and a P-tag over the ship, its own HP slot under P1\'s bar, its own weapon row (now below the drone dock instead of on top of it) and its own level-up turn, in join order. The camera follows the group and zooms out to fit everyone. A pad that disconnects keeps its pilot; pressing A on any unused pad takes that pilot back. The Siri Remote never joins.', where: 'Apple TV with 4 controllers: start a run, press A on pads 2-4, then switch one pad off and back on' }, { text: 'A soft tether keeps the group on one screen: a pilot far from the others moves outward more slowly (moving back in, sideways and warps are unaffected). Tether radius and strength are new co-op tunables.', where: 'Tunables tab: search tether; with 2+ pilots, drive one pilot away from the group' }, ], },Run: node --test control/test/*.test.mjs 2>&1 | tail -5
Expected: # fail 0.
- Step 4: Full gate
Run: scripts/gate.sh
Expected: green. This includes the docs-site frontmatter test, the skills parity test, the tunable-docs test and the GUT suite.
Run: git status --short project.godot audio/bus_layout.tres
Expected: no output.
- Step 5: Windowed eyeball (ui-sweep or a manual run)
Run: godot --path . --rendering-method mobile. Start survival with a keyboard, then connect pads, or use the dev console’s co-op join if present. Check:
- the HUD row with 4 slots;
- the weapon rows below the drone dock;
- the level-up P-tags;
- the world tags over each ship.
Afterwards run git checkout -- project.godot audio/bus_layout.tres.
The point is to catch anything the headless fit tests cannot see: tag legibility and colour clash with the neon background.
- Step 6: Commit
cat > "$TMPDIR/bh-msg" <<'EOF'docs(coop): 4-pilot local co-op architecture + What's new
Remove the "capped at 2" note; document bindings, tether, camera, per-pilot UI,replay and telemetry shape. What's new build:null entry for the console.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01BfcSqKg4b4daxk12fDv5KDEOFgit add docs/architecture/coop-and-pilots.md docs/architecture/remote-config.md control/src/whats_new.jsgit commit --file="$TMPDIR/bh-msg"git push origin HEADThe device install goes through scripts/dev/install-dev.sh. Before it, check the highest BUILD on live branches, per the root CLAUDE.md.
- Step 7: Hand Chris the on-device checklist (Apple TV + 4 controllers)
These cannot be verified headless. Paste them to Chris verbatim:
- Join: start a survival run, then press A on pads 2, 3 and 4 in turn. Each joins once, as P2/P3/P4, at a different spot near P1, in magenta/amber/green. A fifth pad pressing A does nothing.
- Siri Remote: press select/click on the Siri Remote during a co-op run. It never joins and never steals a pilot.
- Reconnect: switch P3’s pad off. P3 stays on the field, holding still. Switch it back on, or use a different unused pad, and press A. It takes P3 back (the same colour and the same ship), and no fifth pilot appears.
- Camera and tether feel: spread all four pilots to the corners. The camera zooms out but never past the point where ships become unreadable. A pilot driving away from the group feels “heavy”, not walled. Warping outward still works. Verdict wanted: is the tether radius/strength right? (Both are live in the console Tunables tab, so try values there before any code change.)
- HUD: four HP slots readable at couch distance, with P-tags in the right colours, and the boss bar not overlapped during a boss.
- Weapon rows: three co-pilot rows below the drone dock, none clipped at the bottom of the TV.
- Level-up turns: get several pilots to level together. Panels go P1, P2, P3, P4 in order, and only the named pilot’s pad can pick on each.
- iPhone (2 pads): the co-pilot weapon rows do not collide with the touch joystick area bottom-left.
- Haptics (only if the haptics plan has merged): each pad rumbles for its OWN pilot’s hits, not P1’s.
- Solo regression: a solo run looks exactly as before. There is no P1 tag, and the HUD and weapon dock are unchanged.
Self-review
Section titled “Self-review”Spec coverage (spec: docs/superpowers/specs/2026-10-05-local-coop-4-pilots-design.md):
| Spec requirement | Task |
|---|---|
MAX_LOCAL_PILOTS = 4, N-wide sim (movement, gems, pickups, level-ups, hard hits) |
1 |
Solo baselines byte-identical; /sim stays pure RefCounted |
1 (Step: determinism), 4 (Step 9), Global Constraints |
| N pilot views and an index-aligned input array in main | 2 |
| Explicit pilot→device binding, Siri Remote excluded, rebind on reconnect | 3 |
| Haptics hook conditional on the haptics plan, not depended on | 3 (conditional step), 11 checklist item 9 |
| Soft tether: ≥2 live pilots, outward component only, bounded remote tunables, co-op-only re-pin | 4 |
| Camera fits the group, zoom floor; radar follows | 5 |
| Pilot tints cyan/magenta/amber/green, P1 solo look unchanged, world tags | 6 |
| HUD fits at iPad 985 / iPhone 1204 / TV 1280 with 4 pilots | 7 |
| Weapon panel fits at the 3 profiles with 4 pilots | 8 |
| Level-up panel fits at the 3 profiles; per-pilot turns | 9 |
| Old 2-pilot replays still load | 2 (input_at null past a short row), 10 (format min-2) |
player2 / player2_hard_hits accessors kept |
1 |
What’s new build: null entry |
11 |
coop-and-pilots.md rewrite removing “CAPPED AT 2”; Chris’s on-device checklist |
11 |
There are no gaps.
Placeholder scan: I searched for TBD, TODO, “implement later”, “similar to Task” and “add appropriate”. None appear in the steps. There are two conditional steps:
- Task 3’s haptics step, which runs only if
_drive_hapticsexists; - Task 9 Step 5, which runs only if the chain test fails.
Both carry the exact code for the branch that applies.
Type consistency:
Sim.join_offset(k) -> Vector2andSim.add_pilot(offset) -> PlayerState(null at the cap) are used the same way in Tasks 1, 7, 8, 9 and 10.PilotPalette.accent(k) -> Color/tag(k) -> Stringcome from Task 6, and Tasks 7-9 use them.Sim.tick(inputs: Array[InputState])with null entries is defined in Task 1 and used in Tasks 2 and 10.- The
ReplayLoader.input_atnull-past-row behaviour is defined in Task 2 and relied on in Task 10. PilotView/_pilot_viewsare defined in Task 2 and used in Tasks 3, 5 and 6.CoopTetherandCoopCameranames match across Tasks 4, 5 and 11.- The UI seam names (
copilot_bar,copilot_row,visible_copilot_rows,_p1_tag,_pilot_tag) are each defined once and used only in their own task’s tests.
Global notes for executors:
- Every new
class_namefile gets a tracked.gd.uidsidecar, which is why thegit addlines use a*glob. After adding one, rungodot --headless --path . --import, thengit checkout -- project.godot audio/bus_layout.tres. - Run each task in its own worktree off
origin/main, per the root CLAUDE.md. Merge in task order, because Tasks 7-9 consume Task 6’sPilotPalette. - After each merge, run
git push origin main.
Execution handoff
Section titled “Execution handoff”This plan is uncommitted in the coop-haptics-specs worktree. The parent session commits it alongside the spec. Recommended execution: superpowers:subagent-driven-development, with one bh-implementer (model opus) per task in its own worktree and a bh-reviewer gate between tasks.
Ordering constraints:
- Tasks 1→2→3 are strictly sequential.
- Task 4 needs Task 1.
- Task 5 needs Task 2.
- Task 6 needs Task 2.
- Tasks 7, 8 and 9 need Task 6. They are independent of each other but all touch
tests/test_ui_literal_ratchet.gdcounts indirectly, so run them sequentially. - Task 10 needs Tasks 1 and 2.
- Task 11 comes last.
If the haptics plan (2026-10-05-haptics.md) merges first, Task 3’s conditional step applies. Otherwise skip it, and the haptics plan’s own co-op task picks it up.