Bullet Heaven — co-op, pilots, drones
Bullet Heaven — co-op, pilots, drones
Section titled “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/player2are thin compatibility accessors (player2returnspilots[1]or null) —pilotsis the real backing array ([player]in single-player,add_pilot(offset)appends more;add_player2()is a one-line wrapper)._nearest_pilot(from: Vector2) -> PlayerStateis the query every enemy/spawn/boss-targeting site uses instead of hardcodingplayer.pos. It short-circuits to return the sole pilot unconditionally whenpilots.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 offSim. Every oldSim-level weapon field (sim.weapon,sim.active_weapon_ids, etc.) is now a forwarding accessor intoplayer.arsenal. - Every
Weapon*.update()takes an explicitpilot: PlayerStatefirst param (notsim.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()derivesinput/input2frominputs[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 needstick()unified into onefor pilot in pilotsloop — flagged inline at theintegrate()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) plussim/sim.gd’s crystal-threshold auto-upgrade system take an explicitpilot: PlayerStateparam throughout, matching theWeapon*.update(pilot, ...)convention. P2 progresses independently.main.gdscans all pilots forpending_levelups > 0and chains level-up panels sequentially (P1 first on a simultaneous level-up; sim stays paused for both).ui/level_up_panel.gdshows 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”DroneStatehas anowner: PlayerStatefield;drone_chargelives onPlayerState(Sim keeps a P1-only forwarding accessor).sim.dronesstays 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_inputspattern. - 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, andStasisWeb.update_pilotruns per living pilot with its ownstasis_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 ofstate_checksum()(adds the 4 RNG stream positions viaSeededRng.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) withLoopbackTransport(in-memory pair, latency/jitter/drop counted in receiver polls) andENetTransport(ENetMultiplayerPeeras a raw packet peer, never onSceneTree.multiplayer, no@rpc; channel 1 reliable rows, channel 2 unreliable-sequenced inputs). Proven headless on 127.0.0.1 bytests/test_enet_transport.gd.- The loopback WAN profile under-stalls real links.
tests/test_lockstep_wan.gdpins 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 fromrtt_ms; now 0 late on clean links, the rest under loss/spikes is lost input packets).LoopbackTransport.make_wan_pairreports its mean RTT viartt_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 ondequantize(q), never the rawInputState(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 fromtransport.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): everyMSG_INPUTcarries the client’s cumulative u8 dash/decoy press counters; on the host the counter alone decides how many edges apply (wrap-awareLockstepProtocol.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_deltareads 128+ as behind).edges_applied/decoys_appliedcount per pilot;tests/test_lockstep_wan.gdreportsedges=applied/sent lost=.net_checksumrides every 30th row; a mismatch ends both sessions withdesync_tick+desync_snapshot. 5 s silence: host downs the client’s pilot (CMD_PILOT_LEAVE, the only command so far), client endshost_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 callsmark_ready()(NetCoopRun.mark_ready, main: last line of_start_net_run); a client sendsMSG_READYand, until then, only services the link and sends aMSG_PINGevery 0.5 s (no inputs, no rows applied). The host issues no TICK row until it AND every client are ready:step()goes down theadvance = falseHOLD path (the tick-0FLAGSstill goes out as a hold row). A not-ready client is exempt from the 5 s silence timeout and is instead droppedLockstepSession.READY_TIMEOUT_MS(30000, a “local” tunable) after the host started, asdrop_reason = "ready_timeout"(PILOT_LEAVE+ adropped: ready_timeoutBYE, so the client endshost_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_rungainsready_wait_ms(both roles) anddrop_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 fromcreate_*until the barrier opens the session callstransport.set_link_timeout(READY_TIMEOUT_MS)(ENetTransport:ENetPacketPeer.set_timeout(32, ms, max(ms, 30000))on every peer, also applied on connect) andset_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) andtests/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
/simhas 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_REQis 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 DevFlagstackler_spawns,suppressor_spawns,web_drone_grant,hard_tuning),RESUME(post-level-up iframes on every pilot).net_checksumnow also covers those four flags and each pilot’s level, active weapon ids and stasis-web level.state_checksumuntouched, 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 whatmain.gddrives. Networked runs are Survival, no areas/dimension choice, no meta shop or hull, remote-config defaults (apply_for_run({}), theconfig_hashisRemoteConfig.active_hash). Level-ups pause everyone (as local co-op): the host rolls for the lowest-index pilot withpending_levelups, holds ticks until that pilot’s device picks (auto-pick withSkilledAutoPlayer.pick_upgrade_forafter 15 s), then sendsRESUME. The host sendsFLAGSat tick 0 and whenever the mask changes.net/lan_beacon.gd(LanBeacon): UDP broadcastDCLAN1|build|port|nameon 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 onhost_timeout(_net_join_queue). Worker unreachable = the old beacon + typed-IP behaviour. The console status carrieshosting: {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 onJITTER_ROWS + 1queued 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 ascushion_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 itsMSG_REJECTand is thenNetTransport.disconnect_peered, and any connected peer with no valid HELLO withinLockstepSession.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_peerusesENetPacketPeer.peer_disconnect_later():ENetMultiplayerPeer.disconnect_peer()resets ENet’s outgoing queue and LOSES the queued REJECT (proved bytest_disconnect_peer_delivers_the_last_packet_and_frees_the_only_slotfailing with it). A dead remote never acks that, so the session force-disconnects a refused peer (disconnect_peer(id, true)=peer_disconnect_now()) oneHELLO_DEADLINE_MSafter its REJECT, freeing the slot without waiting out ENet’s ~30 s timeout. The session emitspeer_rejected(reason)in host-facing words (“A player tried to join with BUILD 281 (this is 282)”); main shows it viaLanLobby.show_host_alertunder the code and IPs. main.gdLAN section (_lan_*/_net_*): Debug Settings rows “Host LAN co-op” / “Join LAN co-op” (dev builds only) and the Remote Consolelan_coopcommand (host / join ip|code / leave) open the lobby; the host starts the run when a peer joins. In a net run_physics_processruns_net_physicsinstead ofsim.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 throughNetCoopRun.pick. Refused during a net run: every dev command outsideNET_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 touser://net-desync.txt). CLI for two Mac instances:godot --path . -- --mp-host [--mp-auto] [--mp-ticks=N]andgodot --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 realmain.tscninstances 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:
NSLocalNetworkUsageDescriptionin 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 entitlementcom.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
NSLocalNetworkUsageDescriptionstring is shown); an unsandboxed dev build needs no entitlement. Sandboxed builds would needcom.apple.security.network.server+.client. - Android:
INTERNET(already present) andCHANGE_WIFI_MULTICAST_STATEfor 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.
Internet co-op (relay, Phase 4)
Section titled “Internet co-op (relay, Phase 4)”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’swelcomesays so), the joiner the lowest free relay SLOT in 2..MAX_MEMBERS(= 2). The relay reuses slots, butRelayTransportgives 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 forceddisconnect_peerof an old id is a no-op (that id is gone), never a kick of the new joiner.set_link_timeoutis a no-op too (the relay has no link timeout of its own). -
RTT:
RelayTransportsends PING[2][peer][nonce][sent_ms]to each peer at once on join, then everyPING_MS(1000); the peer’s transport answers PONG, both forwarded end to end through the DO, sortt_ms()(EWMA,RTT_ALPHA0.25) is the full peer-to-peer RTT that_lead_ticksinput stamping needs; -1 until the first PONG. At mostMAX_PONGS_PER_WINDOW(4) PONGs per peer perPING_MSare 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
LockstepProtocolmessage so the session ends at once instead of after its 5 s / 30 s silence timeout:host_left(4010) or aleaveof peer 1 → BYEleft; any other loss after the welcome → BYEconnection_lost(the banner must not claim the friend left); a kick (4005) → a synthetic REJECT. Before the welcome a loss isfailure()=connect_failed(CONNECT_TIMEOUT_MS8000 also gives up on a stalled connect). -
Kick (host refusing a joiner):
disconnect_peersends CTRL{"t":"kick","peer":N,"why":<the last REJECT reason sent to N>}; the DO closes that socket 4005 withwhyas 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 readsrejected: 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_failedorconnection_lost; it rides thelan_runevent asrelay_failurewithtransport = "relay"(docs/architecture/dev-tools.md§ Telemetry v2).connect_limitedonly happens before the welcome, and a session that was never welcomed sends nolan_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 4029connect_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 withfeatures.internet_coop == truein the accepted remote-config patch.main._online_allowed()is read when the start menu is BUILT (StartMenu.online_allowed, set beforeadd_child), so a flip reaches a player at the next menu build (boot seeds features from the cached patch)._online_openre-checks it, so nothing opens a lobby while the gate is shut. -
Flow: the start menu’s
CO-OP ONLINEaction row (after the mode rows) emitsonline_requested→_online_open():_lan_close(), shipped defaults (apply_for_run({}), as LAN), anOnlineLobbyin_online_lobby, the start menu hidden under it (restored by_lan_closeon cancel). HOST →_online_host_attempt: aRelayTransport.random_code(_online_rng)room,NetCoopRun.create_host, the code shown big;code_takenretries 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) showsOnlineCoop.failure_text(end_reason)and reports onelan_runreasonlobby; 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 nohost_timeoutfollows and the text is not re-applied every frame. A session that ended before its relay was ever welcomed (a joiner’s 5 shost_timeoutbeats the relay’s 8 sCONNECT_TIMEOUT_MSon a stalled connect) reads asconnect_failedand reports nothing. A host’s relay loss before anyone joined showsOnlineCoop.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 showsOnlineCoop.host_refused_textin 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 sOnlineCoop.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_PAUSEDduring a running online session →NetCoopRun.close("backgrounded")(BYE; the host drops the pilot asleftand plays on),mark_ended("backgrounded"), onelan_runreasonbackgrounded.FOCUS_OUTnever 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=Nmakes 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 realmain.tscnover 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/NetCoopRunend to end over the relay codec + hub rules).tests/fake_relay_hub.gdmirrors the DO’s admission/routing/kick/leave rules;tests/fake_relay_socket.gdis theRelaySocketdouble. Worker:relay/test/*.test.mjs(node --test, also inscripts/gate.sh). -
Real-runtime check:
scripts/relay-local-test.sh [ticks] [idle_s]runs the Worker underwrangler 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 endshost_leftwithin 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).
Cross-device determinism
Section titled “Cross-device determinism”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) |
Co-op input
Section titled “Co-op input”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.
Patterns are data
Section titled “Patterns are data”- One file per id:
data/haptics/<id>.json={"ahap": <Apple AHAP dict>, "pad": [pulses]}.ahapis played by the native iPhone path; eachpadpulse is{weak, strong, ms, at_ms[, min_ms]}for the two-motor fallback (a pulse withmin_mslastsmax(min_ms, ms x scale), otherwisemsis 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) andproblems(dict).tests/test_haptic_patterns.gdrunsproblems()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 |
Director, routing and output
Section titled “Director, routing and output”input/haptics_director.gd(HapticsDirector, RefCounted owned bymain): diffs each pilot’shp,pending_levelups,dash_cd,drone_charge,leech_healedand evolved count frame to frame and callsoutput.play(pilot, id, ...).update()callsoutput.flush(now_ms)FIRST, then plays. Baselines are taken on a pilot’s first frame, cleared byreset()(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):routesis pilot index ->{"joypads": [...], "handheld": bool}, rebuilt every frame byroutes_for()to mirror input (a solo pilot rumbles EVERY playable pad; co-op pilot k rumblesplayable[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.nativeset) ->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 againstnow_msand sent byflush(),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.
TouchControlsno longer taps for WARP (so it cannot double with the native pattern); the one tap it keeps isfire_bay(the Drone Bay hold), gated onHapticsOutput.enabled. - Gains:
HapticsOutput.INTENSITY(everything, pad motors too),SHARPNESS(AHAP only) and one<ID>_INTENSITYper pattern (id_gain). All are Remote Console tunables (net/tunables_registry.gd, labels intools/tunable_docs/docs.d/haptics.json). Workflow: tune live, bake the result intodata/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 consoletouch:hapticskey;main._sync_haptics_pref()persists it tometa.hapticsand stops rumble when it goes off. It short-circuitsplay(),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 callshaptics.output.play(0, id)on pilot 1’s devices exactly as gameplay would. - Lifecycle:
maincallsHapticsOutput.init_native()at boot (afterenabledis loaded). It looks up theDcHapticssingleton viaEngine.has_singleton/get_singleton, checkssupports_haptics(), callsprepare()and only then setsnative.stop_all()stops the routed pads and callsnative.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._notificationcalls it onNOTIFICATION_APPLICATION_PAUSEDand..._FOCUS_OUT. - Android: both Android presets set
permissions/vibrate=true;tests/test_release_presets.gdpins it. - Tests:
tests/haptics_spy.gd(HapticsSpy) records plays and overrides the_joy/_handheld/_joy_stopseams (Inputvibration is a silent no-op headless); a fakeDcHapticssingleton object assigned toHapticsOutput.nativetests 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.shbuildsbin/andout/(both gitignored). godot-cpp is pinned by taggodot-4.5-stableAND commit sha (no 4.7 tag exists), cached outside the repo (BH_GODOT_CPP_CACHE, default~/Library/Caches/bh-godot-cpp), needsscons; iOS min 15.0. Abin/.stamp(hash of sources, script and pin) makes a repeat run a no-op.scripts/release/build-ios.shandscripts/dev/install-dev.shrun it.- Every non-iOS preset (tvOS, tvOS-Dev, Android, Android-Dev, macOS) excludes
addons/dc_haptics/*;tests/test_release_presets.gdpins it. - The pack-only-shell guard:
--export-pack(whatinstall-dev.shdoes) never adds a new GDExtension to the existing Xcode shell, soscripts/release/sentry-guard.sh ios-shellfails (naming the full--export-release "iOS") whenaddons/dc_haptics/dc_haptics.gdextensionexists but the shell’s project does not embedlibdc_haptics. Tested byscripts/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.
On-device feel check (owed, Chris)
Section titled “On-device feel check (owed, Chris)”- 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; noDcHapticserrors. - 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.