Internet co-op relay (Phase 4) — implementation plan
Internet co-op relay (Phase 4) Implementation Plan
Section titled “Internet co-op relay (Phase 4) 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. In this repo also read skillbh-sdd-concurrentbefore Task 1 and runsdd-worktree-leak-guardafter every dispatch. Implementer subagents:bh-implementer,model: "opus".
Goal: Two devices on different networks (ATV at home + iPhone on cellular) play a full Survival co-op run through a Cloudflare Durable Object relay, joined by a 5-character room code, with RTT and stall counts landing in telemetry; shipped behind a remote-config gate that is OFF for every store player.
Architecture: A new, storage-free Worker bullet-heaven-relay routes wss://…/mp/room/<CODE> to one RelayRoom Durable Object per code (WebSocket Hibernation API). The DO is a dumb ordered fan-out with membership, a per-message size cap, a per-socket rate cap and host-leave handling; a Workers rate-limit binding caps connects per IP. On the client a new RelayTransport implements the existing NetTransport interface over WebSocketPeer, wrapping the UNCHANGED LockstepProtocol bytes in a 2-byte relay frame, so LockstepSession / NetCoopRun / main._net_physics run exactly as on LAN. A new OnlineLobby (room code display + controller/remote/touch code picker) and a start-menu row appear only when OnlineCoop.enabled() (Dev build, or remote-config features.internet_coop == true).
Tech Stack: Godot 4.7/4.8 typed GDScript (warnings as errors), GUT; Cloudflare Workers + Durable Objects (plain JS ES modules, zero npm deps), node --test; wrangler (dev + deploy only).
Spec: docs/superpowers/specs/2026-10-07-networked-coop-design.md (§4 Transport, §6 Internet DO relay, Phases table row 4, Chris calls 1-9 accepted). Read it with the Spec vs live code section below: the spec predates Phases 0-3 and is stale in places.
Global Constraints
Section titled “Global Constraints”- Determinism baselines must not move. No task in this plan edits any file under
/sim. Every task ends by runningtests/test_determinism_survival.gd,tests/test_determinism.gd,tests/test_determinism_coop.gd(viascripts/check-test-count.sh); a moved baseline is a bug in that task, never a re-pin. - The relay stores nothing. No D1 / KV / R2 / Analytics Engine / Queues binding in
relay/wrangler.toml; noctx.storage/this.ctx.storage/state.storageanywhere inrelay/src; noconsole.*call inrelay/src;[observability] enabled = false. Enforced byrelay/test/no-storage.test.mjs(Task 2), not by review alone. Consequence: the relay itself does not change the privacy page or the ASC label (the existinglan_runevent and the “dev builds only” multiplayer wording DO change before the gate opens for non-Dev players: Task 12). - Same message bytes as LAN.
LockstepProtocol.PROTOCOL_VERSIONstays 4 andnet/lockstep_protocol.gdis not edited. Relay framing is a separate envelope with its own versionRELAY_V = 1. /simis untouched;net/andui/changes are render/net side. Time innet/code is injected (now_ms/ aclock_msCallable) so GUT drives it exactly.- Gate: store builds (
BuildConfig.dev_tools() == false) never show or open online co-op unless the fetched remote-config patch carries"features": {"internet_coop": true}. Dev builds always may. Kill switch{}turns it off. - Pilots: 2 (spec Chris call 9;
LockstepSession.MAX_PILOTS,NetCoopRun.PILOTS). The relay’sMAX_MEMBERS = 2. - Room codes: 5 chars from
ABCDEFGHJKMNPQRSTUVWXYZ23456789(=LanRegistry.CODE_ALPHABET=control/src/mp.js MP_CODE_ALPHABET; a test pins all three equal). - Worker deploy before any client build that points at it. No client build may be installed on a device with
RelayTransport.DEFAULT_URLpointing at a Worker that is not live (Task 13 sequence). - Do not: merge to main without
docs/reference/builds-and-releases.md+bh-merge-reviewed-branch; runbump-build.shinside a task; deploy from a task; editplatform/tvos/export_presets.cfg. - GDScript rules: annotate
Dictionary.get()results asVariant; every newdocs/**/*.mdneeds Starlighttitle:frontmatter; after any merge that addsclass_names rungodot --headless --path . --importandgit checkout -- project.godot audio/bus_layout.tres. - Copy: player-facing lobby text is plain English, no em-dashes (it may be quoted in store copy later).
Spec vs live code (verified 2026-10-14 against c80fca8e)
Section titled “Spec vs live code (verified 2026-10-14 against c80fca8e)”| # | Spec says | Live code | Consequence for this plan |
|---|---|---|---|
| 1 | §Current state: “No multiplayer code on main” | Phases 0-3 shipped: NetTransport/LoopbackTransport/ENetTransport, LockstepSession (protocol v4), NetCoopRun, SimCommands, Sim.net_checksum(), DeterminismProbe, dev LAN lobby |
Phase 4 is only transport + lobby + gate + Worker. |
| 2 | §4 message table: hello carries per-pilot setup; welcome carries setup + start tick |
MSG_HELLO = protocol, build, config_hash only; MSG_WELCOME = pilot, pilot_count, seed. Extra messages exist: MSG_PING, MSG_CMD_REQ, MSG_READY (start barrier), input press counters |
Relay must forward 9 message types it never inspects. No per-pilot meta/hull: net runs are bare Survival (NetCoopRun.build_sim). |
| 3 | Phase 4 lists “room codes” and “relay-assisted LAN discovery” as new | Already built (2026-10-07 easy join): control Worker /mp/host, /mp/nearby, /mp/code/<C> with D1 mp_hosts, 5-char alphabet, LanRegistry, LanLobby code box |
Reuse the alphabet. NOT reusable for store builds: every control route needs CONTROL_KEY, which ships only in Dev builds (net/control_client.gd). Relay-assisted LAN discovery is therefore already done for Dev and stays out of scope. |
| 4 | §6: “the 3-tick input redundancy is not needed on this path” | Input stamping (LockstepSession._lead_ticks) now depends on transport.rtt_ms(); ENet measures it, a WebSocket does not |
RelayTransport must measure end-to-end RTT itself (relay PING/PONG frames forwarded peer-to-peer), else every client input lands late. loss_pct() = -1 (TCP). Redundancy stays (harmless bytes). |
| 5 | §3: desync via EventQueue.push("net_desync", …) |
No net_desync event. One lan_run event per session (main._lan_report_session) with transport, RTT last/mean/max, loss, stalls, cushion/pause waits, ticks, end/drop reason, desync snapshot |
Phase 4 telemetry is mostly free: transport = "relay" + a working rtt_ms(). One new field, relay_failure (Task 10). |
| 6 | §6/§2: session config parity via hashes; tunables “must match” | main._lan_open calls remote_config.apply_for_run({}): every networked run plays on bundled defaults |
A gate implemented as a registered bool tunable would be RESET to false the moment a lobby opens. The gate must be a top-level patch key read like notice (Task 6). Also: a published balance patch never applies to online runs (Chris decision C). |
| 7 | §5: no NSLocalNetworkUsageDescription |
Added via the release/deploy Info.plist patch (bh-appstore-release, bh-deploy skills) |
Irrelevant to the relay (WebSocket to the internet needs no local-network permission). |
| 8 | Phase 0 “probe table filled in” | det_probe exists; the cross-DEVICE digest table has never been filled (only 4.7 vs 4.8 on one Mac) |
Task 0 precondition: ATV + iPhone digests on one BUILD before any relay device test. |
| 9 | Phase 4: “web demo can join” | Web (WASM) determinism unmeasured | Out of scope (below). |
| 10 | §6 cost assumes Workers Paid | Account plan not verified here; chess-moba precedent deploys a DO on the free plan with new_sqlite_classes |
relay/wrangler.toml uses new_sqlite_classes (works on both). |
| 11 | Privacy: “if it stores nothing, page and label unchanged” | True for the relay, but the public page and marketing/appstore/compliance.md currently say networked co-op “is not in the App Store or TestFlight versions” and lan_run is “dev builds only” |
Those sentences become false the moment the gate opens for TestFlight/App Store. Task 12 rewrites them; flipping the gate is blocked on it. |
| 12 | docs/architecture/coop-and-pilots.md “client has no jitter buffer yet”, “cross-device determinism probe missing” |
JITTER_ROWS cushion and det_probe both exist |
Fixed in Task 14 docs pass. |
File structure
Section titled “File structure”relay/ NEW Worker, mirrors telemetry/ + control/ layout (zero npm deps) wrangler.toml DO binding ROOM -> RelayRoom, sqlite migration, ratelimit binding, NO storage package.json { "private": true, "type": "module" } README.md endpoints, frame format, close codes, deploy, "stores nothing" deploy.sh guarded deploy (tests + no-storage check first); written, NOT run src/room_core.js pure: frame parse/encode, membership, routing, token bucket, close codes src/room.js RelayRoom DO glue (Hibernation API, attachments) src/worker.js router: /mp/room/<CODE>, /health; per-IP connect limit test/room-core.test.mjs pure logic test/room.test.mjs DO glue against fake ctx / fake WebSockets test/worker.test.mjs router: code/role/version validation, 426, 429, forwarding to DO test/no-storage.test.mjs structural "stores nothing" + alphabet parity test/frames.test.mjs golden frames (shared with GUT)tests/fixtures/relay_frames.json golden relay frames: hex + decoded (read by node AND GUT)net/relay_frames.gd RelayFrames: GDScript codec for the same framesnet/relay_socket.gd RelaySocket: the socket seam (interface)net/ws_relay_socket.gd WsRelaySocket: RelaySocket over WebSocketPeernet/relay_transport.gd RelayTransport extends NetTransportnet/online_coop.gd OnlineCoop: the gate + store-safe end/status textnet/remote_config.gd + `features` (top-level patch key, like notice)net/remote_config_state.gd valid(): `features` must be a Dictionarytools/remote_config/dump_manifest.gd manifest gains `features: ["internet_coop"]`scripts/publish-config.mjs validates `features`tests/fake_relay_hub.gd FakeRelayHub: in-memory DO stand-in for GUTtests/fake_relay_socket.gd FakeRelaySocket (own file: one class_name per file)tests/test_relay_frames.gdtests/test_relay_transport.gdtests/test_relay_session.gd LockstepSession/NetCoopRun end to end over RelayTransporttests/test_online_coop_gate.gdtests/test_code_picker.gdtests/test_online_lobby.gd incl. fit at tv / iphone / ipadtests/test_online_coop_main.gd two real main.tscn over FakeRelayHubui/code_picker.gd CodePicker: 5-slot code entry for pad / Siri Remote / touch / keyboardui/online_lobby.gd OnlineLobby: host (big code) / join (picker) / failure textui/start_menu.gd build_items(..., online) rowmain.gd _online_* glue, CLI flags, background handling, relay_failure in lan_runscripts/relay-local-test.sh wrangler dev + two headless Godot clients (manual / pre-device)docs/architecture/coop-and-pilots.md, docs/architecture/dev-tools.md, docs/architecture/remote-config.md,marketing/appstore/compliance.md docs (Task 12, 14)Wire format (single source of truth; Tasks 1, 3, 4 implement it)
Section titled “Wire format (single source of truth; Tasks 1, 3, 4 implement it)”Every WebSocket message is binary. Byte 0 = frame kind:
| kind | name | layout | direction | relay action |
|---|---|---|---|---|
| 0 | CTRL | [0][utf8 JSON] |
both | interpreted by the DO / transport |
| 1 | DATA | [1][u8 peer][payload…] |
both | forwarded; client→DO peer = destination, DO→client peer = source |
| 2 | PING | [2][u8 peer][u32 nonce LE][u32 sent_ms LE] |
both | forwarded like DATA |
| 3 | PONG | [3][u8 peer][u32 nonce LE][u32 sent_ms LE] |
both | forwarded like DATA |
DATA payload = one LockstepProtocol message, byte-identical to what ENetTransport sends.
CTRL from DO: {"t":"welcome","you":<id>,"host":1,"peers":[<ids>]} (first frame on every accepted socket), {"t":"join","peer":<id>}, {"t":"leave","peer":<id>}. CTRL from a client: {"t":"kick","peer":<id>,"why":"<reason>"} (host only; ignored from others). why is optional: the host’s last REJECT reason to that peer; the DO closes the kicked socket 4005 with close reason = why sanitised (printable ASCII, ≤ 123 B; absent/empty/invalid → kicked), because Godot’s WebSocketPeer discards frames received in the same poll as the close, so the forwarded REJECT itself can be lost. The client turns the 4005 close reason into a synthetic REJECT (Task 4 fix round 1). Anything else: ignored.
Peer ids: host = 1 (NetTransport.HOST_PEER), joiners = lowest free id in 2..MAX_MEMBERS.
Limits: MAX_FRAME = 4096 bytes (a hold row with 8 max-length commands is 2,102 bytes with its checksum, 2,104 framed; Task 3 pins this), client CTRL ≤ 128 bytes, per-socket token bucket BUCKET_CAP = 600 (a burst of ~10 s of traffic: TCP flushes a stalled uplink at once, and the session survives a 5 s stall; was 300 at plan time), BUCKET_REFILL_PER_S = 180 (host sends 60 rows/s, client 60 inputs/s, plus 1 ping/s and keepalives), per-IP connects 30 / 60 s (Workers rate-limit binding; generous because cellular CGNAT puts many players behind one IP; over it the Worker closes the upgraded socket 4029, below).
URL: GET /mp/room/<CODE>?role=host|join&v=1 with Upgrade: websocket.
Close codes (the DO accepts then closes, so WebSocketPeer.get_close_code() sees them):
| code | name | when | client failure string |
|---|---|---|---|
| 4001 | code_taken | role=host and the room already has a host |
code_taken (host retries a new code) |
| 4003 | room_full | MAX_MEMBERS already connected |
room_full |
| 4004 | no_room | role=join and no host |
no_room |
| 4005 | kicked | host sent kick for this peer |
kicked |
| 4008 | rate_limited | bucket empty | rate_limited |
| 4009 | too_big | frame > MAX_FRAME, or empty, or CTRL > 128 B | too_big |
| 4010 | host_left | host socket closed: every other socket closed | host_left |
| 4029 | connect_limited | the Worker’s per-IP connect limit: the Worker accepts the upgrade and closes it at once (no DO, no room) | connect_limited |
| 4400 | bad_request | bad code / role / v (unknown relay version) |
bad_request |
HTTP-level refusals (no socket): 404 unknown path, 400 bad code/role/v, 426 no Upgrade: websocket. A client sees these as a failed handshake (connect_failed). The per-IP limit is NOT an HTTP 429 (planned here at first): a 429 would read as connect_failed (“check your internet connection”, the wrong advice), so it is the 4029 close above.
Task 0: Preconditions (no code)
Section titled “Task 0: Preconditions (no code)”Files: none.
- Step 1: Branch.
git fetch origin && git worktree add .claude/worktrees/coop-relay -b coop-relay origin/main. All tasks run in that worktree (absolute path to implementers, no Agentisolation: "worktree"on top). - Step 2: Re-verify the “Spec vs live code” table rows 1, 2, 4, 6 against
origin/main(net code moves fast). IfPROTOCOL_VERSIONis no longer 4 orNetTransportgained methods, update Task 4’s interface list before dispatching it. - Step 3: Cross-device determinism, ATV + iPhone. Install ONE Dev BUILD on both (
bh-deploy), run{kind:"det_probe"}from the console on each at the start menu, comparestatus().det_probe.digest. Equal → proceed. Different → STOP: the relay cannot fix a desync; report to Chris (spec Chris call 8). - Step 4: Record the Cloudflare account’s Workers plan (
npx wrangler whoamiwithCLOUDFLARE_ACCOUNT_IDset, per~/.claude/gotchas/cloudflare.md§ Wrangler). Free plan: DO +new_sqlite_classesdeploy fine; note the free DO request quota inrelay/README.md(Task 13).
Task 1: Relay pure core (relay/src/room_core.js)
Section titled “Task 1: Relay pure core (relay/src/room_core.js)”Files:
- Create:
relay/package.json,relay/src/room_core.js,relay/test/room-core.test.mjs - Modify:
scripts/gate.sh(node step file list)
Interfaces:
-
Produces (JS, ES module
relay/src/room_core.js):- consts
CODE_ALPHABET,CODE_LEN = 5,RELAY_V = 1,MAX_MEMBERS = 2,MAX_FRAME = 4096,MAX_CTRL = 128,BUCKET_CAP = 600(300 at plan time),BUCKET_REFILL_PER_S = 180,KIND = {CTRL:0, DATA:1, PING:2, PONG:3},CLOSE = {CODE_TAKEN:4001, ROOM_FULL:4003, NO_ROOM:4004, KICKED:4005, RATE:4008, TOO_BIG:4009, HOST_LEFT:4010, CONNECT_LIMITED:4029, BAD_REQUEST:4400} normCode(v) -> string|nullparseRoomRequest(url: URL) -> {code, role, v} | {error: 'bad_code'|'bad_role'|'bad_version'}admit(members: Array<{peer, role}>, role) -> {ok: true, peer} | {ok: false, close: number, reason: string}route(sender: {peer, role}, frame: Uint8Array, members) -> {sends: Array<{peer, bytes: Uint8Array}>, close?: {code, reason}, kick?: number}onLeave(leaver: {peer, role}, remaining) -> {sends: Array<{peer, bytes}>, closeAll?: {code, reason}}ctrl(obj) -> Uint8Array,welcomeFor(peer, members) -> Uint8ArraytakeToken(bucket: {tokens, at} | undefined, nowMs) -> {ok: boolean, bucket}
- consts
-
Step 1: package.json + gate wiring
relay/package.json:
{ "private": true, "type": "module", "scripts": { "test": "node --test" } }scripts/gate.sh, node step: change the file list to
if node --test control/test/*.test.mjs scripts/test/*.test.mjs telemetry/test/*.test.mjs relay/test/*.test.mjs > "$log/node.txt" 2>&1; thenand the step title to "node tests (remote panel + scripts + telemetry Worker + relay Worker)", plus the header comment line 5.
- Step 2: Write the failing tests —
relay/test/room-core.test.mjs:
import { test } from 'node:test';import assert from 'node:assert/strict';import * as C from '../src/room_core.js';
const u8 = (...b) => Uint8Array.from(b);const data = (peer, ...payload) => u8(C.KIND.DATA, peer, ...payload);const H = { peer: 1, role: 'host' };const J = { peer: 2, role: 'join' };
test('normCode accepts the unambiguous alphabet only', () => { assert.equal(C.normCode(' abcde '), 'ABCDE'); assert.equal(C.normCode('ABCD0'), null); // 0 not in the alphabet assert.equal(C.normCode('ABCD'), null); assert.equal(C.normCode(7), null);});
test('parseRoomRequest validates code, role and relay version', () => { const p = (s) => C.parseRoomRequest(new URL('https://r.example' + s)); assert.deepEqual(p('/mp/room/ABCDE?role=host&v=1'), { code: 'ABCDE', role: 'host', v: 1 }); assert.deepEqual(p('/mp/room/abcde?role=join&v=1'), { code: 'ABCDE', role: 'join', v: 1 }); assert.deepEqual(p('/mp/room/ABCD0?role=host&v=1'), { error: 'bad_code' }); assert.deepEqual(p('/mp/room/ABCDE?role=spy&v=1'), { error: 'bad_role' }); assert.deepEqual(p('/mp/room/ABCDE?role=host&v=2'), { error: 'bad_version' }); assert.deepEqual(p('/mp/room/ABCDE?role=host'), { error: 'bad_version' });});
test('admit: host gets 1, joiner gets the lowest free id, refusals carry close codes', () => { assert.deepEqual(C.admit([], 'host'), { ok: true, peer: 1 }); assert.deepEqual(C.admit([], 'join'), { ok: false, close: C.CLOSE.NO_ROOM, reason: 'no_room' }); assert.deepEqual(C.admit([H], 'host'), { ok: false, close: C.CLOSE.CODE_TAKEN, reason: 'code_taken' }); assert.deepEqual(C.admit([H], 'join'), { ok: true, peer: 2 }); assert.deepEqual(C.admit([H, J], 'join'), { ok: false, close: C.CLOSE.ROOM_FULL, reason: 'room_full' });});
test('route forwards DATA/PING/PONG to the destination with the SOURCE id stamped in', () => { for (const kind of [C.KIND.DATA, C.KIND.PING, C.KIND.PONG]) { const r = C.route(J, u8(kind, 1, 9, 8, 7), [H, J]); assert.deepEqual(r.sends, [{ peer: 1, bytes: u8(kind, 2, 9, 8, 7) }]); assert.equal(r.close, undefined); }});
test('route drops frames to unknown peers or to yourself, never echoes', () => { assert.deepEqual(C.route(J, data(5, 1), [H, J]).sends, []); assert.deepEqual(C.route(J, data(2, 1), [H, J]).sends, []);});
test('route closes on oversize, empty, unknown kind, oversize CTRL', () => { const big = new Uint8Array(C.MAX_FRAME + 1); big[0] = C.KIND.DATA; big[1] = 1; assert.equal(C.route(J, big, [H, J]).close.code, C.CLOSE.TOO_BIG); assert.equal(C.route(J, new Uint8Array(0), [H, J]).close.code, C.CLOSE.TOO_BIG); assert.equal(C.route(J, u8(9, 1), [H, J]).close.code, C.CLOSE.TOO_BIG); const ctrl = new Uint8Array(C.MAX_CTRL + 2); ctrl[0] = C.KIND.CTRL; assert.equal(C.route(H, ctrl, [H, J]).close.code, C.CLOSE.TOO_BIG);});
test('a max-size DATA frame is forwarded', () => { const f = new Uint8Array(C.MAX_FRAME); f[0] = C.KIND.DATA; f[1] = 2; assert.equal(C.route(H, f, [H, J]).sends.length, 1);});
test('kick: only the host, only another member', () => { const k = (who, peer) => C.route(who, C.ctrl({ t: 'kick', peer }), [H, J]); assert.equal(k(H, 2).kick, 2); assert.equal(k(J, 1).kick, undefined); assert.equal(k(H, 1).kick, undefined); assert.equal(k(H, 7).kick, undefined);});
test('malformed CTRL json is ignored, not fatal', () => { const r = C.route(H, u8(C.KIND.CTRL, 123, 34), [H, J]); assert.deepEqual(r, { sends: [] });});
test('welcome lists the other members', () => { const w = JSON.parse(new TextDecoder().decode(C.welcomeFor(2, [H, J]).subarray(1))); assert.deepEqual(w, { t: 'welcome', you: 2, host: 1, peers: [1] });});
test('onLeave: a joiner leaving tells the host; the host leaving closes everyone', () => { const a = C.onLeave(J, [H]); assert.deepEqual(a.sends.map((s) => s.peer), [1]); assert.deepEqual(JSON.parse(new TextDecoder().decode(a.sends[0].bytes.subarray(1))), { t: 'leave', peer: 2 }); assert.equal(a.closeAll, undefined); const b = C.onLeave(H, [J]); assert.deepEqual(b.closeAll, { code: C.CLOSE.HOST_LEFT, reason: 'host_left' }); assert.deepEqual(JSON.parse(new TextDecoder().decode(b.sends[0].bytes.subarray(1))), { t: 'leave', peer: 1 });});
test('token bucket: starts full, refills over time, refuses when empty', () => { let b; let ok = 0; for (let i = 0; i < C.BUCKET_CAP + 5; i++) { const r = C.takeToken(b, 1000); b = r.bucket; if (r.ok) ok++; } assert.equal(ok, C.BUCKET_CAP); assert.equal(C.takeToken(b, 1000).ok, false); assert.equal(C.takeToken(b, 1000 + 1000).ok, true); // 180 tokens back after 1 s // 60 rows/s + 1 ping/s for 10 minutes never trips it let s; let refused = 0; for (let t = 0; t < 600000; t += 16) { const r = C.takeToken(s, t); s = r.bucket; if (!r.ok) refused++; } assert.equal(refused, 0);});-
Step 3: Run, expect FAIL —
node --test relay/test/room-core.test.mjs→ERR_MODULE_NOT_FOUND. -
Step 4: Implement
relay/src/room_core.js:
// Internet co-op relay, pure core (Phase 4). No Workers globals: node --test covers all of it.// Plan: docs/superpowers/plans/2026-10-14-internet-coop-relay-phase4.md § Wire format.// The relay never interprets LockstepProtocol bytes: DATA payloads are opaque.
export const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // = control/src/mp.js, LanRegistryexport const CODE_LEN = 5;export const RELAY_V = 1;export const MAX_MEMBERS = 2; // host + 1 (spec Chris call 9; LockstepSession.MAX_PILOTS)export const MAX_FRAME = 4096;export const MAX_CTRL = 128;export const BUCKET_CAP = 300;export const BUCKET_REFILL_PER_S = 180;export const KIND = { CTRL: 0, DATA: 1, PING: 2, PONG: 3 };export const CLOSE = { CODE_TAKEN: 4001, ROOM_FULL: 4003, NO_ROOM: 4004, KICKED: 4005, RATE: 4008, TOO_BIG: 4009, HOST_LEFT: 4010, BAD_REQUEST: 4400 };
const enc = new TextEncoder();const dec = new TextDecoder();
export function normCode(v) { if (typeof v !== 'string') return null; const c = v.trim().toUpperCase(); if (c.length !== CODE_LEN) return null; for (const ch of c) if (!CODE_ALPHABET.includes(ch)) return null; return c;}
export function parseRoomRequest(url) { const m = url.pathname.match(/^\/mp\/room\/([^/]+)$/); const code = m ? normCode(decodeURIComponent(m[1])) : null; if (!code) return { error: 'bad_code' }; const role = url.searchParams.get('role'); if (role !== 'host' && role !== 'join') return { error: 'bad_role' }; if (url.searchParams.get('v') !== String(RELAY_V)) return { error: 'bad_version' }; return { code, role, v: RELAY_V };}
export function admit(members, role) { const hasHost = members.some((m) => m.role === 'host'); if (role === 'host') { return hasHost ? { ok: false, close: CLOSE.CODE_TAKEN, reason: 'code_taken' } : { ok: true, peer: 1 }; } if (!hasHost) return { ok: false, close: CLOSE.NO_ROOM, reason: 'no_room' }; if (members.length >= MAX_MEMBERS) return { ok: false, close: CLOSE.ROOM_FULL, reason: 'room_full' }; const used = new Set(members.map((m) => m.peer)); for (let p = 2; p <= MAX_MEMBERS; p++) if (!used.has(p)) return { ok: true, peer: p }; return { ok: false, close: CLOSE.ROOM_FULL, reason: 'room_full' };}
export function ctrl(obj) { const body = enc.encode(JSON.stringify(obj)); const out = new Uint8Array(body.length + 1); out[0] = KIND.CTRL; out.set(body, 1); return out;}
export function welcomeFor(peer, members) { return ctrl({ t: 'welcome', you: peer, host: 1, peers: members.map((m) => m.peer).filter((p) => p !== peer) });}
const tooBig = { sends: [], close: { code: CLOSE.TOO_BIG, reason: 'too_big' } };
export function route(sender, frame, members) { if (!(frame instanceof Uint8Array) || frame.length === 0 || frame.length > MAX_FRAME) return tooBig; const kind = frame[0]; if (kind === KIND.CTRL) { if (frame.length > MAX_CTRL + 1) return tooBig; let msg; try { msg = JSON.parse(dec.decode(frame.subarray(1))); } catch { return { sends: [] }; } if (msg && msg.t === 'kick' && sender.role === 'host' && Number.isInteger(msg.peer) && msg.peer !== sender.peer && members.some((m) => m.peer === msg.peer)) { return { sends: [], kick: msg.peer }; } return { sends: [] }; } if (kind !== KIND.DATA && kind !== KIND.PING && kind !== KIND.PONG) return tooBig; if (frame.length < 2) return tooBig; const to = frame[1]; if (to === sender.peer || !members.some((m) => m.peer === to)) return { sends: [] }; const out = frame.slice(); out[1] = sender.peer; return { sends: [{ peer: to, bytes: out }] };}
export function onLeave(leaver, remaining) { const sends = remaining.map((m) => ({ peer: m.peer, bytes: ctrl({ t: 'leave', peer: leaver.peer }) })); if (leaver.role === 'host') return { sends, closeAll: { code: CLOSE.HOST_LEFT, reason: 'host_left' } }; return { sends };}
export function takeToken(bucket, nowMs) { const b = bucket ? { ...bucket } : { tokens: BUCKET_CAP, at: nowMs }; b.tokens = Math.min(BUCKET_CAP, b.tokens + ((nowMs - b.at) / 1000) * BUCKET_REFILL_PER_S); b.at = nowMs; if (b.tokens < 1) return { ok: false, bucket: b }; b.tokens -= 1; return { ok: true, bucket: b };}- Step 5: Run, expect PASS —
node --test relay/test/room-core.test.mjs→# fail 0. - Step 6: Determinism unaffected (no Godot file touched; nothing to run beyond the node suite). Commit:
git add relay/package.json relay/src/room_core.js relay/test/room-core.test.mjs scripts/gate.shgit commit --file=<msgfile> # "relay: pure room core (membership, routing, caps) + node tests"Task 2: Durable Object + Worker router + “stores nothing” check
Section titled “Task 2: Durable Object + Worker router + “stores nothing” check”Files:
- Create:
relay/src/room.js,relay/src/worker.js,relay/wrangler.toml,relay/test/room.test.mjs,relay/test/worker.test.mjs,relay/test/no-storage.test.mjs
Interfaces:
- Consumes: everything from Task 1.
- Produces:
export class RelayRoom { constructor(ctx, env, deps = {}) ; fetch(req) ; webSocketMessage(ws, msg) ; webSocketClose(ws, code, reason, wasClean) ; webSocketError(ws) }wheredeps = { now?: () => number, newPair?: () => [client, server], upgrade?: (client) => Response };export default { fetch(req, env, ctx) }fromworker.js, re-exportingRelayRoom;export function handle(req, env)for tests (preflight ruling: nodepsparam; the tests callhandle(req, env)).
Design notes the implementer must keep:
-
Hibernation: in-memory fields vanish when the DO hibernates. Membership is therefore ALWAYS rebuilt from
this.ctx.getWebSockets()+ws.deserializeAttachment()({peer, role}), never a member Map. The token buckets live in aMapkeyed by ws (lost on hibernation = a full bucket after an idle period, which is correct). -
Refusals use a NON-hibernating accept:
server.accept(); server.close(code, reason), so a refused socket never entersgetWebSockets(). -
acceptWebSocket(server, ['p' + peer])thenserver.serializeAttachment({ peer, role })thenserver.send(welcomeFor(...))then ajoinCTRL to each existing member. -
Close/error handler: idempotent (both may fire). Remove the bucket, compute
onLeaveover the remaining members, send, and oncloseAllclose every remaining socket with 4010. Wrap everyws.send/ws.closein try/catch (a peer may already be gone). -
webSocketMessagewith a string message → close 4009 (binary only). -
request.cf.colois NOT read or forwarded (would be coarse location data: privacy label impact). -
Step 1: wrangler.toml (
~/.claude/gotchas/cloudflare.md: no[vars]; tables after top-level keys;new_sqlite_classes):
name = "bullet-heaven-relay"main = "src/worker.js"compatibility_date = "2026-09-01"workers_dev = true# Same pin as control/wrangler.toml: non-interactive wrangler refuses to pick among several OAuth accounts.account_id = "06f5622d4192dcefefbe0da75d766bfc"
# Internet co-op relay (Phase 4). STORES NOTHING: no D1 / KV / R2 / analytics bindings, no# ctx.storage, no logs (relay/test/no-storage.test.mjs enforces this). The privacy page and the ASC# label rely on it. One RelayRoom DO per room code; WebSocket Hibernation API.
[observability]enabled = false
[[durable_objects.bindings]]name = "ROOM"class_name = "RelayRoom"
# SQLite-backed class: deploys on the free Workers plan too (chess-moba precedent). RelayRoom never# touches storage, so the backing type is a no-op for its logic.[[migrations]]tag = "v1"new_sqlite_classes = ["RelayRoom"]
# Per-IP connect limit. Ephemeral per-colo counters, not stored data. 30/min: cellular CGNAT puts# many players behind one address, and only the WebSocket upgrade counts, never messages.[[ratelimits]]name = "CONNECT_LIMIT"namespace_id = "4710"simple = { limit = 30, period = 60 }(Implementer: confirm compatibility_date is accepted by the installed wrangler with npx --yes wrangler deploy --dry-run inside relay/; lower it to the newest accepted date if not. Dry-run uploads nothing.)
- Step 2: Failing tests —
relay/test/room.test.mjs(fake ctx + fake sockets):
import { test } from 'node:test';import assert from 'node:assert/strict';import { RelayRoom } from '../src/room.js';import { KIND, CLOSE, ctrl } from '../src/room_core.js';
class FakeWs { constructor(name) { this.name = name; this.sent = []; this.closed = null; this.att = null; this.accepted = false; } send(b) { if (this.closed) throw new Error('closed'); this.sent.push(b instanceof Uint8Array ? b : new Uint8Array(b)); } close(code, reason) { this.closed ??= { code, reason }; } accept() { this.accepted = true; } serializeAttachment(a) { this.att = structuredClone(a); } deserializeAttachment() { return this.att; }}class FakeCtx { constructor() { this.sockets = []; } acceptWebSocket(ws, tags) { ws.tags = tags; this.sockets.push(ws); } getWebSockets() { return this.sockets.filter((w) => !w.closed); }}const decodeCtrl = (b) => (b[0] === KIND.CTRL ? JSON.parse(new TextDecoder().decode(b.subarray(1))) : null);
function room() { const ctx = new FakeCtx(); let n = 0; let t = 0; const r = new RelayRoom(ctx, {}, { now: () => t, newPair: () => { const c = new FakeWs('c' + n); const s = new FakeWs('s' + n++); return [c, s]; }, upgrade: (client) => ({ status: 101, client }), }); const open = async (role) => { const res = await r.fetch(new Request(`https://x/mp/room/ABCDE?role=${role}&v=1`, { headers: { Upgrade: 'websocket' } })); return ctx.sockets.at(-1)?.name === 's' + (n - 1) ? ctx.sockets.at(-1) : res.server ?? null; }; return { r, ctx, open, tick: (ms) => { t += ms; } };}Then tests (each a test(...) using the helpers above; write them out in full):
host gets a welcome with you=1 and no peers— open host;decodeCtrl(host.sent[0])deep-equals{t:'welcome',you:1,host:1,peers:[]};host.attequals{peer:1, role:'host'};host.tagsequals['p1'].joiner gets you=2 and the host gets a join— open host, open join; joiner welcome{you:2,peers:[1]}; host’s last sent CTRL{t:'join',peer:2}.join with no host is refused with 4004 and never enters the room— use anewPairspy to capture the server socket; assertaccepted === true,closed.code === CLOSE.NO_ROOM,ctx.getWebSockets().length === 0.second host 4001, third member 4003(same capture pattern).DATA from the joiner reaches the host stamped with source 2—r.webSocketMessage(join, Uint8Array.of(KIND.DATA, 1, 5, 6).buffer); host’s last frame equals[1, 2, 5, 6].string messages close with 4009.rate cap: 301 frames in the same millisecond closes the sender with 4008; after hibernation (new RelayRoom on the same ctx) the bucket is full again.kick: host kicks peer 2 -> peer 2 closed 4005 AFTER a DATA frame sent just before it was delivered— host sends DATA to 2 then CTRL kick; assert join.sent last DATA present andjoin.closed.code === CLOSE.KICKED.host close: joiner gets leave{peer:1} then is closed 4010.joiner close: host gets leave{peer:2}; a new joiner then gets id 2 again.close then error on the same socket is idempotent— exactly oneleavesent.membership survives hibernation— construct a NEWRelayRoom(ctx, …)over the sameFakeCtx(simulated wake) and assert a DATA frame still routes (members rebuilt from attachments).
relay/test/worker.test.mjs:
import { test } from 'node:test';import assert from 'node:assert/strict';import { handle } from '../src/worker.js';
function env({ limited = false } = {}) { const seen = []; return { seen, CONNECT_LIMIT: { limit: async ({ key }) => { seen.push(key); return { success: !limited }; } }, ROOM: { idFromName: (n) => ({ name: n }), get: (id) => ({ fetch: async (req) => new Response('to-room:' + id.name + ':' + new URL(req.url).search) }), }, };}const ws = { Upgrade: 'websocket', 'CF-Connecting-IP': '203.0.113.9' };
test('routes a valid upgrade to the room DO named by the normalised code', async () => { const e = env(); const res = await handle(new Request('https://r/mp/room/abcde?role=join&v=1', { headers: ws }), e); assert.equal(await res.text(), 'to-room:ABCDE:?role=join&v=1'); assert.deepEqual(e.seen, ['ip:203.0.113.9']);});test('426 without Upgrade, 400 for bad code/role/version, 404 elsewhere', async () => { assert.equal((await handle(new Request('https://r/mp/room/ABCDE?role=join&v=1'), env())).status, 426); assert.equal((await handle(new Request('https://r/mp/room/ABCD0?role=join&v=1', { headers: ws }), env())).status, 400); assert.equal((await handle(new Request('https://r/mp/room/ABCDE?role=x&v=1', { headers: ws }), env())).status, 400); assert.equal((await handle(new Request('https://r/mp/room/ABCDE?role=join&v=9', { headers: ws }), env())).status, 400); assert.equal((await handle(new Request('https://r/other', { headers: ws }), env())).status, 404);});test('429 when the per-IP connect limit says no; the DO is never reached', async () => { const res = await handle(new Request('https://r/mp/room/ABCDE?role=host&v=1', { headers: ws }), env({ limited: true })); assert.equal(res.status, 429);});test('/health answers ok without touching any binding', async () => { const res = await handle(new Request('https://r/health'), {}); assert.equal(res.status, 200);});relay/test/no-storage.test.mjs:
import { test } from 'node:test';import assert from 'node:assert/strict';import { readFileSync, readdirSync } from 'node:fs';import { CODE_ALPHABET } from '../src/room_core.js';import { MP_CODE_ALPHABET } from '../../control/src/mp.js';
const here = new URL('..', import.meta.url);const toml = readFileSync(new URL('wrangler.toml', here), 'utf8');
test('wrangler.toml declares no storage or analytics binding and turns logs off', () => { for (const k of ['d1_databases', 'kv_namespaces', 'r2_buckets', 'analytics_engine_datasets', 'queues', 'vectorize', 'hyperdrive', 'tail_consumers', 'logpush']) { assert.ok(!toml.includes(k), `relay must store nothing: found ${k}`); } assert.match(toml, /\[observability\]\s*\nenabled = false/);});test('relay source never touches DO storage and never logs', () => { for (const f of readdirSync(new URL('src/', here))) { const src = readFileSync(new URL('src/' + f, here), 'utf8'); assert.doesNotMatch(src, /\.storage\b/, `${f} touches storage`); assert.doesNotMatch(src, /\bconsole\./, `${f} logs`); assert.doesNotMatch(src, /\bcf\.colo\b|cf-ipcountry|\.cf\b/i, `${f} reads location data`); }});test('room-code alphabet is the same everywhere (relay, control registry, LanRegistry)', () => { assert.equal(CODE_ALPHABET, MP_CODE_ALPHABET); const gd = readFileSync(new URL('../net/lan_registry.gd', here), 'utf8'); assert.match(gd, new RegExp(`CODE_ALPHABET := "${CODE_ALPHABET}"`));});-
Step 3: Run, expect FAIL —
node --test relay/test/*.test.mjs. -
Step 4: Implement
relay/src/room.js:
// RelayRoom: one Durable Object per room code (Phase 4). Dumb ordered fan-out; see room_core.js.// Hibernation API: membership is rebuilt from getWebSockets() + attachments on every event.import { admit, route, onLeave, takeToken, welcomeFor, ctrl, parseRoomRequest, CLOSE } from './room_core.js';
export class RelayRoom { constructor(ctx, env, deps = {}) { this.ctx = ctx; this.env = env; this.now = deps.now ?? (() => Date.now()); this.newPair = deps.newPair ?? (() => { const p = new WebSocketPair(); return [p[0], p[1]]; }); this.upgrade = deps.upgrade ?? ((client) => new Response(null, { status: 101, webSocket: client })); this.buckets = new Map(); this.gone = new WeakSet(); }
members() { const out = []; for (const ws of this.ctx.getWebSockets()) { if (this.gone.has(ws)) continue; const a = ws.deserializeAttachment(); if (a && Number.isInteger(a.peer)) out.push({ ws, peer: a.peer, role: a.role }); } return out; }
async fetch(req) { const p = parseRoomRequest(new URL(req.url)); const [client, server] = this.newPair(); if (p.error) return this.refuse(client, server, CLOSE.BAD_REQUEST, p.error); const members = this.members(); const a = admit(members, p.role); if (!a.ok) return this.refuse(client, server, a.close, a.reason); this.ctx.acceptWebSocket(server, ['p' + a.peer]); server.serializeAttachment({ peer: a.peer, role: p.role }); safeSend(server, welcomeFor(a.peer, [...members, { peer: a.peer, role: p.role }])); for (const m of members) safeSend(m.ws, ctrl({ t: 'join', peer: a.peer })); return this.upgrade(client); }
refuse(client, server, code, reason) { server.accept(); server.close(code, reason); return this.upgrade(client); }
webSocketMessage(ws, message) { const me = ws.deserializeAttachment(); if (!me || this.gone.has(ws)) return; if (typeof message === 'string') return this.kill(ws, CLOSE.TOO_BIG, 'binary_only'); const t = takeToken(this.buckets.get(ws), this.now()); this.buckets.set(ws, t.bucket); if (!t.ok) return this.kill(ws, CLOSE.RATE, 'rate_limited'); const members = this.members(); const r = route({ peer: me.peer, role: me.role }, new Uint8Array(message), members); for (const s of r.sends) { const to = members.find((m) => m.peer === s.peer); if (to) safeSend(to.ws, s.bytes); } if (r.kick !== undefined) { const victim = members.find((m) => m.peer === r.kick); if (victim) this.kill(victim.ws, CLOSE.KICKED, 'kicked'); } if (r.close) this.kill(ws, r.close.code, r.close.reason); }
webSocketClose(ws) { this.leave(ws); } webSocketError(ws) { this.leave(ws); }
kill(ws, code, reason) { try { ws.close(code, reason); } catch { /* already closed */ } this.leave(ws); }
leave(ws) { if (this.gone.has(ws)) return; const me = ws.deserializeAttachment(); this.gone.add(ws); this.buckets.delete(ws); if (!me) return; const rest = this.members(); const r = onLeave({ peer: me.peer, role: me.role }, rest); for (const s of r.sends) { const to = rest.find((m) => m.peer === s.peer); if (to) safeSend(to.ws, s.bytes); } if (r.closeAll) for (const m of rest) this.kill(m.ws, r.closeAll.code, r.closeAll.reason); }}
function safeSend(ws, bytes) { try { ws.send(bytes); } catch { /* peer already gone; its close event cleans up */ }}(Note gone is a WeakSet in memory: after a hibernation wake a closed socket is no longer in getWebSockets() anyway, so losing it is harmless. Test 11 covers idempotency within one lifetime.)
relay/src/worker.js:
// bullet-heaven-relay Worker (Phase 4): /mp/room/<CODE> -> RelayRoom DO; /health. Stores nothing.import { parseRoomRequest } from './room_core.js';export { RelayRoom } from './room.js';
export async function handle(req, env) { const url = new URL(req.url); if (url.pathname === '/health') return new Response('ok', { headers: { 'cache-control': 'no-store' } }); if (!url.pathname.startsWith('/mp/room/')) return new Response('not found', { status: 404 }); if ((req.headers.get('Upgrade') || '').toLowerCase() !== 'websocket') return new Response('websocket only', { status: 426 }); const p = parseRoomRequest(url); if (p.error) return new Response(p.error, { status: 400 }); const ip = req.headers.get('CF-Connecting-IP') || 'unknown'; const { success } = await env.CONNECT_LIMIT.limit({ key: 'ip:' + ip }); if (!success) return new Response('slow down', { status: 429 }); return env.ROOM.get(env.ROOM.idFromName(p.code)).fetch(req);}
export default { fetch: (req, env) => handle(req, env) };- Step 5: Run, expect PASS —
node --test relay/test/*.test.mjs→# fail 0. Then the full node gate line:node --test control/test/*.test.mjs scripts/test/*.test.mjs telemetry/test/*.test.mjs relay/test/*.test.mjs. - Step 6: Commit — “relay: RelayRoom DO (hibernation) + router + per-IP limit + stores-nothing test”. No Godot file touched; determinism n/a.
Task 3: Golden frames + GDScript codec RelayFrames
Section titled “Task 3: Golden frames + GDScript codec RelayFrames”Files:
- Create:
tests/fixtures/relay_frames.json,relay/test/frames.test.mjs,net/relay_frames.gd,tests/test_relay_frames.gd
Interfaces:
-
Produces (GDScript):
class_name RelayFrames extends RefCountedwith constsRELAY_V := 1,KIND_CTRL := 0,KIND_DATA := 1,KIND_PING := 2,KIND_PONG := 3,MAX_FRAME := 4096, close codesCLOSE_CODE_TAKEN := 4001 … CLOSE_BAD_REQUEST := 4400, andstatic func data(peer: int, payload: PackedByteArray) -> PackedByteArraystatic func ping(kind: int, peer: int, nonce: int, sent_ms: int) -> PackedByteArraystatic func ctrl(obj: Dictionary) -> PackedByteArraystatic func decode(bytes: PackedByteArray) -> Dictionary→{}when malformed, else{"kind": int, "peer": int, "payload": PackedByteArray}(DATA),{"kind", "peer", "nonce", "sent_ms"}(PING/PONG),{"kind": 0, "ctrl": Dictionary}(CTRL)static func failure_for_close(code: int) -> String(table in § Wire format;-1→"")
-
Step 1: Fixture
tests/fixtures/relay_frames.json(both test suites read it; hex lowercase):
{ "frames": [ {"name": "data_to_host", "hex": "0101050102", "kind": 1, "peer": 1, "payload_hex": "050102"}, {"name": "data_empty_payload", "hex": "0102", "kind": 1, "peer": 2, "payload_hex": ""}, {"name": "ping", "hex": "020207000000e8030000", "kind": 2, "peer": 2, "nonce": 7, "sent_ms": 1000}, {"name": "pong", "hex": "0301ffffffff00000000", "kind": 3, "peer": 1, "nonce": 4294967295, "sent_ms": 0}, {"name": "welcome", "hex": "007b2274223a2277656c636f6d65222c22796f75223a322c22686f7374223a312c227065657273223a5b315d7d", "kind": 0, "ctrl": {"t": "welcome", "you": 2, "host": 1, "peers": [1]}}, {"name": "kick", "hex": "007b2274223a226b69636b222c2270656572223a327d", "kind": 0, "ctrl": {"t": "kick", "peer": 2}} ], "malformed_hex": ["", "01", "09ff", "0201000000"]}(The two CTRL hex strings were generated with node -e from JSON.stringify and verified 2026-10-14. Two GDScript traps the codec must handle: JSON.stringify SORTS keys by default, so RelayFrames.ctrl() must call JSON.stringify(obj, "", false) to keep insertion order; and a dictionary that came from JSON.parse_string holds floats (2.0), which Godot stringifies as 2.0, not 2, so ctrl() converts integral floats to int (top-level values and Array elements) before stringifying. The encoder test feeds fixture-parsed dicts precisely to pin that.)
- Step 2: Failing tests.
relay/test/frames.test.mjs: for each fixture frame, assert the JS side produces the same bytes (route()stamping aside): DATA/PING/PONG are byte-copies, so assertUint8Array.from(Buffer.from(hex,'hex'))(a fresh copy:route()writesout[1], and a Buffer slice is a view that would mutate the fixture input; preflight ruling) round-trips throughroute({peer: X, role}, …)without[1]rewritten; CTRL frames equalctrl(obj)byte-for-byte.
tests/test_relay_frames.gd:
extends GutTest
# RelayFrames (internet relay Phase 4): the GDScript side of the relay envelope. The golden frames in# tests/fixtures/relay_frames.json are shared with relay/test/frames.test.mjs, so the Worker and the# game can never disagree on a byte.
func _fixture() -> Dictionary: var v: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://tests/fixtures/relay_frames.json")) return v as Dictionary
func test_encoders_match_the_golden_bytes() -> void: for f: Dictionary in _fixture()["frames"]: var want := (String(f["hex"])).hex_decode() var kind := int(f["kind"]) var got: PackedByteArray if kind == RelayFrames.KIND_DATA: got = RelayFrames.data(int(f["peer"]), String(f["payload_hex"]).hex_decode()) elif kind == RelayFrames.KIND_CTRL: got = RelayFrames.ctrl(f["ctrl"]) else: got = RelayFrames.ping(kind, int(f["peer"]), int(f["nonce"]), int(f["sent_ms"])) assert_eq(got, want, String(f["name"]))
func test_decode_round_trips_every_golden_frame() -> void: for f: Dictionary in _fixture()["frames"]: var d := RelayFrames.decode(String(f["hex"]).hex_decode()) assert_eq(int(d["kind"]), int(f["kind"]), String(f["name"])) if int(f["kind"]) == RelayFrames.KIND_DATA: assert_eq(d["payload"], String(f["payload_hex"]).hex_decode()) elif int(f["kind"]) == RelayFrames.KIND_CTRL: assert_eq(JSON.stringify(d["ctrl"]), JSON.stringify(f["ctrl"])) else: assert_eq(int(d["nonce"]), int(f["nonce"])) assert_eq(int(d["sent_ms"]), int(f["sent_ms"]))
func test_malformed_frames_decode_to_empty() -> void: for h: String in _fixture()["malformed_hex"]: assert_eq(RelayFrames.decode(h.hex_decode()), {}, "'%s'" % h)
func test_a_lockstep_message_rides_unchanged() -> void: var row := LockstepProtocol.encode_row(3, 42, PackedInt64Array([1, 2]), [], true, 12345) var d := RelayFrames.decode(RelayFrames.data(2, row)) assert_eq(d["payload"], row) assert_eq(LockstepProtocol.decode(d["payload"])["tick"], 42)
func test_the_biggest_realistic_hold_row_fits_one_frame() -> void: var arg := "x".repeat(LockstepProtocol.MAX_CMD_ARG) var cmds: Array = [] for i in range(8): cmds.append([SimCommands.LEVELUP_PICK, i % 2, arg]) var row := LockstepProtocol.encode_row(1, 1, PackedInt64Array([0, 0]), cmds, true, 1, true) assert_lt(RelayFrames.data(2, row).size(), RelayFrames.MAX_FRAME)
func test_close_codes_map_to_failure_names() -> void: assert_eq(RelayFrames.failure_for_close(4001), "code_taken") assert_eq(RelayFrames.failure_for_close(4004), "no_room") assert_eq(RelayFrames.failure_for_close(4010), "host_left") assert_eq(RelayFrames.failure_for_close(1008), "rate_limited") assert_eq(RelayFrames.failure_for_close(1009), "too_big") assert_eq(RelayFrames.failure_for_close(1000), "") assert_eq(RelayFrames.failure_for_close(-1), "")- Step 3: Run, expect FAIL —
godot --headless --path . --import, thengodot --headless --path . -s addons/gut/gut_cmdln.gd -gtest=res://tests/test_relay_frames.gd -gexit→ parse error (noRelayFrames). - Step 4: Implement
net/relay_frames.gdper the interface (StreamPeerBuffer, little-endian;decodechecks: empty →{}; kind 1 needs ≥2 bytes; kinds 2/3 need exactly 10; kind 0 needs valid JSON object; other kinds →{};ctrl()usesJSON.stringify(obj, "", false)).failure_for_close: the § Wire format table plus1008 → rate_limited,1009 → too_big, else"". - Step 5: Run, expect PASS; node
relay/test/frames.test.mjsPASS. - Step 6:
bash scripts/check-test-count.sh(determinism files included) → green, baselines unmoved.git checkout -- project.godot audio/bus_layout.tres. Commit “net: RelayFrames codec + golden frames shared with the relay Worker”.
Task 4: RelaySocket seam, WsRelaySocket, FakeRelayHub, RelayTransport
Section titled “Task 4: RelaySocket seam, WsRelaySocket, FakeRelayHub, RelayTransport”Files:
- Create:
net/relay_socket.gd,net/ws_relay_socket.gd,net/relay_transport.gd,tests/fake_relay_hub.gd,tests/fake_relay_socket.gd,tests/test_relay_transport.gd - Modify:
net/net_transport.gd(header comment:RelayTransportis real now;kind()comment lists"relay")
Interfaces:
-
Consumes:
RelayFrames(Task 3);NetTransport(existing:send,poll,peers,local_id,kind,rtt_ms,loss_pct,disconnect_peer,set_link_timeout,close);LockstepProtocol.encode_text,MSG_BYE. -
Produces:
class_name RelaySocket extends RefCounted:enum State { CONNECTING, OPEN, CLOSED };func open(url: String) -> Error;func poll() -> void;func state() -> State;func send(bytes: PackedByteArray) -> Error;func receive() -> Array[PackedByteArray];func close_code() -> int(-1 = none, e.g. failed handshake);func close(code: int = 1000, reason: String = "") -> void.class_name WsRelaySocket extends RelaySocketoverWebSocketPeer(connect_to_url(url, TLSOptions.client()),get_ready_state(),put_packet,get_available_packet_count/get_packet,get_close_code();inbound_buffer_size/outbound_buffer_sizeleft at defaults (64 KB ≫ MAX_FRAME)).class_name RelayTransport extends NetTransport:const DEFAULT_URL := "wss://bullet-heaven-relay.chris-allen-06f.workers.dev",const PING_MS := 1000,const RTT_ALPHA := 0.25,const CONNECT_TIMEOUT_MS := 8000static var url_override: String = ""(CLI--mp-relay=; tests). Not a tunable: a URL, and deliberately not remotely settable.var clock_ms: Callable = Time.get_ticks_msec;var make_socket: Callable = func() -> RelaySocket: return WsRelaySocket.new()static func base_url() -> String;static func room_url(base: String, code: String, role: String) -> String;static func random_code(rng: RandomNumberGenerator) -> String(fromLanRegistry.CODE_ALPHABET,LanRegistry.CODE_LEN)func host(code: String) -> Error;func join(code: String) -> Error;func code() -> String;func is_host() -> bool;func is_welcomed() -> bool;func failure() -> String(""while healthy; else a § Wire format failure name,connect_failed, orconnection_lost)- overrides:
kind() -> "relay",local_id(),peers(),send(),poll(),rtt_ms()(EWMA of PONG round trips,-1until the first),loss_pct() -> -1.0,disconnect_peer(peer: int, force: bool = false)(keep the base signature exactly or the override fails to parse; host withforce == false: drop frompeers()now + CTRL kick;force == true: no-op, because a relay kick is already immediate and final, andLockstepSession._refuseforce-disconnects the refused idHELLO_DEADLINE_MSlater, by which time the relay may have given that id to a NEW, correct joiner (ids are reused, ENet’s never are) (preflight ruling F1); client: no-op),set_link_timeout()(no-op: the relay has no link timeout of its own; documented),close() - Behaviour: before
welcome,peers()is empty andsend()is a no-op. A client’speers()is[1]after welcome. CTRLjoin/leaveadd/remove ids. On the host leaving (CTRLleavefor peer 1, or close 4010) a client’spoll()injects ONE synthetic{peer: 1, bytes: LockstepProtocol.encode_text(MSG_BYE, "left")}so the session ends at once ashost_left: leftinstead of waiting outTIMEOUT_MS. On any OTHER loss of the host after the welcome (connection_lost,rate_limited,too_big, …) it injects ONEBYE "connection_lost"instead, so the banner does not say “Your friend left”. On close 4005kickedit injects NO BYE: the REJECT the host queued before the kick has already been drained and must decideend_reason(rejected: …) (preflight ruling F2). The first PING to a peer goes out at once when that peer appears (welcome / join), then everyPING_MSto each peer (preflight ruling F9: the RTT test only pumps 320 ms); an incoming PING is answered with PONG (same nonce/sent_ms) insidepoll(); PONG updates RTT fromclock_ms() - sent_ms(32-bit wrap-safe). Connect timeout: stillCONNECTINGafterCONNECT_TIMEOUT_MS→failure() = "connect_failed"and close.
-
Step 1: Fake hub
tests/fake_relay_hub.gd— an in-memory stand-in for the DO with the SAME admission/routing/leave rules asroom_core.js(port the five functions; keep a comment pointing at the JS and at the wrangler-dev test in Task 11 that proves parity end to end).class_name FakeRelayHub extends RefCounted;var latency_polls := 0;func socket() -> FakeRelaySocket;func member_count(code: String) -> int. In its OWN filetests/fake_relay_socket.gd(one file cannot hold twoclass_names; preflight ruling F10):class_name FakeRelaySocket extends RelaySocketwhoseopen(url)parses/mp/room/<C>?role=&v=and registers with the hub on the firstpoll()(so CONNECTING is observable for one poll), delivers frames afterlatency_pollsof the RECEIVER’s polls, andclose()triggers the hub’s leave logic.var refuse_handshake := false→ stays CLOSED withclose_code() == -1(models 429/offline). -
Step 2: Failing tests
tests/test_relay_transport.gd:
extends GutTest
# RelayTransport (internet relay Phase 4) over FakeRelayHub: the NetTransport contract LockstepSession# relies on (host = peer 1, peers(), ordered delivery, disconnect_peer frees the slot after the last# packet, RTT measured end to end), plus relay-only failures surfaced through failure().
var _t := 0
func _clock() -> int: return _t
func _pair(hub: FakeRelayHub, code: String = "ABCDE") -> Array[RelayTransport]: var h := _tr(hub) var c := _tr(hub) assert_eq(h.host(code), OK) _pump([h]) assert_eq(c.join(code), OK) _pump([h, c], 4) return [h, c]
func _tr(hub: FakeRelayHub) -> RelayTransport: var t := RelayTransport.new() t.clock_ms = _clock t.make_socket = func() -> RelaySocket: return hub.socket() return t
func _pump(ts: Array, n: int = 2) -> Array: var got: Array = [] for _i in range(n): _t += 16 for t: RelayTransport in ts: got.append_array(t.poll()) return got
func test_host_is_peer_1_and_each_side_sees_the_other() -> void: var p := _pair(FakeRelayHub.new()) assert_eq(p[0].local_id(), 1) assert_eq(p[1].local_id(), 2) assert_eq(p[0].peers(), PackedInt32Array([2])) assert_eq(p[1].peers(), PackedInt32Array([1])) assert_eq(p[0].kind(), "relay")
func test_send_reaches_the_peer_unchanged_and_in_order() -> void: var p := _pair(FakeRelayHub.new()) for i in range(50): p[0].send(2, PackedByteArray([i]), i % 2 == 0) # reliable flag ignored: the relay is ordered var seen: Array[int] = [] for pkt: Dictionary in _pump([p[1]], 4): assert_eq(int(pkt["peer"]), 1) seen.append(int((pkt["bytes"] as PackedByteArray)[0])) assert_eq(seen, range(50))
func test_nothing_is_sent_before_the_welcome() -> void: var hub := FakeRelayHub.new() var h := _tr(hub) h.host("ABCDE") assert_eq(h.peers(), PackedInt32Array()) h.send(2, PackedByteArray([1]), true) # must not crash or queue assert_false(h.is_welcomed())
func test_rtt_is_measured_end_to_end_from_ping_pong() -> void: var hub := FakeRelayHub.new() hub.latency_polls = 3 var p := _pair(hub) assert_eq(p[1].rtt_ms(), -1) _pump(p, 20) # one PING_MS window plus the round trip assert_gt(p[1].rtt_ms(), 0) assert_eq(p[1].loss_pct(), -1.0)
func test_join_unknown_code_fails_with_no_room() -> void: var c := _tr(FakeRelayHub.new()) c.join("ZZZZZ") _pump([c], 3) assert_eq(c.failure(), "no_room") assert_eq(c.peers(), PackedInt32Array())
func test_second_host_on_a_code_gets_code_taken() -> void: var hub := FakeRelayHub.new() var a := _tr(hub) a.host("ABCDE") _pump([a]) var b := _tr(hub) b.host("ABCDE") _pump([a, b], 3) assert_eq(b.failure(), "code_taken") assert_eq(a.failure(), "")
func test_third_member_gets_room_full() -> void: var hub := FakeRelayHub.new() var p := _pair(hub) var x := _tr(hub) x.join("ABCDE") _pump([p[0], p[1], x], 3) assert_eq(x.failure(), "room_full")
func test_disconnect_peer_delivers_the_last_packet_then_frees_the_slot() -> void: var hub := FakeRelayHub.new() var p := _pair(hub) p[0].send(2, LockstepProtocol.encode_text(LockstepProtocol.MSG_REJECT, "build mismatch"), true) p[0].disconnect_peer(2) assert_eq(p[0].peers(), PackedInt32Array(), "leaves peers() at once") var got := _pump([p[0], p[1]], 4) var reject := false for pkt: Dictionary in got: if LockstepProtocol.decode(pkt["bytes"]).get("type", 0) == LockstepProtocol.MSG_REJECT: reject = true assert_true(reject, "the REJECT queued before the kick still arrives") assert_eq(p[1].failure(), "kicked") assert_eq(hub.member_count("ABCDE"), 1, "slot free for the next joiner")
func test_host_leaving_injects_one_bye_on_the_client() -> void: var p := _pair(FakeRelayHub.new()) p[0].close() var byes := 0 for pkt: Dictionary in _pump([p[1]], 4): var m := LockstepProtocol.decode(pkt["bytes"]) if int(m.get("type", 0)) == LockstepProtocol.MSG_BYE: byes += 1 assert_eq(int(pkt["peer"]), 1) assert_eq(byes, 1) assert_eq(p[1].failure(), "host_left")
func test_failed_handshake_is_connect_failed() -> void: var hub := FakeRelayHub.new() var c := RelayTransport.new() c.clock_ms = _clock c.make_socket = func() -> RelaySocket: var s := hub.socket() s.refuse_handshake = true return s c.join("ABCDE") _pump([c], 3) assert_eq(c.failure(), "connect_failed")
func test_connect_timeout() -> void: var c := _tr(FakeRelayHub.new()) c.make_socket = func() -> RelaySocket: return RelaySocket.new() # base class: CONNECTING forever c.join("ABCDE") _t += RelayTransport.CONNECT_TIMEOUT_MS + 1 c.poll() assert_eq(c.failure(), "connect_failed")
func test_room_url_and_random_code() -> void: assert_eq(RelayTransport.room_url("wss://r.example/", "ABCDE", "join"), "wss://r.example/mp/room/ABCDE?role=join&v=1") var rng := RandomNumberGenerator.new() rng.seed = 5 var code := RelayTransport.random_code(rng) assert_eq(code.length(), LanRegistry.CODE_LEN) assert_eq(LanRegistry.normalize_code(code), code)
func test_url_override_wins() -> void: RelayTransport.url_override = "ws://127.0.0.1:8787" assert_eq(RelayTransport.base_url(), "ws://127.0.0.1:8787") RelayTransport.url_override = "" assert_eq(RelayTransport.base_url(), RelayTransport.DEFAULT_URL)(The base RelaySocket must therefore return State.CONNECTING from state() and do nothing else; that is its documented “interface” behaviour.)
- Step 3: Run, expect FAIL (import, then the GUT file).
- Step 4: Implement the three
net/files and the fake hub to the interfaces above. Key points forRelayTransport.poll()order (preflight ruling F2: frames BEFORE the close):sock.poll()→ CONNECTING timeout viaclock_ms→ drain every received frame first: CTRL welcome/join/leave (a new peer gets its first PING at once), DATA → inbox, PING → reply PONG, PONG → RTT → then handle CLOSED:failure = RelayFrames.failure_for_close(code), else"connect_failed"if never welcomed, else"connection_lost"; synthetic BYE once per the Behaviour rule ("left"forleave{peer:1}/ 4010, none for 4005kicked,"connection_lost"for any other loss of the host after the welcome) → periodic PING → return and clear inbox. RTT EWMA: first sample sets_rtt, later_rtt += RTT_ALPHA * (sample - _rtt); sample =(clock_ms() - sent_ms) & 0xFFFFFFFF, ignored if > 60000. - Step 5: Run, expect PASS. Re-run
tests/test_loopback_transport.gdandtests/test_enet_transport.gd(contract unchanged). - Step 6:
bash scripts/check-test-count.shgreen, determinism unmoved; restoreproject.godot/audio/bus_layout.tres; commit “net: RelayTransport (WebSocket relay NetTransport) + FakeRelayHub”.
Task 5: Lockstep sessions over the relay (end to end, GUT)
Section titled “Task 5: Lockstep sessions over the relay (end to end, GUT)”Files:
- Create:
tests/test_relay_session.gd
Interfaces:
-
Consumes:
RelayTransport,FakeRelayHub(Task 4);NetCoopRun.create_host/create_client/frame/mark_ready/waiting_for_ready/sim/local_pilot/local_offer/pick/offer_gen/is_ended,LockstepSession.end_reason/stalls/checksums_compared/desync_tick(existing). Reuse the_make_sim,_ai_input,_maybe_pickpattern fromtests/test_net_coop_run.gd(copy the three helpers in; do not import across test files). -
Produces: nothing for later tasks (pure verification), but its passing is the precondition for Task 9.
-
Step 1: Write tests (no production code expected to change; if one fails, the bug is in Task 4’s transport, fix it there test-first):
test_two_devices_play_through_level_ups_over_the_relay_without_desync—FakeRelayHub.latency_polls = 4(~65 ms one-way at 16 ms polls), host + clientNetCoopRunoverRelayTransports, autopilots on both,dev_invuln, loop until both pilots reach level 6 or 30,000 frames; assert both running,host.sim().net_checksum() == client.sim().net_checksum()at the samenext_tick(step the slower one until equal),client.session.checksums_compared > 0,desync_tick == -1.test_client_inputs_are_not_late_with_relay_rtt— after warm-up (past the first PONG), snapshotinputs_late/inputs_scheduled, play on, then assert on the DELTAS:d_late * 10 <= d_scheduled(preflight ruling: the cumulative counters include the pre-first-PONG window wherertt_ms() == -1) (provesrtt_ms()feeds_lead_ticks(); withrtt_ms() == -1this fails, which is the reason row 4 of the spec table exists).test_build_mismatch_is_rejected_with_the_lan_text— client BUILD+1; clientend_reasonbegins with"rejected: build mismatch"; host emittedpeer_rejected; then a correct client joins the SAME code and the run starts (slot freed bydisconnect_peer→ kick). Then pump pastLockstepSession.HELLO_DEADLINE_MS + 1000ms ofclock_mstime and assert the second client is STILL running (it reuses peer id 2, and the host session’s deferred force-disconnect of the refused id must not kick it; preflight ruling F1).test_host_leaving_ends_the_client_at_once— hostclose()mid-run; within 5 polls the client is ended withend_reason == "host_left: left"(nothost_timeout).test_client_leaving_drops_its_pilot_and_the_host_plays_on— clientNetCoopRun.close(); hostdrop_reason == "left", host still ticking.
-
Step 2: Run
-gtest=res://tests/test_relay_session.gd→ PASS (or fix Task 4 code test-first and re-run). -
Step 3:
bash scripts/check-test-count.shgreen; determinism files unmoved (they are in the run). Commit “test: lockstep co-op end to end over RelayTransport”.
Task 6: The remote-config gate features.internet_coop
Section titled “Task 6: The remote-config gate features.internet_coop”Files:
- Create:
net/online_coop.gd,tests/test_online_coop_gate.gd - Modify:
net/remote_config.gd,net/remote_config_state.gd,tools/remote_config/dump_manifest.gd,scripts/publish-config.mjs,scripts/test/publish-config.test.mjs,tests/test_remote_config*.gd(whichever file coversvalid(); grepfunc test_.*validundertests/)
Interfaces:
- Produces:
class_name OnlineCoop extends RefCounted:const FEATURE := "internet_coop",const FEATURES: Array[String] = ["internet_coop"],static func enabled(dev: bool, features: Dictionary) -> bool(dev → true; elsefeatures.get(FEATURE) is bool and true)RemoteConfig:static var features: Dictionary = {}(static, exactly likestatic var notice),signal features_changed,static func features_of(raw: Variant) -> Dictionary(keeps only keys inOnlineCoop.FEATURESwhose values arebool),func _seed_cached() -> void(the cache readssetup()does: notice + features)- Manifest JSON gains
"features": ["internet_coop"].
Why a top-level key and not a bool tunable (spec table row 6): main._lan_open resets all tunables to bundled defaults for every networked session (remote_config.apply_for_run({})), and a flag tunable would also enter config_hash (the join handshake) and only take effect at the next run start. The gate must be readable at the start menu and survive lobby resets, exactly like notice.
- Step 1: Failing GUT tests
tests/test_online_coop_gate.gd(helpers copied fromtests/test_remote_config_refresh.gd:_node()builds aRemoteConfigwithsetup(36, "test-install"), which uses_NullStoreheadless;_respond()feeds_on_donelike a real fetch):
extends GutTest
# Internet co-op gate (Phase 4): Dev builds always; store builds only when the fetched remote-config# patch says "features": {"internet_coop": true}. A kill switch ({}) or anything malformed is OFF.
func after_each() -> void: RemoteConfig.active_id = "default" RemoteConfig.active_hash = 0 RemoteConfig.active_patch = {} RemoteConfig.notice = {} RemoteConfig.features = {}
func _node() -> RemoteConfig: var rc := RemoteConfig.new() add_child_autofree(rc) rc.setup(36, "test-install") return rc
func _respond(rc: RemoteConfig, d: Dictionary) -> void: rc._on_done(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(d).to_utf8_buffer())
func test_dev_builds_are_always_on() -> void: assert_true(OnlineCoop.enabled(true, {}))
func test_store_builds_need_the_feature_true() -> void: assert_false(OnlineCoop.enabled(false, {})) assert_false(OnlineCoop.enabled(false, {"internet_coop": false})) assert_false(OnlineCoop.enabled(false, {"internet_coop": "true"})) assert_false(OnlineCoop.enabled(false, {"internet_coop": 1})) assert_true(OnlineCoop.enabled(false, {"internet_coop": true}))
func test_features_of_keeps_known_bools_only() -> void: assert_eq(RemoteConfig.features_of({"internet_coop": true, "teleport": true, "x": 1}), {"internet_coop": true}) assert_eq(RemoteConfig.features_of("nope"), {}) assert_eq(RemoteConfig.features_of({"internet_coop": "yes"}), {})
func test_a_patch_with_features_is_valid_and_a_bad_one_is_not() -> void: assert_true(RemoteConfigState.valid({"config_id": "b330-001", "features": {"internet_coop": true}})) assert_false(RemoteConfigState.valid({"config_id": "b330-001", "features": []}))
func test_on_done_sets_features_and_emits() -> void: var rc := _node() watch_signals(rc) _respond(rc, {"config_id": "f1", "tunables": {}, "bible": {}, "features": {"internet_coop": true}}) assert_true(OnlineCoop.enabled(false, RemoteConfig.features)) assert_signal_emitted(rc, "features_changed")
func test_fetched_features_survive_a_lobby_reset() -> void: # The bug this design avoids: _lan_open's apply_for_run({}) resets tunables. Features must not care. # Regression pin only: it cannot fail today (apply_for_run never touches a static it doesn't know); # it fails if someone later moves the gate into the tunable/apply path. var rc := _node() _respond(rc, {"config_id": "f1", "features": {"internet_coop": true}}) rc.apply_for_run({}) assert_true(OnlineCoop.enabled(false, RemoteConfig.features))
func test_kill_switch_turns_it_off() -> void: var rc := _node() _respond(rc, {"config_id": "f1", "features": {"internet_coop": true}}) _respond(rc, {}) assert_false(OnlineCoop.enabled(false, RemoteConfig.features))
func test_the_cached_patch_seeds_features_at_boot() -> void: # setup() seeds notice AND features from the cached last-good patch (via _seed_cached), so a flip # reaches the first menu after a relaunch without waiting for the network. # setup() FIRST (preflight ruling F11): without it state.store is null and receive()'s _save() crashes. var rc := _node() RemoteConfig.features = {} rc.state.receive({"config_id": "cached", "tunables": {}, "bible": {}, "features": {"internet_coop": true}}) rc._seed_cached() assert_true(OnlineCoop.enabled(false, RemoteConfig.features))- Step 2: Failing node test in
scripts/test/publish-config.test.mjs(follow the file’s existing manifest fixture style):features: {internet_coop: true}withmanifest.features = ["internet_coop"]→ no problemsfeatures: {internet_coop: "yes"}→features: internet_coop must be true or falsefeatures: {teleport: true}→features: unknown key teleportfeatures: []→features must be an object- manifest without
featuresand a patch withfeatures→features: this build's manifest lists no features (the build predates the features gate; its client would ignore them)
- Step 3: Run both, expect FAIL.
- Step 4: Implement.
net/online_coop.gdper interface (plus Task 9’s text helpers later).net/remote_config.gd:static var features: Dictionary = {},signal features_changed;static func features_of;func _update_features(raw: Variant)(setfeatures = features_of(raw), emitfeatures_changedon change);func _seed_cached()=_update_notice(state.peek_for_run().get("notice", {}))+_update_features(state.peek_for_run().get("features", {})), and thesetup()line 77 call becomes_seed_cached(); in_on_done(line ~135) add_update_features(state.peek_for_run().get("features", {}))next to_update_notice(parsed.get("notice", {})): read features from the ACCEPTED patch afterreceive(), NOT fromparsed, so a blacklisted (crashed) patch thatreceive()refused can never open the gate (preflight ruling; unlike the notice, which deliberately readsparsed). A kill switch{}yields{}. AddRemoteConfig.features = {}toafter_eachoftests/test_remote_config_refresh.gdtoo (static state leaks between tests otherwise).net/remote_config_state.gdvalid(): addand rest.get("features", {}) is Dictionary.tools/remote_config/dump_manifest.gd:"features": OnlineCoop.FEATURESinout.scripts/publish-config.mjs:if ("features" in patch) validateFeatures(patch.features, manifest, p);with the messages above.- Regenerate the manifest for the current BUILD the way
remote-config/README.mdsays (do NOT publish).
- Step 5: Run, expect PASS. Then
node --test scripts/test/*.test.mjs. - Step 6:
bash scripts/check-test-count.shgreen, determinism unmoved (the gate never reaches/sim; it is not inconfig_hash). Add a short## Features — top-level gates (2026-10-14)section todocs/architecture/remote-config.md(why top-level, how to flip, kill switch, per-channel patch rule from Task 13). Commit “remote-config: features gate (internet_coop) read like notice”.
Task 7: CodePicker — room-code entry for pad, Siri Remote, touch and keyboard
Section titled “Task 7: CodePicker — room-code entry for pad, Siri Remote, touch and keyboard”Files:
- Create:
ui/code_picker.gd,tests/test_code_picker.gd
Interfaces:
- Produces:
class_name CodePicker extends HBoxContainer;signal submitted(code: String);const LEN := LanRegistry.CODE_LEN;func code() -> String;func set_code(c: String) -> void;func focus_first() -> void;var cursor := 0. - Behaviour (one control works everywhere, no system keyboard needed, which tvOS lacks in store builds):
- 5 cells (Buttons,
focus_mode = FOCUS_ALL, min size 64×72, fontfont.3xl, title font), each showing a character ofLanRegistry.CODE_ALPHABETor·when unset, plus a trailing “Join” Button. ui_up/ui_downon a focused cell cycle that cell through the alphabet (wraps; first press on an unset cell →A/9). Siri Remote swipes arrive asui_up/down/left/rightthrough the fork’s input mapping, same as the pad.ui_left/ui_rightmove focus between cells (default focus neighbours).ui_accepton a cell moves to the next cell; on the last cell or “Join” →submitted(code())if all 5 set.- A physical keyboard (Mac, iPad keyboard): a printable key →
LanRegistry.normalize_code-valid char sets the current cell and advances; Backspace clears the current cell if it is set, otherwise steps back one cell and clears that (preflight ruling F13); Enter submits. - Touch (iPhone/iPad,
Platform.is_touch()): tapping a cell focuses it and shows aLineEdit(max 5, uppercase) above the cells that opens the system keyboard; its text mirrors into the cells. The cells stay usable by tap-and-swipe for players who avoid the keyboard.
- 5 cells (Buttons,
- Step 1: Failing tests
tests/test_code_picker.gd: drive withInput.parse_input_eventofInputEventAction(ui_up,ui_down,ui_right,ui_accept) andInputEventKey(unicode) on a picker added to the tree,await get_tree().process_framebetween events:- up on an empty first cell →
code()starts with"A"; down ONCE from there →"9"(wraps A → 9 in one press; preflight ruling F12). - typing
a b c d e(unicode) →code() == "ABCDE"; typing0(not in alphabet) changes nothing. - Backspace after 5 chars →
code() == "ABCD"length 4 and cursor on cell 4 (the cursor sits on the set last cell after the 5th char, so Backspace clears it in place); a second Backspace →"ABC", cursor 3. - accept on the last cell with a full code emits
submitted("ABCDE")exactly once (usewatch_signals); with 4 chars it emits nothing. set_code("abcde")→"ABCDE";set_code("toolong!")→ unchanged.
- up on an empty first cell →
- Step 2: Run, FAIL. Step 3: Implement. Step 4: Run, PASS.
- Step 5:
bash scripts/check-test-count.shgreen; determinism unmoved (UI only). Commit “ui: CodePicker for room codes (pad / Siri Remote / touch / keyboard)”.
Task 8: OnlineLobby + failure/end text + device-profile fit tests
Section titled “Task 8: OnlineLobby + failure/end text + device-profile fit tests”Files:
- Create:
ui/online_lobby.gd,tests/test_online_lobby.gd - Modify:
net/online_coop.gd(text helpers)
Interfaces:
- Consumes:
CodePicker(Task 7),ShopLayoutFixture.profile()/DEVICES(tests),Tokens,TokenBind,NeonTheme. - Produces:
class_name OnlineLobby extends CanvasLayer: signalscancelled,host_requested,join_requested(code: String);func show_choice() -> void(two big buttons HOST / JOIN + Cancel);func show_hosting(code: String) -> void(code infont.4xl, “Tell your friend this code. Waiting for them to join…”);func show_joining() -> void(CodePicker focused);func set_status(text: String) -> void;func show_failure(text: String) -> void(status indanger.main, focus back to the relevant control).layer = 72;ui_cancel→cancelled(B / Esc / Siri Menu), asLanLobbydoes.OnlineCoop.failure_text(reason: String) -> String— player-facing, store-safe, maps BOTH relay failures and session end reasons:
| reason (prefix match) | text |
|---|---|
no_room |
No game with that code. Check the code on your friend's screen. |
room_full |
That game already has two players. |
code_taken |
(never shown: the host retries a new code) Could not open a room. Try again. |
connect_failed |
Couldn't reach the co-op server. Check your internet connection and try again. |
connection_lost, host_timeout |
Lost the connection to your friend's game. |
host_left: dropped:, host_left: connection_lost (checked BEFORE host_left; preflight ruling: a client the host dropped ends host_left: dropped: timeout / ready_timeout / disconnected, and Task 4 injects BYE "connection_lost" on a non-leave host loss) |
Lost the connection to your friend's game. |
host_left |
Your friend left the game. |
kicked, rejected: handshake timeout |
Your friend's game didn't accept the connection. Try again. |
rejected: build mismatch, rejected: protocol mismatch |
You and your friend have different versions of Dark Cosmos. Update both, then try again. |
rejected: config mismatch |
Your games have different game data. Restart both games, then try again. |
rejected: session already in progress |
That game has already started. |
rate_limited, too_big, bad_request |
Something went wrong with the connection. Try again. |
backgrounded |
The co-op game ended because Dark Cosmos went to the background. |
desync |
The two games fell out of step, so the co-op game ended. |
| anything else | The co-op game ended. |
OnlineCoop.peer_left_text(drop_reason: String) -> String(host side, mid-run):left/backgrounded→Your friend left. You can keep playing.;timeout/disconnected→Lost your friend's connection. You can keep playing.; else same asleft.- Step 1: Failing tests
tests/test_online_lobby.gd:- Table test over every row above (
OnlineCoop.failure_text), including that no text contains—,BUILD,config_hashor an underscore. show_hosting("K7MPQ")shows the code label with textK7MPQand focuses Cancel (controller host must reach it).show_joining()focuses the picker’s first cell; pickersubmitted("ABCDE")re-emits asjoin_requested("ABCDE").ui_cancelemitscancelled.- Fit at real device profiles (CLAUDE.md rule): for
dev in ["tv", "iphone", "ipad"], host the lobby inside aSubViewportsized byShopLayoutFixture.profile(dev)["vp"]exactly astests/test_stats_sheet_fit.gd::_viewportdoes (copy that helper, includingPlatform.force_safe_insets_pt,force_screen_scale,LiveTokens.set_platform,TokenBind.flush_now()and theafter_eachrestore), for EACH of the three views AND with the longest failure text shown; assert every visible Control’sget_global_rect()lies inside the viewport rect minus the safe-area insets, and that the five code cells plus Join fit on one row (no cell’s rect.end.x beyond the inset edge). iPad is the narrowest (985), so it is the case that bites.
- Table test over every row above (
- Step 2: FAIL. Step 3: Implement (structure mirrors
ui/lan_lobby.gd: dim ColorRect, CenterContainer, VBox withTokens.space(3); statuscustom_minimum_size.x = min(560, vp_width - 2*inset)read at_ready, word-wrapped). Step 4: PASS. - Step 5:
bash scripts/check-test-count.shgreen, determinism unmoved. Runbh-visual-qa(orzsh scripts/ui-sweep.shif it covers new layers) for the three device captures before merge; attach in the review. Commit “ui: OnlineLobby + store-safe co-op failure text, fit-tested at tv/iphone/ipad”.
Task 9: main.gd integration (menu row, host/join, background, CLI)
Section titled “Task 9: main.gd integration (menu row, host/join, background, CLI)”Files:
- Modify:
main.gd,ui/start_menu.gd,net/net_coop_run.gd(close(reason)),net/online_coop.gd - Create:
tests/test_online_coop_main.gd - Modify tests:
tests/test_start_menu*.gd(whichever pinsbuild_items; grepbuild_items(undertests/),tests/test_replay_autostart.gd(Main.lan_cli_requestedasserts)
Interfaces:
-
Consumes: Tasks 4, 6, 8. Existing main seams:
_lan_close,_lan_join_with(t),_lan_new_session,_net_factory,_start_net_run,_lan_pump,_net_on_ended,_net_make_banner/_net_update_banner,_lan_report_session(reason). -
Produces:
StartMenu.build_items(crystals_only: bool, progressed: bool, dev: bool, online: bool = false) -> Array: whenonline, append["online", "CO-OP ONLINE", "Play with a friend over the internet", &"accent.soft", "action"]right after the mode rows; newsignal online_requested; newvar online_allowed := falseonStartMenu, set by main’s_show_start_menubeforeadd_child(exactly likehas_progressed), andStartMenu._ready(start_menu.gd:191, the ONLY caller ofbuild_items; main never calls it) passes it as the 4th arg;_activategains"online": online_requested.emit()(preflight ruling F14).- main:
var _net_relay: RelayTransport = null;var _online := false;var _net_new_relay: Callable = func() -> RelayTransport: return RelayTransport.new()(test seam);var _online_dev: Callable = BuildConfig.dev_tools(test seam for a store build: GUT runs in the editor, soBuildConfig.dev_tools()is always true andrender/build_config.gdhas no override; precedent_sfx_audition_command(cmd, dev); preflight ruling F4);var _online_rng := RandomNumberGenerator.new()(test seam: seed it to predict the host code);var _online_tries := 0(host-code retries left);var _online_lobby: OnlineLobby = null(preflight ruling F15);func _online_allowed() -> bool(OnlineCoop.enabled(_online_dev.call(), RemoteConfig.features));func _online_open(action: String = "", code: String = "") -> void;func _online_host_attempt(tries_left: int) -> void;func _online_join(code: String) -> void;func _online_pump() -> void(called from_lan_pumpwhile_onlineand not started). NetCoopRun.close(reason: String = "left")sendsBYE reason(default unchanged).
-
Step 1: Failing tests.
- Start menu:
build_items(true, false, false, false)has noonlinerow;build_items(true, false, false, true)has it right aftersurvival. tests/test_replay_autostart.gd:Main.lan_cli_requested(["--mp-host-online"])andMain.lan_cli_requested(["--mp-join-code=ABCDE"])are both true (preflight ruling F6).tests/test_online_coop_main.gd(model ontests/test_lan_coop_main.gd: twomain.tscninstances, its frame-pumping helpers,net_allowedhandling): both mains get_net_new_relay = func() -> RelayTransportreturning a transport whosemake_socketuses ONE sharedFakeRelayHuband whoseclock_msis a fake clock the test advances 16 ms per_frame(Task 10 assertsrtt_mean > 0on this run; preflight ruling F5).- Host main
_online_open("host")→ its lobby shows a 5-char code; joiner main_online_open("join", <that code>)→ both reach_net_run(), autopilot (_net_auto = true), play 1,800 ticks, equalsim.net_checksum()at equal ticks. - Joiner with a wrong code → its lobby status equals
OnlineCoop.failure_text("no_room"); nolan_runsent (a lobby that never connected is no session, existing rule). code_taken: pre-occupy the code the host will pick (seed the host’s RNG via a test seam_online_rng), host retries and shows a DIFFERENT code.- Store build gating: with
m._online_dev = func() -> bool: return false(the new seam; there is no existingdev_tools()seam) andRemoteConfig.features = {},_online_open("host")does nothing and a start menu built by_show_start_menuhasonline_allowed == falseand noonlinerow; with{"internet_coop": true}it opens and the row is there. - Background: during a run,
m._notification(NOTIFICATION_APPLICATION_PAUSED)on the CLIENT main → the client’s session closes with BYEbackgrounded, itslan_rungoes out withreason == "backgrounded", the host main’s session showsdrop_reason == "left"and keeps ticking.NOTIFICATION_APPLICATION_FOCUS_OUTdoes NOT end the session (tvOS resign-active: controller UI, Siri press; and Mac alt-tab during two-window testing). - Banner copy: in a store build (
_online_dev→ false) the in-run banner contains none of the dev fields"tick ","stalls","HOST","CLIENT","rtt"(preflight ruling F19: the live banner never containst=/sum=, so that assert could not fail); after an end its text EQUALSOnlineCoop.failure_text(end_reason); behind the start barrier it shows"Waiting for your friend…". - Lobby hygiene (preflight rulings F16-F18): while the
OnlineLobbyis openstart_menu.visible == false, and cancel restores it (visible == true); at run start_online_lobby == null; a joiner whose relay fails before the welcome (wrong code) has_net == nulland_net_relay == nullafter one pump, and pumping 6 s more sends nolan_run.
- Host main
- Start menu:
-
Step 2: FAIL. Step 3: Implement.
_online_open:if not _online_allowed(): return;_lan_close();remote_config.apply_for_run({})+SfxMix.apply()(same reason as_lan_open: both peers on shipped defaults so the config hash matches); createOnlineLobbyinto_online_lobby, wirecancelled → _lan_close,host_requested → func(): _online_tries = 5; _online_host_attempt(_online_tries),join_requested → _online_join;_online = true; hide the start menu (start_menu.visible = falsewhen not null, as the shop/ship/bestiary actions do: the menu sits on a lower layer and its_inputwould otherwise also takeui_accept; preflight ruling F16);action == "host"→ host at once,"join"+ code → join at once,""→show_choice(). Headless / GUT (net_allowed == false): like_lan_open, refuse with a lobby status unless the_net_new_relayseam was replaced by a test._online_host_attempt(n):_online_tries = n;code := RelayTransport.random_code(_online_rng);t := _net_new_relay.call();t.host(code);_net_relay = t;_net = NetCoopRun.create_host(t, randi(), _net_factory, Sim_Const.BUILD, RemoteConfig.active_hash); connectpeer_rejectedto_online_lobby.set_status(...)(OnlineLobbyhas noshow_host_alert; that isLanLobby’s; preflight ruling);_net_started = false;_lan_new_session(); lobbyshow_hosting(code)._online_join(code):t := _net_new_relay.call();t.join(code);_net_relay = t;_lan_join_with(t); status"Connecting…"._online_pump()(before the existing_lan_pumpsession checks), only whilenot _net_started:- Session end first (preflight ruling F3): if
_net != null and _net.is_ended()→_online_lobby.show_failure(OnlineCoop.failure_text(_net.session.end_reason))(so a build-mismatched joiner, whose relay failure iskicked, sees the version text), instead ofLanLobby.join_failure_text. - Else relay failure:
if _net_relay != null and _net_relay.failure() != ""→code_takenand host → close_net,_online_host_attempt(_online_tries - 1)(give up at 0 withfailure_text("code_taken")); otherwise_online_lobby.show_failure(OnlineCoop.failure_text(_net_relay.failure())),_lan_report_session("lobby")only if the session had connected (welcomed). - After showing a failure, once (preflight ruling F18):
_net.close(),_net = null,_net_relay = null, soshow_failureis not re-run every frame, nohost_timeoutfollows 5 s later, and_lan_closesends nolan_runfor a lobby that never connected.
- Session end first (preflight ruling F3): if
_lan_closeclears_net_relay,_online, frees_online_lobby(and nulls it); ifstart_menu != nullsetstart_menu.visible = trueand callstart_menu.refocus()(cancel path; preflight ruling F16)._start_net_run: alsoqueue_free()+ null_online_lobbynext to the existing_net_lobbyfree (main.gd ~5085; preflight ruling F17)._notification:if what == NOTIFICATION_APPLICATION_PAUSED and _online and _net != null:→_lan_report_session("backgrounded"),_net.close("backgrounded"), mark ended, set_net_end_text = OnlineCoop.failure_text("backgrounded"). Only PAUSED; never FOCUS_OUT._net_on_ended/ banner: when_online and not _online_dev.call()useOnlineCoop.failure_text(end_reason)and a neutral colour; Dev keeps today’s dev text. Host side mid-run peer drop: showOnlineCoop.peer_left_text(session.drop_reason)for 4 s.- Start menu: in
_show_start_menu,start_menu.online_allowed = _online_allowed()BEFOREadd_child(start_menu)(_readyreads it intobuild_items); connectonline_requested → _online_open(). A flip reaches a player at the next menu build (boot reads the cached patch, Task 6), documented. Main.lan_cli_requested(main.gd:629): also true for--mp-host-online(with or without=CODE) and--mp-join-code=(preflight ruling F6).- CLI (
_lan_cli_boot):--mp-relay=<url>setsRelayTransport.url_override;--mp-host-online[=CODE](Dev only: a fixed CODE so a second process can join without reading stdout);--mp-join-code=CODE. Both honour--mp-auto/--mp-ticks=Nand print the existing[lan] host|client t=.. sum=..lines. Also print[relay] code=XXXXXon host. Gate the--mp-*-onlineflags on_online_dev.call()(=BuildConfig.dev_tools()outside tests) like the existing ones (theif BuildConfig.dev_tools(): _lan_cli_boot.call_deferred()check at main.gd:591-592). - Drone Bay / dimensions / live tunables stay refused in a net run (existing
_net_run()guards cover the online path because it reuses_net).
-
Step 4: PASS (the new main test,
test_lan_coop_main.gd,test_lan_easy_join_main.gd, start-menu tests). -
Step 5:
bash scripts/gate.sh(full: boot smoke + device-boot smoke catch any boot-timeRelayTransport/OnlineLobbymisuse); determinism unmoved. Commit “main: online co-op via the relay (gated menu row, lobby, background ends cleanly, CLI)”.
Task 10: Telemetry — RTT + stalls for relay sessions
Section titled “Task 10: Telemetry — RTT + stalls for relay sessions”Files:
- Modify:
main.gd(_lan_summary),docs/architecture/dev-tools.md(§ Telemetry v2lan_run),tests/test_online_coop_main.gd(or the existing lan_run telemetry test; greplan_runintests/)
Interfaces:
- Consumes:
RelayTransport.kind() == "relay",rtt_ms(),failure();NetSessionTelemetry(unchanged: it samplesrtt_ms()once a second and skips-1). - Produces:
lan_rungains exactly one field,relay_failure(string, only whentransport == "relay";""when the relay never failed). No other new field:transport,rtt_ms,rtt_mean,rtt_max,link_samples,stalls,cushion_waits,pause_waits,ticks,end_reason,drop_reason,ready_wait_msalready exist.loss_pct/loss_maxstay-1on the relay (TCP).
Privacy check (CLAUDE.md telemetry rule): relay_failure is “why the session ended”, already enumerated in the privacy page and compliance.md; the relay’s datacenter (cf.colo) is deliberately NOT captured (coarse location). Task 12 still has to update the “dev builds only” and enet / loopback wording.
- Step 1: Failing test: after test 1 of Task 9’s relay run ends via
--mp-ticks-style quit or_lan_close, capture the pushedlan_run(the existing EventQueue spy seam;EventQueue.pushis a no-op headless, so use the injectable spy the LAN telemetry test uses) and asserttransport == "relay",rtt_mean > 0,link_samples > 0, (preflight ruling F5: main pumps on the wall clock and a fast headless run can finish before the first PING or measurertt = 0, so the test’s_net_new_relaysets each transport’sclock_msto a fake clock the test advances 16 ms per_frame; Task 9’stests/test_online_coop_main.gdhelpers should do this from the start)stalls >= 0,relay_failure == "",loss_pct == -1. A second case: joiner whose host closes mid-run →relay_failure == "host_left",reason == "session_end". - Step 2: FAIL. Step 3: Implement in
_lan_summary:if ls.transport is RelayTransport: d["relay_failure"] = (ls.transport as RelayTransport).failure(). - Step 4: PASS. Update
dev-tools.mdlan_runfield list:transport=enet/loopback/relay;relay_failure; RTT on the relay = end-to-end PING/PONG through the DO; loss always -1 there. Add the measurement query used for “done when”:
SELECT json_extract(data,'$.role') role, json_extract(data,'$.ticks') ticks, json_extract(data,'$.rtt_mean') rtt_mean, json_extract(data,'$.rtt_max') rtt_max, json_extract(data,'$.stalls') stalls, json_extract(data,'$.cushion_waits') cushion, json_extract(data,'$.end_reason') end_reason, json_extract(data,'$.relay_failure') relay_failure, json_extract(data,'$.reason') reasonFROM events WHERE type = 'lan_run' AND json_extract(data,'$.transport') = 'relay'ORDER BY rowid DESC LIMIT 20;(run via bh-telemetry / the console MCP telemetry_query; events is the generic table every type lands in.) No telemetry Worker change and no D1 migration (lan_run is a generic event): telemetry/deploy.sh is not involved.
- Step 5:
bash scripts/check-test-count.shgreen, determinism unmoved. Commit “telemetry: lan_run carries relay transport, end-to-end RTT, relay_failure”.
Task 11: Local two-process test against wrangler dev
Section titled “Task 11: Local two-process test against wrangler dev”Files:
- Create:
scripts/relay-local-test.sh - Create:
relay/README.md(a STUB: title, one paragraph, and thescripts/relay-local-test.shusage; Task 13 fills it in and lists it under Modify; preflight ruling F7) - Modify:
docs/reference/running-and-testing.md(one new entry)
This is the parity check between FakeRelayHub and the real DO runtime (hibernation, close codes, binary frames, WebSocketPeer against workerd). Not part of scripts/gate.sh (needs npx wrangler, ports, ~1 min); required before every device test of a relay change and before merge.
- Step 1: Write the script (bash 3.2-safe, macOS:
perl -e 'alarm N; exec @ARGV'instead oftimeout):
#!/usr/bin/env bash# Two headless Godot clients through a LOCAL relay (wrangler dev = workerd, the real DO runtime).# Proves FakeRelayHub parity end to end. Usage: scripts/relay-local-test.sh [ticks] (default 3600)set -euo pipefailcd "$(dirname "$0")/.."ticks="${1:-3600}"port=8787code="TEST2"log="$(mktemp -d)"( cd relay && npx --yes wrangler dev --local --port "$port" > "$log/wrangler.txt" 2>&1 ) &wpid=$!trap 'kill $wpid 2>/dev/null || true' EXITfor _ in $(seq 1 60); do curl -fs "http://127.0.0.1:$port/health" >/dev/null 2>&1 && break; sleep 1; donecurl -fs "http://127.0.0.1:$port/health" >/dev/null || { tail -20 "$log/wrangler.txt"; echo "FAIL: wrangler dev did not come up"; exit 1; }godot --headless --path . --import > /dev/null 2>&1 || trueperl -e 'alarm 300; exec @ARGV' godot --headless --path . -- --mp-host-online="$code" --mp-relay="ws://127.0.0.1:$port" --mp-auto --mp-ticks="$ticks" > "$log/host.txt" 2>&1 &hpid=$!sleep 3perl -e 'alarm 300; exec @ARGV' godot --headless --path . -- --mp-join-code="$code" --mp-relay="ws://127.0.0.1:$port" --mp-auto --mp-ticks="$ticks" > "$log/client.txt" 2>&1 || truewait $hpid || truegit checkout -- project.godot audio/bus_layout.tres 2>/dev/null || trueh=$(grep -E '^\[lan\] host t=' "$log/host.txt" | grep FINAL | sed -E 's/.* t=([0-9]+) sum=([-0-9]+).*/\1 \2/')c=$(grep -E '^\[lan\] client t=' "$log/client.txt" | grep FINAL | sed -E 's/.* t=([0-9]+) sum=([-0-9]+).*/\1 \2/')echo "host: $h"; echo "client: $c"grep -hE '^\[lan\] (host|client) t=' "$log/host.txt" "$log/client.txt" | grep -E 'stalls=' | tail -2[ -n "$h" ] && [ "$h" = "$c" ] && { echo "PASS (logs: $log)"; exit 0; }echo "FAIL: final tick/sum differ or missing (logs: $log)"; exit 1(The sed matches main.gd::_net_report’s verified format: [lan] host|client t=<tick> sum=<checksum> levels=... stalls=... FINAL. The --headless client needs net_allowed = true, which _lan_cli_boot already sets.)
- Step 2: Run it. Expect
PASS, equal final tick + sum,stallssmall (localhost). Also run once withticks=600while killing the host process at ~300 ticks (manual): the client log must showended: … host_leftwithin a second, nothost_timeout. - Step 3: Add a NEW entry to
docs/reference/running-and-testing.md(there is no LAN CLI entry there; the LAN CLI is documented indocs/architecture/coop-and-pilots.md, so point at it; preflight ruling): the script, what it proves, that it is not in the gate. Commit “scripts: relay-local-test (two headless clients through wrangler dev)”.
Task 12: Privacy page, compliance file and the flip rule
Section titled “Task 12: Privacy page, compliance file and the flip rule”Files:
- Modify:
marketing/appstore/compliance.md - Separate repo (
~/Claude/bullet-heaven-site, its own CLAUDE.md andscripts/deploy-site.sh):privacy/index.html“Multiplayer” + “Co-op connection quality” text. Prepared as a branch there, NOT deployed until Chris approves the flip.
This task blocks flipping the gate for any non-Dev channel. It does not block merging the dark code.
- Step 1: In
compliance.md:lan_run“(currently dev builds only)” → “(dev builds, and store builds where online co-op is enabled)”; transport kindsenet / loopback / relay; add a “Multiplayer (online co-op via relay)” paragraph: the two devices connect through our Cloudflare relay, not directly, so neither sees the other’s IP; the relay forwards gameplay inputs only while a game is running and stores nothing (no logs, no database; seerelay/test/no-storage.test.mjs); IPs reach Cloudflare as part of the connection (same as every HTTPS request) and are used, without being stored, to rate-limit connection attempts; room codes are random and exist only while the host’s game is connected. Processors: Cloudflare (Workers + Durable Objects). ASC label: no new data type (still “Other Diagnostic Data” forlan_run); state “no change to collected data types” explicitly so a reviewer does not have to infer it. - Step 2: Site repo branch
online-coop-privacy: rewrite the Multiplayer section’s first paragraph (“only in our internal development builds”) to describe online co-op via the relay in plain language as above, keep the LAN/room-code paragraph scoped to development builds. Do not deploy; report the branch. - Step 3: Commit (this repo) “docs(compliance): online co-op via relay; flip prerequisites”.
Task 13: Worker deploy script, README, deploy sequence (written, not run)
Section titled “Task 13: Worker deploy script, README, deploy sequence (written, not run)”Files:
-
Create:
relay/deploy.sh -
Modify:
relay/README.md(stub created in Task 11; preflight ruling F7) -
Step 1:
relay/deploy.sh(mirrorstelemetry/deploy.shauth handling; refuses unless tests pass):
#!/usr/bin/env bash# Deploy the internet co-op relay Worker. Runs the relay tests (incl. the stores-nothing check) first.# Usage: bash relay/deploy.sh [--dry-run]set -euo pipefailcd "$(dirname "$0")"export CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfcif [ -n "${CLOUDFLARE_API_TOKEN:-}" ]; then echo "deploy.sh: auth = CLOUDFLARE_API_TOKEN" >&2else echo "deploy.sh: auth = wrangler login (OAuth)" >&2; finode --test test/*.test.mjsif [ -n "$(git status --porcelain -- .)" ]; then echo "deploy.sh: relay/ has uncommitted changes; wrangler ships what is on disk, not HEAD. Commit first." >&2; exit 1fiif [ "${1:-}" = "--dry-run" ]; then exec npx --yes wrangler deploy --dry-run; finpx --yes wrangler deploycurl -fsS "https://bullet-heaven-relay.chris-allen-06f.workers.dev/health"; echo- Step 2:
relay/README.md(Starlight frontmatter not required:relay/is not underdocs/; checkdocs-sitecontent globs and addtitle:anyway if the docs site ingests repo READMEs): endpoints, the § Wire format table (copied), limits and close codes, “stores nothing” + where it is enforced, local dev (wrangler dev --local),scripts/relay-local-test.sh, deploy, cost note (spec §6 arithmetic; free-plan DO quota per Task 0 Step 4), kill options (republish the channel patch withoutfeatures;{}only as an all-off kill since it also drops that channel’s tunables/bible/sounds;wrangler deleteas last resort). - Step 3: Deploy sequence (documented in README and repeated here; Chris authorises each outward step):
- Branch
coop-relaygreen:scripts/gate.sh,scripts/relay-local-test.sh,bh-reviewerMERGE. - Worker first:
bash relay/deploy.sh --dry-run, thenbash relay/deploy.sh(needs Chris’s OK: first deploy of a new public endpoint). Verify/health200 and onewscat-style smoke (orscripts/relay-local-test.shpointed at the live URL via--mp-relay=wss://…, two Mac processes over the real internet path). - Only then build any client whose
RelayTransport.DEFAULT_URLpoints at it: Dev installs on ATV + iPhone (bh-deploy/bh-worktree-device-install), device test (Task 15). - Merge to main per
docs/reference/builds-and-releases.md(bh-merge-reviewed-branch), push. - Store 1.1 ships with the code and the gate OFF (no
featureskey in any appstore/testflight patch). Nothing else to do: absent key = OFF. - Flip for testers: after Task 12’s site branch is deployed and compliance updated, publish a testflight-channel patch for the 1.1 BUILD with
"features": {"internet_coop": true}:node scripts/publish-config.mjs <BUILD> --channel testflight. A channel patch replaces the base patch for that channel, so it must carry the base patch’stunables/bible/soundstoo (copyremote-config/build-<BUILD>.jsonand addfeatures). It also serves later builds until they get their own patch. - Kill: republish the same channel patch without
features(keeping itstunables/bible/sounds).{}is the WHOLE-patch kill switch for that channel and also drops its tunables/bible/sounds, so use it only as an all-off kill (preflight ruling F8). Devices pick it up on the next refresh (boot / menu / resume, throttled 5 min) and the row disappears at the next menu build. A session already running is not interrupted. - App Store players: only via a reviewed version (Chris decision A).
- Branch
- Step 4: Commit “relay: deploy script + README (deploy before any client build)”.
Task 14: Architecture docs
Section titled “Task 14: Architecture docs”Files:
-
Modify:
docs/architecture/coop-and-pilots.md,net/net_transport.gd(comment, if not done in Task 4),docs/superpowers/specs/2026-10-07-networked-coop-design.md(a dated “Status 2026-10-14” note at the top pointing at this plan and the stale-rows table; do not rewrite the spec) -
Step 1:
coop-and-pilots.md: new “### Internet co-op (relay, Phase 4)” subsection: files, data flow (RelayTransport→ DO →RelayTransport, same LockstepProtocol bytes), host = peer 1, RTT via PING/PONG, synthetic BYE on host loss, gate, lobby, background rule (PAUSED ends, FOCUS_OUT does not), CLI flags, tests list, the local wrangler test. Fix the stale lines (jitter buffer exists;det_probeexists, cross-device table location). -
Step 2: Docs-site gate:
node --test scripts/test/docsite-frontmatter.test.mjs. Commit “docs: internet co-op relay architecture”.
Task 15: Device test (Chris + Toby; agents never install while a human A/B is live)
Section titled “Task 15: Device test (Chris + Toby; agents never install while a human A/B is live)”Files: none (results go to the plan’s ledger / memory and docs/RELEASE-1.0.1.md or the 1.1 queue).
Preconditions: Task 0 digests equal; Worker deployed (Task 13 step 2); same Dev BUILD on both devices.
- Step 1: Same Wi-Fi first (removes cellular as a variable): ATV hosts from the menu row (Dev build shows it), reads the code; iPhone joins by typing it (touch keyboard path) and once via the swipe cells. Play to game over with level-ups on both. Expect: no desync,
relay_failureempty. - Step 2: Siri Remote join: iPhone hosts, ATV joins using only the Siri Remote on the CodePicker. Expect the picker usable without a keyboard.
- Step 3: The Phase 4 “done when” run: ATV at home on home broadband hosts; iPhone on cellular (Wi-Fi off) joins; play a full run to game over. Then pull the
lan_runrows with the Task 10 query: both rows present,transport = relay,rtt_mean/rtt_maxrecorded,stallsandcushion_waitsrecorded,end_reason/reasonsane. Record the numbers (RTT mean/max, stalls per minute) in the ledger; these decide whether render-only own-ship prediction (Phase 5) is needed. - Step 4: Failure UX spot checks: wrong code (no_room text), a third device joining (room_full), mismatched BUILD (version text, then a correct device joins the same code), iPhone pressed Home mid-run (iPhone: “went to the background” text on return; ATV: “Your friend left. You can keep playing.”), ATV host quits to menu (iPhone: “Your friend left the game.” within ~1 s).
- Step 5: Store-gate check: on a TestFlight install with no
featurespatch, the menu shows no CO-OP ONLINE row and--mp-*flags do nothing.
Out of scope (Phase 5 or later)
Section titled “Out of scope (Phase 5 or later)”- Reconnect / resume after backgrounding, mid-run join, slot reservation with a session token, resync after a desync (needs full-state export/import ported from
origin/networked-multiplayer-mb-mcbehind its completeness test). In Phase 4 any disconnect ends that player’s part of the session. - 3-4 networked pilots (needs the 4-pilot plan; relay
MAX_MEMBERSandLockstepSession.MAX_PILOTSstay 2). - Render-only own-ship prediction for the client (decided from Task 15’s RTT numbers).
- Web demo joining (WASM float determinism unmeasured; would need
det_probeon web and an Apple-only/platform-family handshake rule first). - Android/Mac store builds as online peers (not Apple arm64-vs-arm64; same determinism caveat).
- Relay-assisted LAN discovery for store builds (Dev already has it via the control Worker registry; a store version would be a relay feature that sees private IPs: privacy change).
- Per-pilot meta upgrades/hulls, Drone Bay, dimension picks and remote-config patches inside networked runs (net runs stay bare Survival on bundled defaults, as on LAN today).
- A Remote Console
online_coopcommand and Debug Settings rows (CLI + menu cover agents and humans for now; adding them later must keep Debug-console parity). - WebRTC P2P / UDP relay (spec §6: only if Task 15’s measurements demand it).
- A
net_desynctelemetry event (thelan_rundesync snapshot already covers it).
Risks of shipping dark without reconnect, and how the gate mitigates them
Section titled “Risks of shipping dark without reconnect, and how the gate mitigates them”| Risk | What happens in Phase 4 | Mitigation |
|---|---|---|
| iOS backgrounding (Home, call, Control Centre long enough, lock) suspends the app and the OS tears down the socket | That player’s session ends. A backgrounded client: host plays on alone. A backgrounded host: the client’s run ends at once (“Your friend left the game.”). | Gate OFF for every store player, so only testers who know the limitation can hit it. On APPLICATION_PAUSED the game sends a clean BYE backgrounded (when it still can), so the partner gets an immediate, honest message instead of a 5 s freeze. FOCUS_OUT (tvOS resign-active, Mac alt-tab) never ends a session. lan_run reason = backgrounded counts how often it happens, which sizes Phase 5’s reconnect work. |
| Cellular handover / tunnel / lift: TCP stalls, then the WebSocket dies | Stalls show as client freezes (counted); a dead socket ends the session (connection_lost). |
Same gate. stalls and relay_failure in telemetry quantify it. |
| Store players see a half-finished feature | Impossible while the key is absent; the menu row is only built when OnlineCoop.enabled(). |
Unit test (Task 9 case 4) + Task 15 Step 5. |
| App Review: “hidden features” (guideline 2.3.1) if the flag is flipped for App Store users of a version reviewed with it off | Rejection or removal risk. | Flip only the testflight channel; App Store players get online co-op only in a version submitted for review with the row visible (Chris decision A). |
| Relay abuse (someone scripting connects) | Connects capped per IP; rooms capped at 2; frames capped; rate capped; nothing stored to leak. | Relay design + tests. Kill: gate {}; Worker can be deleted without touching telemetry/control (separate Worker). |
| Cost runaway | ~$0.003 per 20-min match beyond quota (spec §6). | Gate limits volume to testers; Cloudflare dashboard watch during the TestFlight window. |
Decision: new Worker, not a route on an existing one
Section titled “Decision: new Worker, not a route on an existing one”New Worker bullet-heaven-relay. Reasons:
- Store builds must reach it. The control Worker gates every route on
CONTROL_KEY, which ships only in Dev builds; putting the relay there means either shipping that key in store builds (it also unlocks the Remote Console) or carving a keyless exception into a Worker designed to be key-gated. - “Stores nothing” becomes structural. Both existing Workers have D1 (and telemetry has KV) bindings. A relay living beside them cannot be proven storage-free by a config check; in its own Worker,
no-storage.test.mjsproves it fromwrangler.tomland the source. - Deploy-risk isolation.
telemetry/deploy.shguards the store-critical event path with a live-schema column check; adding DO migrations there couples every relay change to that path. A separate Worker can be redeployed, rolled back or deleted without touching telemetry or the console. - Cost: one more
wrangler.tomland deploy script, sameworkers.devsubdomain pattern as the other two.
Self-review notes (done while writing)
Section titled “Self-review notes (done while writing)”- Spec coverage: §4 RelayTransport (Task 4), §6 DO relay incl. hibernation, codes, per-IP limit, size/rate caps, host leave (Tasks 1-2), privacy (Constraints + Task 12), cost (Task 13 README), Phase 4 “done when” (Tasks 10 + 15), version/config refusal UX (Task 8 table + Task 5 test 3). Relay-assisted discovery and web demo: explicitly out of scope with reasons.
- Names used across tasks:
RelayTransport.failure(),RelayFrames.failure_for_close(),OnlineCoop.enabled/failure_text/peer_left_text,RemoteConfig.features/features_of,CodePicker.submitted,OnlineLobby.join_requested/host_requested/cancelled,_net_new_relay,_online_openare consistent. - Every task states that
/simis untouched and runs the determinism files throughcheck-test-count.sh.
Execution handoff
Section titled “Execution handoff”For a fresh session with no planning context.
- Chris approved (relayed by the launching agent, 2026-10-14): build Phase 4 on its own branch now, target release 1.2; it MAY ship dark in 1.1 behind a remote-config flag that defaults OFF; the store must not expose it until Chris has played a full internet run.
- Authorised: writing code on branch
coop-relay(Task 0 creates it offorigin/main), local tests,wrangler dev --local,wrangler deploy --dry-run. - Needs Chris’s same-day OK: the first
relay/deploy.sh(new public endpoint), any Dev install on ATV/iPhone, any remote-config patch carryingfeatures, deploying the site privacy branch, merge to main. - Done outside the plan: nothing. No Worker, no patch, no site change, no code. This plan file is the only artifact (branch
coop-relay-plan). - Rejected alternatives: relay as a route on the control Worker (CONTROL_KEY is dev-only) or the telemetry Worker (storage bindings + store-critical deploy path); the gate as a bool tunable (reset by
apply_for_run({})in_lan_open, and it would enterconfig_hash); WebRTC/UDP (spec §6, only if Task 15 numbers demand it); sendingcf.coloin telemetry (location data). - Open questions for Chris: see the report that accompanied this plan (ship dark in 1.1 or wait; testflight-only flip; privacy update timing; online runs ignore balance patches; per-IP limit; Workers plan; deploy authorisation).
- Gotchas hit while planning: GDScript
JSON.stringifysorts keys and prints parsed ints as2.0(Task 3 note);RemoteConfig.noticeis a STATIC var and tests reset it inafter_each, sofeaturesfollows suit;FOCUS_OUTfires on tvOS resign-active, so onlyAPPLICATION_PAUSEDends an online session. - Exact next action: Task 0 (branch, re-verify the spec-vs-code rows, ATV + iPhone
det_probedigests on one Dev BUILD, record the Workers plan), then Task 1 subagent-driven withbh-implementer(model: "opus").