Skip to content

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 skill bh-sdd-concurrent before Task 1 and run sdd-worktree-leak-guard after 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.

  • Determinism baselines must not move. No task in this plan edits any file under /sim. Every task ends by running tests/test_determinism_survival.gd, tests/test_determinism.gd, tests/test_determinism_coop.gd (via scripts/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; no ctx.storage / this.ctx.storage / state.storage anywhere in relay/src; no console.* call in relay/src; [observability] enabled = false. Enforced by relay/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 existing lan_run event 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_VERSION stays 4 and net/lockstep_protocol.gd is not edited. Relay framing is a separate envelope with its own version RELAY_V = 1.
  • /sim is untouched; net/ and ui/ changes are render/net side. Time in net/ code is injected (now_ms / a clock_ms Callable) 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’s MAX_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_URL pointing 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; run bump-build.sh inside a task; deploy from a task; edit platform/tvos/export_presets.cfg.
  • GDScript rules: annotate Dictionary.get() results as Variant; every new docs/**/*.md needs Starlight title: frontmatter; after any merge that adds class_names run godot --headless --path . --import and git 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.

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 frames
net/relay_socket.gd RelaySocket: the socket seam (interface)
net/ws_relay_socket.gd WsRelaySocket: RelaySocket over WebSocketPeer
net/relay_transport.gd RelayTransport extends NetTransport
net/online_coop.gd OnlineCoop: the gate + store-safe end/status text
net/remote_config.gd + `features` (top-level patch key, like notice)
net/remote_config_state.gd valid(): `features` must be a Dictionary
tools/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 GUT
tests/fake_relay_socket.gd FakeRelaySocket (own file: one class_name per file)
tests/test_relay_frames.gd
tests/test_relay_transport.gd
tests/test_relay_session.gd LockstepSession/NetCoopRun end to end over RelayTransport
tests/test_online_coop_gate.gd
tests/test_code_picker.gd
tests/test_online_lobby.gd incl. fit at tv / iphone / ipad
tests/test_online_coop_main.gd two real main.tscn over FakeRelayHub
ui/code_picker.gd CodePicker: 5-slot code entry for pad / Siri Remote / touch / keyboard
ui/online_lobby.gd OnlineLobby: host (big code) / join (picker) / failure text
ui/start_menu.gd build_items(..., online) row
main.gd _online_* glue, CLI flags, background handling, relay_failure in lan_run
scripts/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.


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 Agent isolation: "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). If PROTOCOL_VERSION is no longer 4 or NetTransport gained 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, compare status().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 whoami with CLOUDFLARE_ACCOUNT_ID set, per ~/.claude/gotchas/cloudflare.md § Wrangler). Free plan: DO + new_sqlite_classes deploy fine; note the free DO request quota in relay/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|null
    • parseRoomRequest(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) -> Uint8Array
    • takeToken(bucket: {tokens, at} | undefined, nowMs) -> {ok: boolean, bucket}
  • 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

Terminal window
if node --test control/test/*.test.mjs scripts/test/*.test.mjs telemetry/test/*.test.mjs relay/test/*.test.mjs > "$log/node.txt" 2>&1; then

and 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, LanRegistry
export 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:
Terminal window
git add relay/package.json relay/src/room_core.js relay/test/room-core.test.mjs scripts/gate.sh
git 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) } where deps = { now?: () => number, newPair?: () => [client, server], upgrade?: (client) => Response }; export default { fetch(req, env, ctx) } from worker.js, re-exporting RelayRoom; export function handle(req, env) for tests (preflight ruling: no deps param; the tests call handle(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 a Map keyed 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 enters getWebSockets().

  • acceptWebSocket(server, ['p' + peer]) then server.serializeAttachment({ peer, role }) then server.send(welcomeFor(...)) then a join CTRL to each existing member.

  • Close/error handler: idempotent (both may fire). Remove the bucket, compute onLeave over the remaining members, send, and on closeAll close every remaining socket with 4010. Wrap every ws.send/ws.close in try/catch (a peer may already be gone).

  • webSocketMessage with a string message → close 4009 (binary only).

  • request.cf.colo is 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):

  1. 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.att equals {peer:1, role:'host'}; host.tags equals ['p1'].
  2. 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}.
  3. join with no host is refused with 4004 and never enters the room — use a newPair spy to capture the server socket; assert accepted === true, closed.code === CLOSE.NO_ROOM, ctx.getWebSockets().length === 0.
  4. second host 4001, third member 4003 (same capture pattern).
  5. 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].
  6. string messages close with 4009.
  7. 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.
  8. 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 and join.closed.code === CLOSE.KICKED.
  9. host close: joiner gets leave{peer:1} then is closed 4010.
  10. joiner close: host gets leave{peer:2}; a new joiner then gets id 2 again.
  11. close then error on the same socket is idempotent — exactly one leave sent.
  12. membership survives hibernation — construct a NEW RelayRoom(ctx, …) over the same FakeCtx (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 RefCounted with consts RELAY_V := 1, KIND_CTRL := 0, KIND_DATA := 1, KIND_PING := 2, KIND_PONG := 3, MAX_FRAME := 4096, close codes CLOSE_CODE_TAKEN := 4001 … CLOSE_BAD_REQUEST := 4400, and

    • static func data(peer: int, payload: PackedByteArray) -> PackedByteArray
    • static func ping(kind: int, peer: int, nonce: int, sent_ms: int) -> PackedByteArray
    • static func ctrl(obj: Dictionary) -> PackedByteArray
    • static 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 assert Uint8Array.from(Buffer.from(hex,'hex')) (a fresh copy: route() writes out[1], and a Buffer slice is a view that would mutate the fixture input; preflight ruling) round-trips through route({peer: X, role}, …) with out[1] rewritten; CTRL frames equal ctrl(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, then godot --headless --path . -s addons/gut/gut_cmdln.gd -gtest=res://tests/test_relay_frames.gd -gexit → parse error (no RelayFrames).
  • Step 4: Implement net/relay_frames.gd per the interface (StreamPeerBuffer, little-endian; decode checks: empty → {}; kind 1 needs ≥2 bytes; kinds 2/3 need exactly 10; kind 0 needs valid JSON object; other kinds → {}; ctrl() uses JSON.stringify(obj, "", false)). failure_for_close: the § Wire format table plus 1008 → rate_limited, 1009 → too_big, else "".
  • Step 5: Run, expect PASS; node relay/test/frames.test.mjs PASS.
  • 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: RelayTransport is 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 RelaySocket over WebSocketPeer (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_size left 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 := 8000
      • static 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 (from LanRegistry.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, or connection_lost)
      • overrides: kind() -> "relay", local_id(), peers(), send(), poll(), rtt_ms() (EWMA of PONG round trips, -1 until 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 with force == false: drop from peers() now + CTRL kick; force == true: no-op, because a relay kick is already immediate and final, and LockstepSession._refuse force-disconnects the refused id HELLO_DEADLINE_MS later, 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 and send() is a no-op. A client’s peers() is [1] after welcome. CTRL join/leave add/remove ids. On the host leaving (CTRL leave for peer 1, or close 4010) a client’s poll() injects ONE synthetic {peer: 1, bytes: LockstepProtocol.encode_text(MSG_BYE, "left")} so the session ends at once as host_left: left instead of waiting out TIMEOUT_MS. On any OTHER loss of the host after the welcome (connection_lost, rate_limited, too_big, …) it injects ONE BYE "connection_lost" instead, so the banner does not say “Your friend left”. On close 4005 kicked it injects NO BYE: the REJECT the host queued before the kick has already been drained and must decide end_reason (rejected: …) (preflight ruling F2). The first PING to a peer goes out at once when that peer appears (welcome / join), then every PING_MS to each peer (preflight ruling F9: the RTT test only pumps 320 ms); an incoming PING is answered with PONG (same nonce/sent_ms) inside poll(); PONG updates RTT from clock_ms() - sent_ms (32-bit wrap-safe). Connect timeout: still CONNECTING after CONNECT_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 as room_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 file tests/fake_relay_socket.gd (one file cannot hold two class_names; preflight ruling F10): class_name FakeRelaySocket extends RelaySocket whose open(url) parses /mp/room/<C>?role=&v= and registers with the hub on the first poll() (so CONNECTING is observable for one poll), delivers frames after latency_polls of the RECEIVER’s polls, and close() triggers the hub’s leave logic. var refuse_handshake := false → stays CLOSED with close_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 for RelayTransport.poll() order (preflight ruling F2: frames BEFORE the close): sock.poll() → CONNECTING timeout via clock_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" for leave{peer:1} / 4010, none for 4005 kicked, "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.gd and tests/test_enet_transport.gd (contract unchanged).
  • Step 6: bash scripts/check-test-count.sh green, determinism unmoved; restore project.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_pick pattern from tests/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):

    1. 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 + client NetCoopRun over RelayTransports, 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 same next_tick (step the slower one until equal), client.session.checksums_compared > 0, desync_tick == -1.
    2. test_client_inputs_are_not_late_with_relay_rtt — after warm-up (past the first PONG), snapshot inputs_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 where rtt_ms() == -1) (proves rtt_ms() feeds _lead_ticks(); with rtt_ms() == -1 this fails, which is the reason row 4 of the spec table exists).
    3. test_build_mismatch_is_rejected_with_the_lan_text — client BUILD+1; client end_reason begins with "rejected: build mismatch"; host emitted peer_rejected; then a correct client joins the SAME code and the run starts (slot freed by disconnect_peer → kick). Then pump past LockstepSession.HELLO_DEADLINE_MS + 1000 ms of clock_ms time 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).
    4. test_host_leaving_ends_the_client_at_once — host close() mid-run; within 5 polls the client is ended with end_reason == "host_left: left" (not host_timeout).
    5. test_client_leaving_drops_its_pilot_and_the_host_plays_on — client NetCoopRun.close(); host drop_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.sh green; 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 covers valid(); grep func test_.*valid under tests/)

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; else features.get(FEATURE) is bool and true)
    • RemoteConfig: static var features: Dictionary = {} (static, exactly like static var notice), signal features_changed, static func features_of(raw: Variant) -> Dictionary (keeps only keys in OnlineCoop.FEATURES whose values are bool), func _seed_cached() -> void (the cache reads setup() 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 from tests/test_remote_config_refresh.gd: _node() builds a RemoteConfig with setup(36, "test-install"), which uses _NullStore headless; _respond() feeds _on_done like 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} with manifest.features = ["internet_coop"] → no problems
    • features: {internet_coop: "yes"} → features: internet_coop must be true or false
    • features: {teleport: true} → features: unknown key teleport
    • features: [] → features must be an object
    • manifest without features and a patch with features → 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.gd per 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) (set features = features_of(raw), emit features_changed on change); func _seed_cached() = _update_notice(state.peek_for_run().get("notice", {})) + _update_features(state.peek_for_run().get("features", {})), and the setup() 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 after receive(), NOT from parsed, so a blacklisted (crashed) patch that receive() refused can never open the gate (preflight ruling; unlike the notice, which deliberately reads parsed). A kill switch {} yields {}. Add RemoteConfig.features = {} to after_each of tests/test_remote_config_refresh.gd too (static state leaks between tests otherwise).
    • net/remote_config_state.gd valid(): add and rest.get("features", {}) is Dictionary.
    • tools/remote_config/dump_manifest.gd: "features": OnlineCoop.FEATURES in out.
    • 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.md says (do NOT publish).
  • Step 5: Run, expect PASS. Then node --test scripts/test/*.test.mjs.
  • Step 6: bash scripts/check-test-count.sh green, determinism unmoved (the gate never reaches /sim; it is not in config_hash). Add a short ## Features — top-level gates (2026-10-14) section to docs/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, font font.3xl, title font), each showing a character of LanRegistry.CODE_ALPHABET or · when unset, plus a trailing “Join” Button.
    • ui_up / ui_down on a focused cell cycle that cell through the alphabet (wraps; first press on an unset cell → A / 9). Siri Remote swipes arrive as ui_up/down/left/right through the fork’s input mapping, same as the pad.
    • ui_left / ui_right move focus between cells (default focus neighbours).
    • ui_accept on 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 a LineEdit (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.
  • Step 1: Failing tests tests/test_code_picker.gd: drive with Input.parse_input_event of InputEventAction (ui_up, ui_down, ui_right, ui_accept) and InputEventKey (unicode) on a picker added to the tree, await get_tree().process_frame between events:
    1. up on an empty first cell → code() starts with "A"; down ONCE from there → "9" (wraps A → 9 in one press; preflight ruling F12).
    2. typing a b c d e (unicode) → code() == "ABCDE"; typing 0 (not in alphabet) changes nothing.
    3. 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.
    4. accept on the last cell with a full code emits submitted("ABCDE") exactly once (use watch_signals); with 4 chars it emits nothing.
    5. set_code("abcde") → "ABCDE"; set_code("toolong!") → unchanged.
  • Step 2: Run, FAIL. Step 3: Implement. Step 4: Run, PASS.
  • Step 5: bash scripts/check-test-count.sh green; 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: signals cancelled, host_requested, join_requested(code: String); func show_choice() -> void (two big buttons HOST / JOIN + Cancel); func show_hosting(code: String) -> void (code in font.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 in danger.main, focus back to the relevant control). layer = 72; ui_cancel → cancelled (B / Esc / Siri Menu), as LanLobby does.
    • 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 as left.
  • Step 1: Failing tests tests/test_online_lobby.gd:
    1. Table test over every row above (OnlineCoop.failure_text), including that no text contains —, BUILD, config_hash or an underscore.
    2. show_hosting("K7MPQ") shows the code label with text K7MPQ and focuses Cancel (controller host must reach it).
    3. show_joining() focuses the picker’s first cell; picker submitted("ABCDE") re-emits as join_requested("ABCDE").
    4. ui_cancel emits cancelled.
    5. Fit at real device profiles (CLAUDE.md rule): for dev in ["tv", "iphone", "ipad"], host the lobby inside a SubViewport sized by ShopLayoutFixture.profile(dev)["vp"] exactly as tests/test_stats_sheet_fit.gd::_viewport does (copy that helper, including Platform.force_safe_insets_pt, force_screen_scale, LiveTokens.set_platform, TokenBind.flush_now() and the after_each restore), for EACH of the three views AND with the longest failure text shown; assert every visible Control’s get_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.
  • Step 2: FAIL. Step 3: Implement (structure mirrors ui/lan_lobby.gd: dim ColorRect, CenterContainer, VBox with Tokens.space(3); status custom_minimum_size.x = min(560, vp_width - 2*inset) read at _ready, word-wrapped). Step 4: PASS.
  • Step 5: bash scripts/check-test-count.sh green, determinism unmoved. Run bh-visual-qa (or zsh scripts/ui-sweep.sh if 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 pins build_items; grep build_items( under tests/), tests/test_replay_autostart.gd (Main.lan_cli_requested asserts)

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: when online, append ["online", "CO-OP ONLINE", "Play with a friend over the internet", &"accent.soft", "action"] right after the mode rows; new signal online_requested; new var online_allowed := false on StartMenu, set by main’s _show_start_menu before add_child (exactly like has_progressed), and StartMenu._ready (start_menu.gd:191, the ONLY caller of build_items; main never calls it) passes it as the 4th arg; _activate gains "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, so BuildConfig.dev_tools() is always true and render/build_config.gd has 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_pump while _online and not started).
    • NetCoopRun.close(reason: String = "left") sends BYE reason (default unchanged).
  • Step 1: Failing tests.

    • Start menu: build_items(true, false, false, false) has no online row; build_items(true, false, false, true) has it right after survival.
    • tests/test_replay_autostart.gd: Main.lan_cli_requested(["--mp-host-online"]) and Main.lan_cli_requested(["--mp-join-code=ABCDE"]) are both true (preflight ruling F6).
    • tests/test_online_coop_main.gd (model on tests/test_lan_coop_main.gd: two main.tscn instances, its frame-pumping helpers, net_allowed handling): both mains get _net_new_relay = func() -> RelayTransport returning a transport whose make_socket uses ONE shared FakeRelayHub and whose clock_ms is a fake clock the test advances 16 ms per _frame (Task 10 asserts rtt_mean > 0 on this run; preflight ruling F5).
      1. 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, equal sim.net_checksum() at equal ticks.
      2. Joiner with a wrong code → its lobby status equals OnlineCoop.failure_text("no_room"); no lan_run sent (a lobby that never connected is no session, existing rule).
      3. 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.
      4. Store build gating: with m._online_dev = func() -> bool: return false (the new seam; there is no existing dev_tools() seam) and RemoteConfig.features = {}, _online_open("host") does nothing and a start menu built by _show_start_menu has online_allowed == false and no online row; with {"internet_coop": true} it opens and the row is there.
      5. Background: during a run, m._notification(NOTIFICATION_APPLICATION_PAUSED) on the CLIENT main → the client’s session closes with BYE backgrounded, its lan_run goes out with reason == "backgrounded", the host main’s session shows drop_reason == "left" and keeps ticking. NOTIFICATION_APPLICATION_FOCUS_OUT does NOT end the session (tvOS resign-active: controller UI, Siri press; and Mac alt-tab during two-window testing).
      6. 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 contains t=/sum=, so that assert could not fail); after an end its text EQUALS OnlineCoop.failure_text(end_reason); behind the start barrier it shows "Waiting for your friend…".
      7. Lobby hygiene (preflight rulings F16-F18): while the OnlineLobby is open start_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 == null and _net_relay == null after one pump, and pumping 6 s more sends no lan_run.
  • 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); create OnlineLobby into _online_lobby, wire cancelled → _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 = false when not null, as the shop/ship/bestiary actions do: the menu sits on a lower layer and its _input would otherwise also take ui_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_relay seam 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); connect peer_rejected to _online_lobby.set_status(...) (OnlineLobby has no show_host_alert; that is LanLobby’s; preflight ruling); _net_started = false; _lan_new_session(); lobby show_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_pump session checks), only while not _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 is kicked, sees the version text), instead of LanLobby.join_failure_text.
      • Else relay failure: if _net_relay != null and _net_relay.failure() != "" → code_taken and host → close _net, _online_host_attempt(_online_tries - 1) (give up at 0 with failure_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, so show_failure is not re-run every frame, no host_timeout follows 5 s later, and _lan_close sends no lan_run for a lobby that never connected.
    • _lan_close clears _net_relay, _online, frees _online_lobby (and nulls it); if start_menu != null set start_menu.visible = true and call start_menu.refocus() (cancel path; preflight ruling F16).
    • _start_net_run: also queue_free() + null _online_lobby next to the existing _net_lobby free (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() use OnlineCoop.failure_text(end_reason) and a neutral colour; Dev keeps today’s dev text. Host side mid-run peer drop: show OnlineCoop.peer_left_text(session.drop_reason) for 4 s.
    • Start menu: in _show_start_menu, start_menu.online_allowed = _online_allowed() BEFORE add_child(start_menu) (_ready reads it into build_items); connect online_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> sets RelayTransport.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=N and print the existing [lan] host|client t=.. sum=.. lines. Also print [relay] code=XXXXX on host. Gate the --mp-*-online flags on _online_dev.call() (= BuildConfig.dev_tools() outside tests) like the existing ones (the if 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-time RelayTransport/OnlineLobby misuse); 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 v2 lan_run), tests/test_online_coop_main.gd (or the existing lan_run telemetry test; grep lan_run in tests/)

Interfaces:

  • Consumes: RelayTransport.kind() == "relay", rtt_ms(), failure(); NetSessionTelemetry (unchanged: it samples rtt_ms() once a second and skips -1).
  • Produces: lan_run gains exactly one field, relay_failure (string, only when transport == "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_ms already exist. loss_pct/loss_max stay -1 on 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 pushed lan_run (the existing EventQueue spy seam; EventQueue.push is a no-op headless, so use the injectable spy the LAN telemetry test uses) and assert transport == "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 measure rtt = 0, so the test’s _net_new_relay sets each transport’s clock_ms to a fake clock the test advances 16 ms per _frame; Task 9’s tests/test_online_coop_main.gd helpers 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.md lan_run field 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') reason
FROM 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.sh green, 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 the scripts/relay-local-test.sh usage; 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 of timeout):
#!/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 pipefail
cd "$(dirname "$0")/.."
ticks="${1:-3600}"
port=8787
code="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' EXIT
for _ in $(seq 1 60); do curl -fs "http://127.0.0.1:$port/health" >/dev/null 2>&1 && break; sleep 1; done
curl -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 || true
perl -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 3
perl -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 || true
wait $hpid || true
git checkout -- project.godot audio/bus_layout.tres 2>/dev/null || true
h=$(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, stalls small (localhost). Also run once with ticks=600 while killing the host process at ~300 ticks (manual): the client log must show ended: … host_left within a second, not host_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 in docs/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 and scripts/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 kinds enet / 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; see relay/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” for lan_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 (mirrors telemetry/deploy.sh auth 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 pipefail
cd "$(dirname "$0")"
export CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc
if [ -n "${CLOUDFLARE_API_TOKEN:-}" ]; then echo "deploy.sh: auth = CLOUDFLARE_API_TOKEN" >&2
else echo "deploy.sh: auth = wrangler login (OAuth)" >&2; fi
node --test test/*.test.mjs
if [ -n "$(git status --porcelain -- .)" ]; then
echo "deploy.sh: relay/ has uncommitted changes; wrangler ships what is on disk, not HEAD. Commit first." >&2; exit 1
fi
if [ "${1:-}" = "--dry-run" ]; then exec npx --yes wrangler deploy --dry-run; fi
npx --yes wrangler deploy
curl -fsS "https://bullet-heaven-relay.chris-allen-06f.workers.dev/health"; echo
  • Step 2: relay/README.md (Starlight frontmatter not required: relay/ is not under docs/; check docs-site content globs and add title: 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 without features; {} only as an all-off kill since it also drops that channel’s tunables/bible/sounds; wrangler delete as last resort).
  • Step 3: Deploy sequence (documented in README and repeated here; Chris authorises each outward step):
    1. Branch coop-relay green: scripts/gate.sh, scripts/relay-local-test.sh, bh-reviewer MERGE.
    2. Worker first: bash relay/deploy.sh --dry-run, then bash relay/deploy.sh (needs Chris’s OK: first deploy of a new public endpoint). Verify /health 200 and one wscat-style smoke (or scripts/relay-local-test.sh pointed at the live URL via --mp-relay=wss://…, two Mac processes over the real internet path).
    3. Only then build any client whose RelayTransport.DEFAULT_URL points at it: Dev installs on ATV + iPhone (bh-deploy / bh-worktree-device-install), device test (Task 15).
    4. Merge to main per docs/reference/builds-and-releases.md (bh-merge-reviewed-branch), push.
    5. Store 1.1 ships with the code and the gate OFF (no features key in any appstore/testflight patch). Nothing else to do: absent key = OFF.
    6. 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’s tunables/bible/sounds too (copy remote-config/build-<BUILD>.json and add features). It also serves later builds until they get their own patch.
    7. Kill: republish the same channel patch without features (keeping its tunables/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.
    8. App Store players: only via a reviewed version (Chris decision A).
  • Step 4: Commit “relay: deploy script + README (deploy before any client build)”.

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_probe exists, 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_failure empty.
  • 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_run rows with the Task 10 query: both rows present, transport = relay, rtt_mean/rtt_max recorded, stalls and cushion_waits recorded, end_reason / reason sane. 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 features patch, the menu shows no CO-OP ONLINE row and --mp-* flags do nothing.

  • 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-mc behind 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_MEMBERS and LockstepSession.MAX_PILOTS stay 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_probe on 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_coop command 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_desync telemetry event (the lan_run desync 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:

  1. 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.
  2. “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.mjs proves it from wrangler.toml and the source.
  3. Deploy-risk isolation. telemetry/deploy.sh guards 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.
  4. Cost: one more wrangler.toml and deploy script, same workers.dev subdomain pattern as the other two.
  • 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_open are consistent.
  • Every task states that /sim is untouched and runs the determinism files through check-test-count.sh.

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 off origin/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 carrying features, 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 enter config_hash); WebRTC/UDP (spec §6, only if Task 15 numbers demand it); sending cf.colo in 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.stringify sorts keys and prints parsed ints as 2.0 (Task 3 note); RemoteConfig.notice is a STATIC var and tests reset it in after_each, so features follows suit; FOCUS_OUT fires on tvOS resign-active, so only APPLICATION_PAUSED ends an online session.
  • Exact next action: Task 0 (branch, re-verify the spec-vs-code rows, ATV + iPhone det_probe digests on one Dev BUILD, record the Workers plan), then Task 1 subagent-driven with bh-implementer (model: "opus").