Skip to content

Local co-op up to 4 pilots (design)

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 over pilots. Still 2-wide: tick() builds _pilot_inputs := [input, input2], and integrate() is hard-coded to player / player2 (inline TODO: “a 3rd pilot would tick everything else but never move”). player2_hard_hits is 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 on playable[1]), input2 polling, replay record/load, camera midpoint, radar.
  • ui/hud.gd has a one-off P2 HP block (HP2_GROUP_Y); ui/weapon_panel.gd a one-off P2 row (P2_ROW_Y). input/replay_recorder.gd, tools/soak/soak_run.gd, net/gameplay_telemetry.gd reference P2 too.
  • Camera: midpoint of two alive pilots, fixed zoom, nothing keeps pilots on screen.
  • Pilots are visually identical (no per-pilot tint).
  • const MAX_LOCAL_PILOTS := 4; add_pilot refuses 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_inputs is inputs padded with null to pilots.size().
  • integrate() moves into a for pilot in pilots loop (skip dead / null input), with the web-slow and dimension speed multipliers applied per pilot exactly as today. Processing order is pilots order, which matches the old P1-then-P2 path, so the 2-pilot behaviour is unchanged.
  • player2_hard_hits becomes PlayerState.hard_hits; Sim.player2_hard_hits stays 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_RADIUS from the alive-pilot centroid has the outward component of its displacement scaled by TETHER_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_determinism must stay byte-identical — a move there is a bug. test_determinism_coop may move if its pilots stray past the radius; that is an intentional re-pin via skill bh-determinism-repin.
  • 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 the player_node accessor for the many P1 readers).
  • Join: _can_join(sim, mode) = not tutorial and pilots.size() < MAX_LOCAL_PILOTS. Any playable pad not already bound to a pilot pressing A joins as the next pilot. Device binding is an explicit pilot → device array (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]); main calls sim.tick(inputs). tick_single stays 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.

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.

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.

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

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.

  • 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).
  • 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_pilot caps 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.