Skip to content

Bullet Heaven — co-op, pilots, drones

Current source of truth for the N-pilot sim model and the per-pilot systems built on it. Moved out of the root CLAUDE.md on 2026-09-28 (content unchanged); design/plan history in memory bullet-heaven-roadmap and docs/superpowers/plans/2026-07-02-multiplayer-sim-foundation-m-a.md, 2026-07-10-p2-upgrade-pipeline.md, 2026-07-11-p2-drone-decoy.md.

Sim.pilots: Array[PlayerState] (M-A, DONE 2026-07-02, co-op still gated off)

Section titled “Sim.pilots: Array[PlayerState] (M-A, DONE 2026-07-02, co-op still gated off)”
  • player/player2 are thin compatibility accessors (player2 returns pilots[1] or null) — pilots is the real backing array ([player] in single-player, add_pilot(offset) appends more; add_player2() is a one-line wrapper).
  • _nearest_pilot(from: Vector2) -> PlayerState is the query every enemy/spawn/boss-targeting site uses instead of hardcoding player.pos. It short-circuits to return the sole pilot unconditionally when pilots.size() == 1, which is WHY the single-player determinism baseline held byte-identical through this milestone.
  • Each pilot owns a PilotArsenal (player.arsenal) — weapon instances, active_weapon_ids, and the weapon-level/threshold bookkeeping dicts all moved off Sim. Every old Sim-level weapon field (sim.weapon, sim.active_weapon_ids, etc.) is now a forwarding accessor into player.arsenal.
  • Every Weapon*.update() takes an explicit pilot: PlayerState first param (not sim.player).
  • _check_pilot_hit(pilot) is the ONE hit-check function (armor/iframe/dev_invuln-aware) for every pilot; game-over is _all_pilots_down().
  • Sim.tick(inputs: Array[InputState]) is the canonical signature. tick_single(input, input2=null) is a compatibility wrapper most call sites (tests, main.gd) still use. tick() derives input/input2 from inputs[0]/inputs[1], so real per-tick pilot handling is CAPPED AT 2 today (a 3rd pilot fails safe/inert, not corrupt). Extending past 2 needs tick() unified into one for pilot in pilots loop — flagged inline at the integrate() call sites.

Per-pilot XP + upgrade pipeline (2026-07-10, b057167..6746eca)

Section titled “Per-pilot XP + upgrade pipeline (2026-07-10, b057167..6746eca)”
  • Kill-XP: shared party credit banks into every alive pilot (104b24c, 2026-07-09).
  • sim/upgrade_system.gd’s roll/apply/preview pipeline (17 of ~29 functions) plus sim/sim.gd’s crystal-threshold auto-upgrade system take an explicit pilot: PlayerState param throughout, matching the Weapon*.update(pilot, ...) convention. P2 progresses independently.
  • main.gd scans all pilots for pending_levelups > 0 and chains level-up panels sequentially (P1 first on a simultaneous level-up; sim stays paused for both).
  • ui/level_up_panel.gd shows the correct pilot’s own build, gates keyboard/joypad/mouse input to that pilot’s own device in co-op, and labels which pilot the panel belongs to.
  • ⚠️ Owed: a real 2-controller (+ mouse) co-op playtest — confirm P1 can’t hijack P2’s panel and panels chain correctly on-device.

Per-pilot drones/decoy (2026-07-11, 83f1e98..0a6ddf4) — co-op sweep complete

Section titled “Per-pilot drones/decoy (2026-07-11, 83f1e98..0a6ddf4) — co-op sweep complete”
  • DroneState has an owner: PlayerState field; drone_charge lives on PlayerState (Sim keeps a P1-only forwarding accessor).
  • sim.drones stays ONE shared flat pool (enemies already targeted it that way).
  • P2’s decoy button recharges/deploys their own drone via a per-pilot loop in Sim.tick() mirroring the weapon-firing loop’s _pilot_inputs pattern.
  • The decoy-proximity damage-synergy mechanic was removed entirely (Chris’s call — a reactions-mode-era leftover that can’t be made per-pilot without rewriting the anonymous damage_enemy() pipeline).
  • The mid-run Drone Bay and the in-play HUD decoy-charge indicator stay P1-only by design (undesigned separate features, not gaps).
  • The Web drone (class web, overnight T4) and the Stasis Web card are per-pilot too: the bonus Web drone is owned by whichever pilot launched, and StasisWeb.update_pilot runs per living pilot with its own stasis_web_level/stasis_web_cd. Detail: docs/architecture/enemies-bosses-survival.md § Tackler (Player counters).

Networked co-op (lockstep core + dev-only LAN host/join)

Section titled “Networked co-op (lockstep core + dev-only LAN host/join)”

Design: docs/superpowers/specs/2026-10-07-networked-coop-design.md (host-scheduled input lockstep, ENet on LAN, Cloudflare Durable Object relay for internet, phased plan + open Chris calls). The July LAN prototype lives only on the unmerged branch origin/networked-multiplayer-mb-mc; port pieces from it by hand, never merge it.

Built (overnight T8 = spec Phase 2; main.gd drives it through the LAN and internet flows below):

  • Sim.net_checksum(): a superset of state_checksum() (adds the 4 RNG stream positions via SeededRng.get_state(), run_time/kills, boss state machines, pilot dash/iframe/tackle/heal timers, enemy type/id/stasis/slow timers). state_checksum() is untouched, so the baselines cannot move.
  • net/net_transport.gd (NetTransport, host = peer 1) with LoopbackTransport (in-memory pair, latency/jitter/drop counted in receiver polls) and ENetTransport (ENetMultiplayerPeer as a raw packet peer, never on SceneTree.multiplayer, no @rpc; channel 1 reliable rows, channel 2 unreliable-sequenced inputs). Proven headless on 127.0.0.1 by tests/test_enet_transport.gd.
  • The loopback WAN profile under-stalls real links. tests/test_lockstep_wan.gd pins stalls / late inputs / display lag per profile (100 ms, 250 ms, burst loss, spikes); pure 100-250 ms delay stalls 0 times, while real Wi-Fi runs (lan_run, BUILD 279/282) stall ~7-8 client frames/s. The model has no device-side frame-timing jitter, so treat its stall numbers as a regression guard, not a prediction; the late-input counters there were the real finding (every client input landed after its stamped tick at >=100 ms RTT until task 6 stamped from rtt_ms; now 0 late on clean links, the rest under loss/spikes is lost input packets). LoopbackTransport.make_wan_pair reports its mean RTT via rtt_ms() so the stamping sees what a settled ENet link would.
  • net/lockstep_protocol.gd: binary messages (hello/welcome/reject/input/row/bye/ping) and the quantized input (u16 move angle + int8 aim x/y + dash/decoy/move bits). Every sim, the host’s own included, ticks on dequantize(q), never the raw InputState (mutation-checked: feeding the host the raw input desyncs by tick 29).
  • net/lockstep_session.gd: create_host/create_client, step(input, now_ms) per frame, service(now_ms) while paused. Host never waits; client stalls on a missing row and runs 2 ticks/frame when >3 are queued. Client inputs are stamped for the host tick they should ARRIVE at: max(newest TICK row + 1, next_tick) + ceil((rtt_ms + ms since that row arrived, capped ROW_AGE_CAP_MS) / DT) + INPUT_MARGIN_TICKS (1), a FULL RTT, not half (the row is one-way old on arrival and the input flies one-way back); targets never go backwards; hold rows never move the base; RTT comes from transport.rtt_ms() (0 while unknown, capped at 1 s); early ones wait for their tick, late ones land on the host’s current tick, a missing one repeats move/aim with edges cleared. Dash/decoy presses can’t be lost (protocol v3, day-one task 7): every MSG_INPUT carries the client’s cumulative u8 dash/decoy press counters; on the host the counter alone decides how many edges apply (wrap-aware LockstepProtocol.press_delta), an entry’s own edge bit only places one on its exact stamped tick, and a bit whose packet does not advance the counter is dropped, so each press applies exactly once. A press whose every redundant copy was lost lands on the next delivered packet’s newest entry without the bit (newest first); presses still collapse when there are more of them than that packet has entries (3), or when its entries arrive late and all clamp onto the host’s current tick (the usual case after a real burst; harmless under the 1.5 s dash cooldown). A level-triggered presser (autopilot held dash) loses presses across a ~2 s outage (press_delta reads 128+ as behind). edges_applied / decoys_applied count per pilot; tests/test_lockstep_wan.gd reports edges=applied/sent lost=. net_checksum rides every 30th row; a mismatch ends both sessions with desync_tick + desync_snapshot. 5 s silence: host downs the client’s pilot (CMD_PILOT_LEAVE, the only command so far), client ends host_timeout; the host pings every 0.5 s while paused. Time is injected.
  • Start barrier (protocol v4, day-one task 8): building a run blocks the main thread (the factory’s sim on WELCOME, then main’s _start_net_run / _new_run), so a peer that ticked as soon as it was RUNNING left a slower one a catch-up burst or, past 5 s, a dropped pilot. Every peer now calls mark_ready() (NetCoopRun.mark_ready, main: last line of _start_net_run); a client sends MSG_READY and, until then, only services the link and sends a MSG_PING every 0.5 s (no inputs, no rows applied). The host issues no TICK row until it AND every client are ready: step() goes down the advance = false HOLD path (the tick-0 FLAGS still goes out as a hold row). A not-ready client is exempt from the 5 s silence timeout and is instead dropped LockstepSession.READY_TIMEOUT_MS (30000, a “local” tunable) after the host started, as drop_reason = "ready_timeout" (PILOT_LEAVE + a dropped: ready_timeout BYE, so the client ends host_left: dropped: ready_timeout); the barrier then opens and the host plays on alone. A client that has sent its HELLO uses the same limit for host silence until the first TICK row (a host blocked in its own build sends nothing, and ENet only flushes the queued WELCOME on its next poll). After the barrier the 5 s rules apply again. waiting_for_ready() drives the dev banner (“Waiting for the other player to load…” / “…for the host to start…”); lan_run gains ready_wait_ms (both roles) and drop_reason (host). ENet’s OWN peer timeout (unacked reliable packets cut a peer after 5.9-10.2 s on localhost) would drop a blocked device first, so from create_* until the barrier opens the session calls transport.set_link_timeout(READY_TIMEOUT_MS) (ENetTransport: ENetPacketPeer.set_timeout(32, ms, max(ms, 30000)) on every peer, also applied on connect) and set_link_timeout(0) (ENet defaults 32/5000/30000) when it opens, on both roles. Behind the barrier no client is held to the 5 s silence rule (every silence clock restarts at the barrier), and a ready client sends only keepalive pings until the first tick row (inputs stamped while the host builds would merge into its first tick). Accepted cost: a host that hangs after the HELLO takes 30 s, not 5, to fail in the joiner’s lobby. Tests: tests/test_lockstep_ready.gd (8 s blocked client load: no tick row, no drop, then no catch-up burst and no stall; never-ready client dropped at the join timeout; host’s own build gates it too; no client input before the first tick row; a FLAGS command queued during the wait applies identically) and tests/test_enet_transport.gd (real ENet: a client blocked past the (test-scaled) normal timeout behind the barrier stays connected, and after the barrier a silent client is cut by ENet’s normal timeout again).
  • Two sims in ONE process stay identical for 3,000 ticks through a lossy jittery loopback, so /sim has no shared static mutable state that interleaving breaks.

Built (overnight T9 = spec Phase 1 command stream + a dev-only LAN flow):

  • Protocol v2 (now v4: v3 added the edge counters, v4 the start barrier, both above): every row carries a seq; commands are [kind:u8, pilot:u8, arg utf8 u16-len]; a HOLD row (ROW_HOLD) applies its commands WITHOUT ticking the sim and always carries a checksum; MSG_CMD_REQ is the client asking the host to schedule a command (today only its own pilot’s level-up pick).
  • sim/sim_commands.gd (SimCommands, pure /sim) is the ONLY applier of out-of-tick mutations in a networked run: PILOT_LEAVE, LEVELUP_ROLL (upgrade_system.roll_upgrade_choices, 3 cards), LEVELUP_PICK (apply_upgrade), FLAGS (bitmask mirror of the DevFlags tackler_spawns, suppressor_spawns, web_drone_grant, hard_tuning), RESUME (post-level-up iframes on every pilot). net_checksum now also covers those four flags and each pilot’s level, active weapon ids and stasis-web level. state_checksum untouched, so no baseline moved.
  • The host’s session validates every command (_accept): a pick must be one of that pilot’s open offer cards, from that pilot’s own device, one in flight per pilot; every other kind is host-only.
  • net/net_coop_run.gd (NetCoopRun) is what main.gd drives. Networked runs are Survival, no areas/dimension choice, no meta shop or hull, remote-config defaults (apply_for_run({}), the config_hash is RemoteConfig.active_hash). Level-ups pause everyone (as local co-op): the host rolls for the lowest-index pilot with pending_levelups, holds ticks until that pilot’s device picks (auto-pick with SkilledAutoPlayer.pick_upgrade_for after 15 s), then sends RESUME. The host sends FLAGS at tick 0 and whenever the mask changes.
  • net/lan_beacon.gd (LanBeacon): UDP broadcast DCLAN1|build|port|name on port 47611 once a second; the game itself is ENet on 47610 (NetCoopRun.PORT). ui/lan_lobby.gd (LanLobby) shows the host’s own IPs, the hosts heard, and a manual-IP box.
  • Easy join (2026-10-07, dev-only): net/lan_registry.gd (LanRegistry) talks to the control Worker’s /mp/* registry (CONTROL_KEY-gated, salted-hash “same public network” match, 60 s TTL; control/src/mp.js). The host registers and shows a 5-letter room code (LanLobby.set_code); the joiner’s lobby lists registry + beacon hosts merged by IP (LanLobby.merge_hosts, other BUILDs greyed), then a code box, then a collapsed IP box. A host’s private IPs are tried in order (own /24 first) and fall over on host_timeout (_net_join_queue). Worker unreachable = the old beacon + typed-IP behaviour. The console status carries hosting: {code, ips} while a host lobby is open. Spec: docs/superpowers/specs/2026-10-07-lan-easy-join-design.md.
  • Device gotchas (Dev 271, 2026-10-07): an iOS/macOS joiner with Local Network privacy off gets a silent UDP drop, i.e. a bare host_timeout; the lobby now says so (LanLobby.join_failure_text). iOS can’t hear the beacon (no multicast entitlement), so its join list is registry-only; a Mac listing a host proves nothing about the registry path. The client has a jitter buffer (LockstepSession.JITTER_ROWS, 2, a “local” tunable): it primes on JITTER_ROWS + 1 queued rows, then holds that many in reserve, so Wi-Fi clumping costs a ~33 ms display delay instead of stalls; frames spent refilling it count as cushion_waits (lan_run). GUI runs don’t print the periodic [lan] stall report (CLI --mp-* only).
  • Host lobby hardening (2026-10-08, net-lobby-harden): the host has ONE ENet slot, so a refused joiner (wrong protocol/BUILD/config, run in progress) gets its MSG_REJECT and is then NetTransport.disconnect_peered, and any connected peer with no valid HELLO within LockstepSession.HELLO_DEADLINE_MS (3000, a “local” tunable: not in the join config hash) of the host first seeing it (injected _now) is refused the same way. ENetTransport.disconnect_peer uses ENetPacketPeer.peer_disconnect_later(): ENetMultiplayerPeer.disconnect_peer() resets ENet’s outgoing queue and LOSES the queued REJECT (proved by test_disconnect_peer_delivers_the_last_packet_and_frees_the_only_slot failing with it). A dead remote never acks that, so the session force-disconnects a refused peer (disconnect_peer(id, true) = peer_disconnect_now()) one HELLO_DEADLINE_MS after its REJECT, freeing the slot without waiting out ENet’s ~30 s timeout. The session emits peer_rejected(reason) in host-facing words (“A player tried to join with BUILD 281 (this is 282)”); main shows it via LanLobby.show_host_alert under the code and IPs.
  • main.gd LAN section (_lan_* / _net_*): Debug Settings rows “Host LAN co-op” / “Join LAN co-op” (dev builds only) and the Remote Console lan_coop command (host / join ip|code / leave) open the lobby; the host starts the run when a peer joins. In a net run _physics_process runs _net_physics instead of sim.tick, each device’s camera follows its own pilot, the level-up panel opens only for the local pilot’s offer and its pick goes through NetCoopRun.pick. Refused during a net run: every dev command outside NET_SAFE_DEV_KINDS (render/feel-only kinds), the Drone Bay, live tunables and remote-config patches. A dev banner shows tick/checksum status and turns red on a desync (snapshot written to user://net-desync.txt). CLI for two Mac instances: godot --path . -- --mp-host [--mp-auto] [--mp-ticks=N] and godot --path . -- --mp-join=<ip> [--mp-auto] [--mp-ticks=N] (--mp-auto = autopilot + auto-pick; both print [lan] host|client t=.. sum=.. every 600 ticks, compare the sums).
  • Tests: test_sim_commands, test_net_coop_run (two autopiloted devices over a lossy loopback reach level 6 each, mid-run flag flip, no desync), test_lan_coop_main (two real main.tscn instances joined through the lobby seams play through level-ups), test_lan_beacon (real UDP on 127.0.0.1).

Platform keys (NOT in the presets yet, platform/tvos/ is guarded; Chris adds them):

  • iOS / iPadOS: NSLocalNetworkUsageDescription in Info.plist (e.g. “Dark Cosmos uses your local network to play co-op with nearby devices.”), otherwise the first ENet connect is silently blocked. Sending or hearing the UDP broadcast beacon additionally needs the restricted entitlement com.apple.developer.networking.multicast (requested from Apple per app id); without it an iPhone or iPad still joins by typing the host’s IP, which the host’s lobby shows.
  • tvOS: no local-network prompt; nothing needed for unicast ENet [Likely]. Broadcast discovery may need the same multicast entitlement [Guessing].
  • macOS 15+: the system asks for local-network permission on first use (for a signed app the NSLocalNetworkUsageDescription string is shown); an unsandboxed dev build needs no entitlement. Sandboxed builds would need com.apple.security.network.server + .client.
  • Android: INTERNET (already present) and CHANGE_WIFI_MULTICAST_STATE for broadcast receive on some devices.

Still missing: a Drone Bay command, live tunables/config patches as commands, per-pilot MetaRunSetup/hulls, cross-device determinism RESULTS (the probe exists, see § Cross-device determinism below), per-pilot HUD (the HUD still shows P1 on both devices; parallax follows P1), mid-run join/reconnect, a client pausing the host, and fx events from the first of two catch-up ticks in one frame are dropped (render only). No net_desync telemetry event: a new kind of data would need the privacy page + ASC label first.

The same NetCoopRun / _lan_pump / _start_net_run / _net_physics path, over a RelayTransport (net/relay_transport.gd, the Cloudflare room relay in relay/) instead of ENet. Plan (and the binding wire format): docs/superpowers/plans/2026-10-14-internet-coop-relay-phase4.md. Online co-op is TestFlight-only until a store version is reviewed with it visible (1.2; Apple 2.3.1), see Gate below.

Files: relay/ (Worker: src/worker.js router + per-IP connect limit, src/room.js the RelayRoom Durable Object, src/room_core.js pure routing/admission/kick/token bucket, test/, deploy.sh, README.md = endpoints, close codes, limits, deploy sequence); net/relay_transport.gd (RelayTransport extends NetTransport), net/relay_frames.gd (RelayFrames, the envelope codec; golden bytes shared with the Worker’s node tests in tests/fixtures/relay_frames.json), net/relay_socket.gd (RelaySocket seam) + net/ws_relay_socket.gd (WsRelaySocket, Godot WebSocketPeer), net/online_coop.gd (OnlineCoop: gate + every player-facing failure string), ui/online_lobby.gd (OnlineLobby, pure view) + ui/code_picker.gd (CodePicker, 5-cell code entry that works with pad, Siri Remote, touch and keyboard, no system keyboard needed on tvOS).

Data flow: LockstepSession → RelayTransport.send(peer, bytes) → a binary DATA frame [1][u8 dest peer][LockstepProtocol bytes] over one WebSocket to wss://<relay>/mp/room/<CODE>?role=host|join&v=1 → the room’s DO rewrites the peer byte to the SOURCE and forwards it → the other RelayTransport → poll() hands the session the byte-identical LockstepProtocol message ENet would have delivered. The relay never reads those bytes, never simulates and stores nothing (relay/test/no-storage.test.mjs). One reliable ordered stream, so reliable is ignored and loss_pct() is -1 (TCP: late, never lost).

  • Peer ids: the host is peer 1 (NetTransport.HOST_PEER, the DO’s welcome says so), the joiner the lowest free relay SLOT in 2..MAX_MEMBERS (= 2). The relay reuses slots, but RelayTransport gives every slot that joins a fresh, never-reused SESSION id (2, 3, 4, …), as ENet does, and translates on send / receive / kick (the wire keeps the slot byte). Without that, a joiner that left mid-run and came back on slot 2 had its HELLO ignored as a repeat from the dropped client and hung for 30 s (final review I1); now it gets “session already in progress” at once. A forced disconnect_peer of an old id is a no-op (that id is gone), never a kick of the new joiner. set_link_timeout is a no-op too (the relay has no link timeout of its own).

  • RTT: RelayTransport sends PING [2][peer][nonce][sent_ms] to each peer at once on join, then every PING_MS (1000); the peer’s transport answers PONG, both forwarded end to end through the DO, so rtt_ms() (EWMA, RTT_ALPHA 0.25) is the full peer-to-peer RTT that _lead_ticks input stamping needs; -1 until the first PONG. At most MAX_PONGS_PER_WINDOW (4) PONGs per peer per PING_MS are answered, so a peer flooding PINGs can’t spend our relay token bucket.

  • Ending a session from the relay’s side (client): the transport turns the socket’s close into ONE synthetic LockstepProtocol message so the session ends at once instead of after its 5 s / 30 s silence timeout: host_left (4010) or a leave of peer 1 → BYE left; any other loss after the welcome → BYE connection_lost (the banner must not claim the friend left); a kick (4005) → a synthetic REJECT. Before the welcome a loss is failure() = connect_failed (CONNECT_TIMEOUT_MS 8000 also gives up on a stalled connect).

  • Kick (host refusing a joiner): disconnect_peer sends CTRL {"t":"kick","peer":N,"why":<the last REJECT reason sent to N>}; the DO closes that socket 4005 with why as the close REASON. Godot’s WSLPeer drops frames received in the same poll as a close, so the REJECT forwarded just before is usually lost; the joiner’s transport injects a REJECT carrying the close reason (unless the real one got through), so a build mismatch still reads rejected: build mismatch….

  • Keepalive: WsRelaySocket.HEARTBEAT_S = 25 s WebSocket pings (answered by the Workers runtime without waking a hibernated DO), so a lone host in its lobby (no peers, so no relay PINGs) survives edge idle timeouts.

  • Failures: RelayTransport.failure() = "" or a close name (code_taken, room_full, no_room, kicked, rate_limited, too_big, host_left, bad_request, connect_limited), connect_failed or connection_lost; it rides the lan_run event as relay_failure with transport = "relay" (docs/architecture/dev-tools.md § Telemetry v2). connect_limited only happens before the welcome, and a session that was never welcomed sends no lan_run, so it never reaches telemetry.

  • Relay limits (relay/README.md § Limits): 4096 B frames, 128 B client CTRL, per-socket token bucket 600 / 180 s⁻¹, 2 members, 30 WebSocket upgrades / 60 s per IP (over it the Worker closes the upgrade 4029 connect_limited, “Too many tries from this network”, not an HTTP 429 the client would read as no internet).

  • Gate: OnlineCoop.enabled(dev, RemoteConfig.features) (net/online_coop.gd): Dev builds always; store builds only with features.internet_coop == true in the accepted remote-config patch. main._online_allowed() is read when the start menu is BUILT (StartMenu.online_allowed, set before add_child), so a flip reaches a player at the next menu build (boot seeds features from the cached patch). _online_open re-checks it, so nothing opens a lobby while the gate is shut.

  • Flow: the start menu’s CO-OP ONLINE action row (after the mode rows) emits online_requested → _online_open(): _lan_close(), shipped defaults (apply_for_run({}), as LAN), an OnlineLobby in _online_lobby, the start menu hidden under it (restored by _lan_close on cancel). HOST → _online_host_attempt: a RelayTransport.random_code(_online_rng) room, NetCoopRun.create_host, the code shown big; code_taken retries with a fresh code (ONLINE_HOST_TRIES = 5). JOIN → the CodePicker’s code → _online_join → _lan_join_with. Cancel in the join view backs out to the choice view and drops the attempt (join_cancelled); Cancel elsewhere closes the lobby.

  • Failures before the run (_online_pump, from _lan_pump): a session that ended (a REJECT, host left) shows OnlineCoop.failure_text(end_reason) and reports one lan_run reason lobby; otherwise a relay failure (no_room, room_full, connect_failed, …) shows its text and reports nothing (a lobby that never connected is no session). Either way the attempt is dropped at once (_net/_net_relay = null), so no host_timeout follows and the text is not re-applied every frame. A session that ended before its relay was ever welcomed (a joiner’s 5 s host_timeout beats the relay’s 8 s CONNECT_TIMEOUT_MS on a stalled connect) reads as connect_failed and reports nothing. A host’s relay loss before anyone joined shows OnlineCoop.host_failure_text (the server, not “your friend”) and the choice view. HOST / JOIN presses also refuse headless without the relay seam. The host’s refusal of a joiner shows OnlineCoop.host_refused_text in store builds.

  • In the run: the lobby is freed in _start_net_run. Store builds’ banner carries only player text (Waiting for your friend…, Your friend is choosing an upgrade…, the host’s 4 s OnlineCoop.peer_left_text(drop_reason), the end line = failure_text(end_reason) in a neutral colour); Dev keeps the dev banner (ONLINE CO-OP · HOST · tick …).

  • Backgrounding: NOTIFICATION_APPLICATION_PAUSED during a running online session → NetCoopRun.close("backgrounded") (BYE; the host drops the pilot as left and plays on), mark_ended("backgrounded"), one lan_run reason backgrounded. FOCUS_OUT never ends it (tvOS resign-active, Mac alt-tab). Not applied in the lobby: a host switching to Messages to send the code must not lose the room; if iOS kills the socket meanwhile the relay failure shows on return.

  • CLI (Dev only): -- --mp-host-online[=CODE] [--mp-auto] [--mp-ticks=N] (a fixed CODE so a second process can join without reading stdout; prints [relay] code=XXXXX) and -- --mp-join-code=CODE; --mp-relay=<url> points both at another relay (e.g. wrangler dev; RelayTransport.url_override, deliberately not remotely settable); --mp-build=N makes a joiner claim BUILD N (provokes the build-mismatch kick). Same [lan] host|client t=.. sum=.. lines as LAN.

  • Test seams: _net_new_relay (a FakeRelayHub transport), _net_now (the session clock), _online_dev (a store build under GUT, which always runs as dev), _online_rng (predict the code). Headless without the relay seam the lobby opens but no socket (net_allowed, as LAN).

  • Tests: test_online_coop_main (two real main.tscn over one FakeRelayHub: host/join by code and 1,800 lockstep ticks, wrong code, code_taken retry, store gating, backgrounding, store banner, lobby hygiene), test_online_lobby, test_code_picker, test_online_coop_gate, test_relay_frames (golden bytes shared with the Worker), test_relay_transport (kick/REJECT injection, synthetic BYE, RTT, connect timeout), test_relay_session (LockstepSession/NetCoopRun end to end over the relay codec + hub rules). tests/fake_relay_hub.gd mirrors the DO’s admission/routing/kick/leave rules; tests/fake_relay_socket.gd is the RelaySocket double. Worker: relay/test/*.test.mjs (node --test, also in scripts/gate.sh).

  • Real-runtime check: scripts/relay-local-test.sh [ticks] [idle_s] runs the Worker under wrangler dev --local (workerd, the real DO runtime) with two headless Godot clients over a real WebSocket: idle lobby past 2 × HEARTBEAT_S, build-mismatch kick whose REJECT reason must survive the 4005, rejoin as peer 2, a 3,600-tick run with RTT measured and a level-up, and the host killed mid-run (joiner ends host_left within 1.5 s). Not in the gate (needs wrangler, ~2-3 min); run it before any device test of a relay change and before merge. RELAY_URL=wss://… runs the same checks against a deployed relay (post-deploy). Deploy: relay/deploy.sh (dev-tools.md § Internet co-op relay Worker).

Lockstep needs every device’s sim to produce identical Sim.net_checksum()s; the relay cannot fix a desync. The probe exists ({kind:"det_probe"}, net/determinism_probe.gd, described in the Determinism probe bullet in docs/architecture/dev-tools.md). This table is where cross-device results go (one BUILD installed on every device, compare status().det_probe.digest; digests from different BUILDs are not comparable):

Date BUILD Devices / engines Result
2026-10-11 (one Mac) Godot 4.7 vs 4.8, same Mac identical digest, 5 probes (the 4.8 A/B session; its sim-CPU cost is in docs/research/2026-10-11-godot-48-gpu-ab-and-fill-rate.md)
pending — Apple TV + iPhone not yet run (plan Task 0 Step 3: required before any relay device test; mismatch = stop, tell Chris)

InputRouter.poll_device(device: int) -> InputState returns REAL per-device dash/decoy edges (it was hardcoded false, false — the bug that stripped P1’s dash+drone the instant P2 joined local co-op). The tap-vs-hold decoy-decision + edge-latch state machine poll() runs for the primary input is mirrored per-device via device_edges(device, ...), keyed by device id.

Haptics (2026-10-05, CoreHaptics premium 2026-10-06)

Section titled “Haptics (2026-10-05, CoreHaptics premium 2026-10-06)”

Specs docs/superpowers/specs/2026-10-05-haptics-design.md (routing, director) and docs/superpowers/specs/2026-10-06-corehaptics-premium-design.md (patterns as data, native iPhone path); plans in docs/superpowers/plans/. Render/input-side only. The one /sim field it reads that exists for it is PlayerState.leech_healed (HP Leech actually restored this run, after the max_hp clamp; incremented in Sim._sweep_dead). It is render-read only and deliberately NOT in snapshot_string/state_checksum, so the determinism baselines do not move.

  • One file per id: data/haptics/<id>.json = {"ahap": <Apple AHAP dict>, "pad": [pulses]}. ahap is played by the native iPhone path; each pad pulse is {weak, strong, ms, at_ms[, min_ms]} for the two-motor fallback (a pulse with min_ms lasts max(min_ms, ms x scale), otherwise ms is fixed).
  • input/haptic_patterns.gd (HapticPatterns) loads them lazily: IDS, load_file, pad(id) (cached array, read-only), scaled_ahap(id, intensity, sharpness) (scales every event, clamps 0..1, snaps the scales to 0.01 so the extension’s parsed-pattern cache keeps hitting) and problems(dict). tests/test_haptic_patterns.gd runs problems() over every file, so a malformed pattern fails the gate, never a device.
  • The 13 ids and what fires each (all in input/haptics_director.gd; the numbers are consts there or in the pattern files, so read them there rather than here):
id Trigger
hit hp lost accumulates per pilot until it reaches HIT_FLOOR_FRAC of max_hp and the per-pilot HIT_COOLDOWN_MS has passed; strength scales with the accumulated loss
hit_heavy same trigger, when the accumulated loss is at least HIT_HEAVY_FRAC of max_hp (adds a rumble tail)
down the pilot’s hp reaches 0 (the killing frame plays only this and clears the hit accumulator)
level_up pending_levelups rises
evolve the pilot’s evolved-weapon count rises (_evolved_count: arsenal.weapon_by_id plus arsenal.aim, so Railspike counts). The sim emits no evolve event, so this is a diff
leech the rise in leech_healed accumulates to LEECH_FRAC of max_hp, at most every LEECH_EVERY_MS. Regen and pickups raise hp but not leech_healed, so they never play it
heartbeat a living pilot below HEARTBEAT_FRAC of max_hp, every HEARTBEAT_EVERY_S; suppressed while paused, while a boss wind-up is active, and at death (HEARTBEAT_* are live-tunable statics)
boss_windup Warden fire_charge (via BossWarden.render_info, copied onto haptics.boss_charge) strictly rising, re-armed every WIND_EVERY_MS; scale and iPhone sharpness follow the charge (WIND_*)
boss_kill a "BOSS DOWN" reaction in render_fx_events (all pilots)
nuke a "NUKE" reaction (all pilots)
warp dash_cd rises from <= 0 past DASH_EDGE (the sim confirmed the warp, so a press on cooldown is silent)
decoy drone_charge drops from >= 1.0 to below DECOY_SPENT
warp_ready dash_cd returns to 0; played with handheld_ok = false (the touch WARP arc already shows it), so pads only
  • input/haptics_director.gd (HapticsDirector, RefCounted owned by main): diffs each pilot’s hp, pending_levelups, dash_cd, drone_charge, leech_healed and evolved count frame to frame and calls output.play(pilot, id, ...). update() calls output.flush(now_ms) FIRST, then plays. Baselines are taken on a pilot’s first frame, cleared by reset() (new run / back to the menu), and refreshed even while muted or disabled so nothing bursts on re-enable. A pause edge stops all rumble; one-shots still fire on that frame (the level-up that opened the panel, the hit that ended the run). Attract mode and capture runs are muted.
  • input/haptics_output.gd (HapticsOutput): routes is pilot index -> {"joypads": [...], "handheld": bool}, rebuilt every frame by routes_for() to mirror input (a solo pilot rumbles EVERY playable pad; co-op pilot k rumbles playable[k]; P1 gets the phone/tablet vibrator only on a touch device with no pad).
  • HapticsOutput.play(pilot, id, scale, handheld_ok, sharp) routing: a handheld route on an iPhone with a Taptic Engine (HapticsOutput.native set) -> DcHaptics.play_ahap(scaled_ahap(...)), last_path = "native"; everything else (pads, Android phones, iPad, an iPhone without the extension) -> pad pulses, the first sent at once and later ones queued against now_ms and sent by flush(), last_path = "pad". last_path (“native” / “pad” / “none”) is shown by the Haptic Lab row.
  • The director owns the WARP and DRONE buzz on every route, the phone included. TouchControls no longer taps for WARP (so it cannot double with the native pattern); the one tap it keeps is fire_bay (the Drone Bay hold), gated on HapticsOutput.enabled.
  • Gains: HapticsOutput.INTENSITY (everything, pad motors too), SHARPNESS (AHAP only) and one <ID>_INTENSITY per pattern (id_gain). All are Remote Console tunables (net/tunables_registry.gd, labels in tools/tunable_docs/docs.d/haptics.json). Workflow: tune live, bake the result into data/haptics/<id>.json, put the per-id gain back to 1.
  • One switch: HapticsOutput.enabled, written by the pause-menu Haptics button, the Debug Settings “Haptics” row and the console touch:haptics key; main._sync_haptics_pref() persists it to meta.haptics and stops rumble when it goes off. It short-circuits play(), flush() (drops the queue) and covers native too.
  • Haptic Lab: Debug Settings row + the console picker send the dev command haptic_play (main.gd), which calls haptics.output.play(0, id) on pilot 1’s devices exactly as gameplay would.
  • Lifecycle: main calls HapticsOutput.init_native() at boot (after enabled is loaded). It looks up the DcHaptics singleton via Engine.has_singleton/get_singleton, checks supports_haptics(), calls prepare() and only then sets native. stop_all() stops the routed pads and calls native.stop_all(), which stops playing players only and leaves the CHHapticEngine running; the extension’s stopped/reset handlers own restarts after iOS backgrounding or an audio-session change. main._notification calls it on NOTIFICATION_APPLICATION_PAUSED and ..._FOCUS_OUT.
  • Android: both Android presets set permissions/vibrate=true; tests/test_release_presets.gd pins it.
  • Tests: tests/haptics_spy.gd (HapticsSpy) records plays and overrides the _joy/_handheld/_joy_stop seams (Input vibration is a silent no-op headless); a fake DcHaptics singleton object assigned to HapticsOutput.native tests the native call shape.

DcHaptics native extension (platform/tvos/addons/dc_haptics/)

Section titled “DcHaptics native extension (platform/tvos/addons/dc_haptics/)”

A small Objective-C++ GDExtension (src/, SConstruct, dc_haptics.gdextension, all tracked) wrapping CoreHaptics: supports_haptics(), prepare(), play_ahap(json) (parsed patterns cached by JSON string), stop_all(). iOS only: the .gdextension declares iOS (xcframework) and macOS (dylib, so the macOS editor that runs the iOS export loads it; supports_haptics() is false there).

  • build.sh builds bin/ and out/ (both gitignored). godot-cpp is pinned by tag godot-4.5-stable AND commit sha (no 4.7 tag exists), cached outside the repo (BH_GODOT_CPP_CACHE, default ~/Library/Caches/bh-godot-cpp), needs scons; iOS min 15.0. A bin/.stamp (hash of sources, script and pin) makes a repeat run a no-op. scripts/release/build-ios.sh and scripts/dev/install-dev.sh run it.
  • Every non-iOS preset (tvOS, tvOS-Dev, Android, Android-Dev, macOS) excludes addons/dc_haptics/*; tests/test_release_presets.gd pins it.
  • The pack-only-shell guard: --export-pack (what install-dev.sh does) never adds a new GDExtension to the existing Xcode shell, so scripts/release/sentry-guard.sh ios-shell fails (naming the full --export-release "iOS") when addons/dc_haptics/dc_haptics.gdextension exists but the shell’s project does not embed libdc_haptics. Tested by scripts/test/sentry-guard.test.mjs.
  • GDScript reaches it ONLY through Engine.get_singleton("DcHaptics") + .call(): the root project has no such class (it does not exist on web, tvOS, Android, or in the editor), so never name it directly.
  • iPhone (native): every pattern from the Haptic Lab plays, the row says native, no console errors; haptics survive backgrounding and returning; Haptics Off silences native too.
  • iPhone in play: hit vs heavy hit feel different; heartbeat below 25% hull, stops on pause / during a Warden wind-up / at death; Leech pulses are soft and not constant; evolution flourish; NUKE thump; WARP/DRONE feel crisp (native), never double, and a press on cooldown is silent.
  • Apple TV + Xbox pad (pad fallback): the four new events plus the warp-ready tick are felt and distinct; the heartbeat is not annoying (else tune HapticsDirector.HEARTBEAT_*).
  • iPad (no Taptic Engine): the Lab row says pad; no DcHaptics errors.
  • Mac + pad (SDL): same events; keyboard-only play does nothing and logs no errors.
  • Co-op (2 pads): each pilot’s hits buzz only their own pad; BOSS DOWN buzzes both.
  • OPEN: Android pad rumble is unverified (engine path not checked on device).

Controller disconnect auto-pause (2026-10-16)

Section titled “Controller disconnect auto-pause (2026-10-16)”

PadDropWatch (input/pad_drop_watch.gd) caches which joypads are playable in connect order (= the pilot mapping; Godot clears the name before a drop, so the Siri Remote is filtered at connect time). main._pad_drop_pause pauses through _toggle_pause() (no sim mutation) and shows PadDropWatch.NOTICE on the pause menu. Rules: local co-op pauses when a pad holding a pilot slot drops; one local pilot (incl. every networked run) only when the DROPPED pad drove this run (InputRouter.pad_driven), or on tvOS when it was the last playable pad. A drop behind a level-up/dimension/dialogue/codex/Bay is held (_pad_drop_pending) and pauses once _joy_live(). The notice clears only when the dropped pad returns (or a newcomer starts driving); a reconnect never unpauses. Networked runs follow the normal pause rules (a host pause holds the session). Test: tests/test_pad_disconnect_pause.gd; capture scene pause_pad.