Local co-op up to 4 pilots (design)
Local co-op: up to 4 pilots
Section titled “Local co-op: up to 4 pilots”Date: 2026-10-05 · Status: Design approved in chat (Chris); spec awaiting review
Scope: raise local co-op from 2 to up to 4 pilots (Chris asked for a 3rd; the cap is 4 because the array refactor costs the same and Apple TV pairs 4 controllers). Chris has 4 controllers for the device test.
Current state (verified on origin/main 2026-10-05)
Section titled “Current state (verified on origin/main 2026-10-05)”- Sim:
pilots: Array[PlayerState],add_pilot(offset). Fire, dash, decoy, regen and hit-check already loop overpilots. Still 2-wide:tick()builds_pilot_inputs := [input, input2], andintegrate()is hard-coded toplayer/player2(inline TODO: “a 3rd pilot would tick everything else but never move”).player2_hard_hitsis a Sim field, not per-pilot. main.gd: 48 P2-specific references —player2_node/renderer/facing/last-pos,_last_hp2/_last_dash_cd2/_last_hard_hits2, the join block (_can_p2_join:pilots.size() < 2; trigger = A onplayable[1]),input2polling, replay record/load, camera midpoint, radar.ui/hud.gdhas a one-off P2 HP block (HP2_GROUP_Y);ui/weapon_panel.gda one-off P2 row (P2_ROW_Y).input/replay_recorder.gd,tools/soak/soak_run.gd,net/gameplay_telemetry.gdreference P2 too.- Camera: midpoint of two alive pilots, fixed zoom, nothing keeps pilots on screen.
- Pilots are visually identical (no per-pilot tint).
Design
Section titled “Design”const MAX_LOCAL_PILOTS := 4;add_pilotrefuses past it. Spawn offsets for pilots 2–4 are distinct points around P1 (e.g. ±70 x, +70 y) so drop-ins never stack.tick(inputs):_pilot_inputsisinputspadded withnulltopilots.size().integrate()moves into afor pilot in pilotsloop (skip dead / null input), with the web-slow and dimension speed multipliers applied per pilot exactly as today. Processing order ispilotsorder, which matches the old P1-then-P2 path, so the 2-pilot behaviour is unchanged.player2_hard_hitsbecomesPlayerState.hard_hits;Sim.player2_hard_hitsstays as a forwarding accessor (same pattern as the other compatibility accessors).- Soft tether (only when ≥ 2 pilots are alive): after integrate, a pilot further than
TETHER_RADIUSfrom the alive-pilot centroid has the outward component of its displacement scaled byTETHER_DAMP. No hard clamp, so warps still work and can’t be trapped. Both values are tunables (docs/architecture/remote-config.md), bounded so a patch can’t make it a wall or turn it off below the camera cap. - Determinism: with one pilot no tether code runs and the integrate order is identical, so
test_determinism_survival/test_determinismmust stay byte-identical — a move there is a bug.test_determinism_coopmay move if its pilots stray past the radius; that is an intentional re-pin via skillbh-determinism-repin.
main.gd
Section titled “main.gd”- Replace the P2 fields with
var _pilot_views: Array[PilotView]— one per pilot holding node, renderer, facing, last pos, last dash cd, last hp, last hard hits. P1’s existing node becomes_pilot_views[0]’s (keep theplayer_nodeaccessor for the many P1 readers). - Join:
_can_join(sim, mode)= not tutorial andpilots.size() < MAX_LOCAL_PILOTS. Any playable pad not already bound to a pilot pressing A joins as the next pilot. Device binding is an explicitpilot → devicearray (not “pilot k = playable[k]”), so a pad that disconnects and reconnects can’t silently shuffle who drives which ship. The Siri Remote stays excluded (playable list). - Input:
inputs[k] = input_router.poll_device(_pilot_device[k]);maincallssim.tick(inputs).tick_singlestays for legacy tests only. - Replay: recorder/loader store N inputs per tick; loading an old 2-pilot replay still works (missing pilots read as null).
- Soak and gameplay telemetry: iterate pilots instead of reading
player2.
Camera
Section titled “Camera”Centre on the alive pilots’ centroid; zoom out to fit their bounding box + margin, smoothed, capped
at ~0.7×. TETHER_RADIUS is chosen so the tethered party fits inside the capped view on the
narrowest profile. The radar’s “view pilot” uses the camera whenever more than one pilot exists.
Identity
Section titled “Identity”Each pilot gets an accent tint on its ship and a small “P1”–“P4” tag. Default palette: cyan, magenta, amber, green (art direction may override). P1’s look in single-player is unchanged.
HUD + weapon panel
Section titled “HUD + weapon panel”The one-off P2 HP block and P2 weapon row become per-pilot slots built in a loop (slot k shown
while pilots.size() > k), each tagged with the pilot colour. Must fit at all three device
profiles (iPad 985, iPhone 1204, TV 1280 logical px) with 4 pilots — tested in a SubViewport per
ShopLayoutFixture profile, not at 1280 only (CLAUDE.md UI-fit rule).
Level-up
Section titled “Level-up”main already chains panels across all pilots with pending_levelups > 0 and the panel gates input
to its pilot’s device; extend tests to 3–4 pilots, and label with the pilot tag + colour.
Out of scope
Section titled “Out of scope”- Online co-op, split-screen, per-pilot hull choice (pilots still mirror P1’s hull), a co-op lobby.
- The P1-only Drone Bay and decoy-charge HUD stay P1-only by design (coop-and-pilots.md).
Testing
Section titled “Testing”- Sim: 3- and 4-pilot movement (each pilot moves on its own input); tether damps outward motion
only, never inward, and is inert with one alive pilot;
add_pilotcaps at 4. - Determinism: single-player baselines unchanged; co-op baseline re-pinned only if moved, via skill.
- main: join of pilots 3 and 4 from distinct pads; a bound pad can’t join twice; Siri Remote never joins; reconnect keeps the binding.
- Replay round-trip at 3 pilots; old 2-pilot replay still loads.
- HUD / weapon panel / level-up panel fit at 4 pilots on all three profiles.
- Docs: update
docs/architecture/coop-and-pilots.md(remove the “CAPPED AT 2” note). - On-device (Chris, Apple TV + 4 controllers): join order, each ship follows its pad, camera framing + tether feel, HUD legibility, level-up panel chaining, haptics per pad.
Ships after the haptics spec (2026-10-05-haptics-design.md), which routes by pilot index and so
covers pilots 3–4 automatically once they exist.