Skip to content

LAN co-op easy join — 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 consult bh-sdd-concurrent before Task 1 and dispatch bh-implementer (model opus) per task, each in its OWN worktree.

Goal: A second device joins a LAN co-op game with no typing (nearby list), or with a 5-letter room code, falling back to a typed IP only as a last resort.

Architecture: The control Worker gains a tiny host registry (/mp/*, D1 table mp_hosts, CONTROL_KEY-gated) keyed by a salted hash of the caller’s public IP. A new render-side LanRegistry node registers the host, lists nearby hosts and resolves codes over HTTP; LanLobby merges registry + beacon hosts into one tap-to-join list with code entry; main.gd wires it in and tries a host’s private IPs in order. The Remote Console’s Join box takes a code or an IP. Nothing touches /sim, so determinism baselines cannot move.

Tech Stack: Cloudflare Worker (plain JS) + D1, node:test with the sqlite-backed control/test/_d1.mjs; Godot 4.7 typed GDScript + GUT.

Spec: docs/superpowers/specs/2026-10-07-lan-easy-join-design.md (approved by Chris 2026-10-07). Background: docs/superpowers/specs/2026-10-07-networked-coop-design.md, docs/architecture/coop-and-pilots.md § LAN co-op.

  • Branch base: work branches off origin/overnight-2026-10-07 (the LAN code is NOT on main). One worktree per task under .claude/worktrees/<name>, never branch inside a live checkout. Merge each finished task back into overnight-2026-10-07 and git push origin overnight-2026-10-07 right after.
  • Dev-only: every /mp/* route requires CONTROL_KEY exactly like the existing game-facing routes (POST: key in the body; GET: ?key=). The game side only ever constructs LanRegistry in a dev build (BuildConfig.dev_tools()). No privacy page / ASC label change.
  • Never store a raw IP: the Worker stores only SHA-256(MP_SALT + "|" + netKey); MP_SALT is a Worker secret. Missing MP_SALT → every /mp/* route answers 503 (after the key check).
  • Room code: 5 chars from ABCDEFGHJKMNPQRSTUVWXYZ23456789 (no 0/O/1/I/L), random, collision retry, case-insensitive lookup.
  • Liveness: host re-POSTs every 15 s; a row expires 60 s after its last refresh; leave deletes at once.
  • “Same network” key: IPv4 → the full address (v4:a.b.c.d); IPv6 → its /64 (v6: + first 4 expanded hextets); ::ffff:a.b.c.d counts as IPv4. A row keeps every key it has been seen from; nearby matches on ANY overlap.
  • Limits: lan_ips ≤ 4 entries, private IPv4 only (10/8, 172.16/12, 192.168/16), others dropped; name ≤ 24 chars; port must be 1024–65535; build a positive integer. POST bodies are guarded: malformed → 400, never 500.
  • D1 table: mp_hosts(code TEXT PRIMARY KEY, net_keys TEXT, name TEXT, build INTEGER, port INTEGER, lan_ips TEXT, updated_ms INTEGER) in control/migrations/0002_mp_hosts.sql.
  • Offline: if the Worker is unreachable, the game behaves exactly as today (beacon list + typed IP). Nearby failures are silent; a code lookup failure shows a status line.
  • Panel client string rule: control/src/panel/*.js are string modules: no backtick, no ${, no backslash inside them (control/test/panel-modules.test.mjs enforces it). Write regex classes like [.] and [0-9], never \. / \d.
  • Tests: never pull a whole GUT run into context; use scripts/check-test-count.sh (full log at $TMPDIR/bh-gut-last.log). Single GUT script: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=<exact_name>.gd -gexit. A fresh worktree needs godot --headless --path . --import once first. Node: node --test control/test/*.test.mjs. Merge gate: bash scripts/gate.sh.
  • Godot strictness: warnings are errors; annotate Dictionary.get() / JSON.parse_string() results as Variant, not :=.
  • No new D1 table for telemetry (this is the control DB, not telemetry), so RETENTION_TABLES is untouched.
File Responsibility
control/src/mp.js (new) Pure registry helpers: code alphabet/alloc/normalise, private-v4 check, net key, salted hash, host body validation, row → host view
control/src/worker.js /mp/host, /mp/host/leave, /mp/nearby, /mp/code/<CODE> routes + mp_hosts prune
control/migrations/0002_mp_hosts.sql (new), control/schema.sql The table
control/test/mp-registry.test.mjs (new) Worker + helper tests
net/lan_registry.gd (new) Game-side HTTP client for the registry (host refresh, nearby poll, code resolve) + pure IP helpers
tests/test_lan_registry.gd (new) Registry against a stub requester
ui/lan_lobby.gd Host view shows the code; join view = merged list → code entry → collapsed IP; merge_hosts
tests/test_lan_lobby.gd (new) Lobby merge / de-dupe / BUILD gating / code entry
main.gd (LAN section ~L3588-3720, build_status ~L3299) Wire registry into host/join, code resolve, multi-IP fallover, lan_coop code, status hosting
tests/test_lan_easy_join_main.gd (new) The wiring, through the real main.tscn
tests/gut_pre_run.gd LanRegistry.io_enabled = false
control/src/panel/{markup,client,tooltips}.js, control/src/whats_new.js, control/test/_panel-harness.mjs Console Join box (code or IP), hosting line, last-host prefill
control/test/panel-lan.test.mjs (new) Console tests
control/README.md, docs/architecture/coop-and-pilots.md Docs

Files:

  • Create: control/src/mp.js, control/migrations/0002_mp_hosts.sql, control/test/mp-registry.test.mjs
  • Modify: control/src/worker.js (imports at L1, pruneStale L11-17, new routes before the final 404 at L131), control/schema.sql (append), control/README.md (endpoint table + secret + deploy steps)

Interfaces:

  • Produces (HTTP, consumed by Task 2):

    • POST /mp/host body {key, code?, build, port, name, lan_ips} → 200 {code, ttl_s: 60}; 400 {ok:false,error}; 401; 503.
    • POST /mp/host/leave body {key, code} → 200 {ok:true}.
    • GET /mp/nearby?key= → 200 {hosts: [{code, name, build, port, lan_ips}]}.
    • GET /mp/code/<CODE>?key= → 200 {code, name, build, port, lan_ips} or 404 {ok:false,error:'not found'}.
  • Produces (JS): mp.js exports MP_CODE_ALPHABET, MP_CODE_LEN, MP_TTL_MS, MP_MAX_IPS, MP_NAME_MAX, allocCode(rand), normCode(v), isPrivateV4(ip), netKey(ip), hashKey(salt, key), hostBody(b), hostView(row).

  • Test seam: env.MP_RAND (a () => number) replaces Math.random for code allocation.

  • Step 1: Write the migration and schema

control/migrations/0002_mp_hosts.sql:

-- LAN co-op easy join (2026-10-07): live LAN hosts by room code. A host re-POSTs /mp/host every
-- 15 s; a row is dead 60 s after updated_ms and is pruned on the next /mp/host or /caps POST.
-- net_keys = JSON array of salted SHA-256 hashes of the networks the host was seen from (never a
-- raw IP); lan_ips = JSON array of the host's private IPv4s. docs/superpowers/specs/2026-10-07-lan-easy-join-design.md
CREATE TABLE IF NOT EXISTS mp_hosts (
code TEXT PRIMARY KEY,
net_keys TEXT NOT NULL,
name TEXT NOT NULL,
build INTEGER NOT NULL,
port INTEGER NOT NULL,
lan_ips TEXT NOT NULL,
updated_ms INTEGER NOT NULL
);

Append the same CREATE TABLE IF NOT EXISTS mp_hosts (...) to control/schema.sql with a one-line comment -- LAN hosts by room code. migrations/0002_mp_hosts.sql (matches how watch is mirrored there).

  • Step 2: Write the failing tests

control/test/mp-registry.test.mjs:

// LAN co-op easy join (2026-10-07): the /mp/* host registry. Runs the real worker against the
// sqlite-backed D1 stand-in (./_d1.mjs, schema.sql + migrations). 203.0.113.0/24 and 2001:db8::/32
// are documentation ranges.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import worker from '../src/worker.js';
import { fakeD1 } from './_d1.mjs';
import { allocCode, normCode, netKey, isPrivateV4, hostBody, MP_CODE_ALPHABET, MP_TTL_MS } from '../src/mp.js';
const KEY = 'k';
const env = (extra = {}) => ({ ...fakeD1(), CONTROL_KEY: KEY, MP_SALT: 'salt', ...extra });
const call = (e, method, path, { ip, body, key = KEY } = {}) => {
const headers = ip ? { 'CF-Connecting-IP': ip } : {};
const q = method === 'GET' && key ? (path.includes('?') ? '&' : '?') + 'key=' + key : '';
const init = { method, headers };
if (method === 'POST') init.body = typeof body === 'string' ? body : JSON.stringify({ key, ...body });
return worker.fetch(new Request('https://x.test' + path + q, init), e);
};
const HOST = { build: 265, port: 47610, name: 'Mac · macOS', lan_ips: ['192.168.1.20'] };
const host = async (e, ip, extra = {}) => (await call(e, 'POST', '/mp/host', { ip, body: { ...HOST, ...extra } })).json();
const nearby = async (e, ip) => (await (await call(e, 'GET', '/mp/nearby', { ip })).json()).hosts;
test('the code alphabet has no 0/O/1/I/L and every allocated code normalises to itself', () => {
for (const ch of '0O1IL') assert.ok(!MP_CODE_ALPHABET.includes(ch), ch);
for (let i = 0; i < 500; i++) { const c = allocCode(); assert.equal(normCode(c), c); }
assert.equal(normCode(' abcde '), 'ABCDE');
assert.equal(normCode('ABCD0'), null);
assert.equal(normCode('ABCDEF'), null);
assert.equal(normCode(12345), null);
});
test('netKey: v4 whole address, v6 /64, v4-mapped v6 counts as v4', () => {
assert.equal(netKey('203.0.113.7'), 'v4:203.0.113.7');
assert.equal(netKey('2001:db8:1:2::5'), 'v6:2001:0db8:0001:0002');
assert.equal(netKey('2001:DB8:1:2:aaaa:bbbb:cccc:dddd'), 'v6:2001:0db8:0001:0002');
assert.equal(netKey('::ffff:203.0.113.7'), 'v4:203.0.113.7');
assert.equal(netKey('999.1.1.1'), null);
assert.equal(netKey(''), null);
assert.equal(netKey(null), null);
});
test('isPrivateV4 and hostBody limits', () => {
assert.ok(isPrivateV4('10.0.0.5') && isPrivateV4('172.16.0.1') && isPrivateV4('172.31.9.9') && isPrivateV4('192.168.1.2'));
assert.ok(!isPrivateV4('172.32.0.1') && !isPrivateV4('8.8.8.8') && !isPrivateV4('192.169.0.1') && !isPrivateV4('fe80::1'));
const b = hostBody({ build: 265, port: 47610, name: 'x'.repeat(40),
lan_ips: ['8.8.8.8', '10.0.0.5', '172.16.0.1', '172.32.0.1', '192.168.1.2', '192.168.1.3', '10.1.1.1', '10.0.0.5'] });
assert.equal(b.name.length, 24);
assert.deepEqual(b.lan_ips, ['10.0.0.5', '172.16.0.1', '192.168.1.2', '192.168.1.3']);
assert.equal(hostBody({ build: 265, port: 80 }), null);
assert.equal(hostBody({ build: 265, port: 70000 }), null);
assert.equal(hostBody({ build: 'x', port: 47610 }), null);
assert.equal(hostBody(null), null);
assert.equal(hostBody({ build: 265, port: 47610 }).name, 'Device');
});
test('host allocates a code; a refresh with that code keeps it and updates the row', async () => {
const e = env();
const a = await host(e, '203.0.113.7');
assert.equal(normCode(a.code), a.code);
assert.equal(a.ttl_s, 60);
const b = await host(e, '203.0.113.7', { code: a.code.toLowerCase(), name: 'Renamed' });
assert.equal(b.code, a.code);
const rows = e.db.prepare('SELECT * FROM mp_hosts').all();
assert.equal(rows.length, 1);
assert.equal(rows[0].name, 'Renamed');
});
test('a refresh whose row expired re-registers the SAME code', async () => {
const e = env();
const a = await host(e, '203.0.113.7');
e.db.prepare('UPDATE mp_hosts SET updated_ms=?').run(Date.now() - MP_TTL_MS - 1000);
const b = await host(e, '203.0.113.7', { code: a.code });
assert.equal(b.code, a.code);
});
test('allocation retries past a live collision', async () => {
const seq = [0, 0, 0, 0, 0, 0.1, 0.1, 0.1, 0.1, 0.1];
const e = env({ MP_RAND: () => seq.shift() ?? 0.5 });
e.db.prepare('INSERT INTO mp_hosts VALUES(?,?,?,?,?,?,?)').run('AAAAA', '[]', 'x', 1, 47610, '[]', Date.now());
const a = await host(e, '203.0.113.7');
assert.equal(a.code, 'DDDDD'); // 0.1 * 31 -> index 3
});
test('expiry: a row 60 s past its refresh is gone from code lookup and nearby', async () => {
const e = env();
const a = await host(e, '203.0.113.7');
assert.equal((await call(e, 'GET', '/mp/code/' + a.code)).status, 200);
e.db.prepare('UPDATE mp_hosts SET updated_ms=?').run(Date.now() - MP_TTL_MS - 1);
assert.equal((await call(e, 'GET', '/mp/code/' + a.code)).status, 404);
assert.deepEqual(await nearby(e, '203.0.113.7'), []);
});
test('code lookup is case-insensitive and returns the host view', async () => {
const e = env();
const a = await host(e, '203.0.113.7');
const r = await call(e, 'GET', '/mp/code/' + a.code.toLowerCase());
assert.equal(r.status, 200);
assert.deepEqual(await r.json(), { code: a.code, name: HOST.name, build: 265, port: 47610, lan_ips: ['192.168.1.20'] });
assert.equal((await call(e, 'GET', '/mp/code/ZZZZZ')).status, 404);
assert.equal((await call(e, 'GET', '/mp/code/0OO1L')).status, 404);
});
test('leave deletes the row at once', async () => {
const e = env();
const a = await host(e, '203.0.113.7');
const r = await call(e, 'POST', '/mp/host/leave', { body: { code: a.code } });
assert.equal(r.status, 200);
assert.equal((await call(e, 'GET', '/mp/code/' + a.code)).status, 404);
assert.equal((await call(e, 'POST', '/mp/host/leave', { body: { code: 'nope' } })).status, 400);
});
test('nearby: same v4 and same v6 /64 match; another network does not', async () => {
const e = env();
const v4 = await host(e, '203.0.113.7');
const v6 = await host(e, '2001:db8:1:2::5', { name: 'v6 host' });
assert.deepEqual((await nearby(e, '203.0.113.7')).map((h) => h.code), [v4.code]);
assert.deepEqual((await nearby(e, '2001:db8:1:2:ffff::9')).map((h) => h.code), [v6.code]);
assert.deepEqual(await nearby(e, '198.51.100.1'), []);
assert.deepEqual(await nearby(e, '2001:db8:1:3::5'), []);
assert.deepEqual(await nearby(e, undefined), [], 'no CF-Connecting-IP = no network = nothing');
});
test('nearby: a host seen over v4 then v6 matches joiners on either family', async () => {
const e = env();
const a = await host(e, '203.0.113.7');
await host(e, '2001:db8:1:2::5', { code: a.code });
assert.deepEqual((await nearby(e, '203.0.113.7')).map((h) => h.code), [a.code]);
assert.deepEqual((await nearby(e, '2001:db8:1:2::77')).map((h) => h.code), [a.code]);
});
test('only salted hashes are stored, never a raw IP', async () => {
const e = env();
await host(e, '203.0.113.7');
await host(e, '2001:db8:1:2::5');
const dump = JSON.stringify(e.db.prepare('SELECT * FROM mp_hosts').all());
assert.ok(!dump.includes('203.0.113'), dump);
assert.ok(!dump.toLowerCase().includes('2001:db8') && !dump.includes('2001:0db8'), dump);
const other = env({ MP_SALT: 'other' });
await host(other, '203.0.113.7');
const k1 = JSON.parse(e.db.prepare('SELECT net_keys FROM mp_hosts LIMIT 1').get().net_keys)[0];
const k2 = JSON.parse(other.db.prepare('SELECT net_keys FROM mp_hosts').get().net_keys)[0];
assert.notEqual(k1, k2, 'the salt changes the hash');
});
test('bad bodies are 400, never 500', async () => {
const e = env();
assert.equal((await call(e, 'POST', '/mp/host', { body: '{nope' })).status, 400);
assert.equal((await call(e, 'POST', '/mp/host', { body: { build: 265, port: 80 } })).status, 400);
assert.equal((await call(e, 'POST', '/mp/host', { body: { build: 'x', port: 47610 } })).status, 400);
assert.equal((await call(e, 'POST', '/mp/host/leave', { body: '[]' })).status, 401);
});
test('every /mp route is key-gated (401), then needs MP_SALT (503)', async () => {
const e = env();
assert.equal((await call(e, 'POST', '/mp/host', { body: HOST, key: 'bad' })).status, 401);
assert.equal((await call(e, 'POST', '/mp/host/leave', { body: { code: 'ABCDE' }, key: 'bad' })).status, 401);
assert.equal((await call(e, 'GET', '/mp/nearby', { key: '' })).status, 401);
assert.equal((await call(e, 'GET', '/mp/code/ABCDE', { key: '' })).status, 401);
const n = env({ MP_SALT: '' });
assert.equal((await call(n, 'POST', '/mp/host', { body: HOST })).status, 503);
assert.equal((await call(n, 'GET', '/mp/nearby')).status, 503);
assert.equal((await call(n, 'GET', '/mp/code/ABCDE')).status, 503);
});
test('the /caps prune also drops dead mp_hosts rows', async () => {
const e = env();
await host(e, '203.0.113.7');
e.db.prepare('UPDATE mp_hosts SET updated_ms=?').run(Date.now() - MP_TTL_MS - 1);
await call(e, 'POST', '/caps', { body: { code: 'AAA111', blob: { x: 1 } } });
assert.equal(e.db.prepare('SELECT COUNT(*) AS n FROM mp_hosts').get().n, 0);
});
  • Step 3: Run to verify they fail

Run: node --test control/test/mp-registry.test.mjs Expected: FAIL — Cannot find module '../src/mp.js'.

  • Step 4: Write control/src/mp.js
// LAN co-op easy join (2026-10-07): pure helpers for the /mp/* host registry in worker.js. No D1;
// the only global is crypto.subtle (Workers and Node 22 both have it), so node --test covers it.
// Spec: docs/superpowers/specs/2026-10-07-lan-easy-join-design.md.
export const MP_CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no 0/O/1/I/L
export const MP_CODE_LEN = 5;
export const MP_TTL_MS = 60000; // a host row is dead 60 s after its last refresh (the game refreshes every 15 s)
export const MP_MAX_IPS = 4;
export const MP_NAME_MAX = 24;
export function allocCode(rand = Math.random) {
let c = '';
for (let i = 0; i < MP_CODE_LEN; i++) c += MP_CODE_ALPHABET[Math.floor(rand() * MP_CODE_ALPHABET.length)];
return c;
}
// The canonical (upper-case) code, or null for anything that is not one.
export function normCode(v) {
if (typeof v !== 'string') return null;
const c = v.trim().toUpperCase();
if (c.length !== MP_CODE_LEN) return null;
for (const ch of c) if (!MP_CODE_ALPHABET.includes(ch)) return null;
return c;
}
const V4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
function v4Octets(ip) {
const m = typeof ip === 'string' ? ip.match(V4) : null;
if (!m) return null;
const o = m.slice(1).map(Number);
return o.every((n) => n <= 255) ? o : null;
}
export function isPrivateV4(ip) {
const o = v4Octets(ip);
if (!o) return false;
return o[0] === 10 || (o[0] === 172 && o[1] >= 16 && o[1] <= 31) || (o[0] === 192 && o[1] === 168);
}
// Eight zero-padded hextets, or null.
function expand6(ip) {
const halves = ip.toLowerCase().split('::');
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(':') : [];
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
const fill = halves.length === 2 ? 8 - head.length - tail.length : 0;
if (fill < 0) return null;
const all = [...head, ...Array(fill).fill('0'), ...tail];
if (all.length !== 8 || !all.every((h) => /^[0-9a-f]{1,4}$/.test(h))) return null;
return all.map((h) => h.padStart(4, '0'));
}
// The "same network" key for one request's CF-Connecting-IP: IPv4 = the whole address, IPv6 = its /64.
export function netKey(ip) {
if (typeof ip !== 'string' || !ip) return null;
const mapped = ip.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
const addr = mapped ? mapped[1] : ip;
if (v4Octets(addr)) return 'v4:' + addr;
const h = addr.includes(':') ? expand6(addr) : null;
return h ? 'v6:' + h.slice(0, 4).join(':') : null;
}
// Hex SHA-256 of salt|key. Only this is ever stored.
export async function hashKey(salt, key) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(salt + '|' + key));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
// A validated /mp/host body, or null (-> 400). Non-private / extra lan_ips are dropped, not refused.
export function hostBody(b) {
if (!b || typeof b !== 'object' || Array.isArray(b)) return null;
const { build, port } = b;
if (!Number.isInteger(build) || build <= 0) return null;
if (!Number.isInteger(port) || port < 1024 || port > 65535) return null;
const name = (typeof b.name === 'string' && b.name.trim() ? b.name.trim() : 'Device').slice(0, MP_NAME_MAX);
const ips = Array.isArray(b.lan_ips) ? b.lan_ips.filter(isPrivateV4) : [];
return { code: normCode(b.code), build, port, name, lan_ips: [...new Set(ips)].slice(0, MP_MAX_IPS) };
}
export function hostView(row) {
return { code: row.code, name: row.name, build: row.build, port: row.port, lan_ips: JSON.parse(row.lan_ips) };
}
  • Step 5: Wire the routes into control/src/worker.js

Imports (top of file):

import { allocCode, normCode, netKey, hashKey, hostBody, hostView, MP_TTL_MS } from './mp.js';

In pruneStale, after the watch delete (a Worker deployed before migration 0002 has no table, so guard it):

try { await env.DB.prepare('DELETE FROM mp_hosts WHERE updated_ms < ?').bind(now - MP_TTL_MS).run(); } catch { /* pre-0002 DB */ }

Add above export default:

// ── LAN co-op easy join (2026-10-07): host registry. spec 2026-10-07-lan-easy-join-design.md ──
// The caller's network key as a salted hash ([] when CF-Connecting-IP is missing or unparseable).
async function mpKeys(env, req) {
const k = netKey(req.headers.get('CF-Connecting-IP'));
return k ? [await hashKey(env.MP_SALT, k)] : [];
}
const MP_INSERT = 'INSERT OR IGNORE INTO mp_hosts(code,net_keys,name,build,port,lan_ips,updated_ms) VALUES(?,?,?,?,?,?,?)';
async function mpRoute(req, env, url) {
const now = Date.now();
const p = url.pathname;
if (req.method === 'POST' && (p === '/mp/host' || p === '/mp/host/leave')) {
let body;
try { body = JSON.parse(await req.text()); } catch { return j({ ok: false, error: 'bad json' }, 400); }
if (!keyOk(env.CONTROL_KEY, body?.key)) return j({ ok: false, error: 'unauthorized' }, 401);
if (!env.MP_SALT) return j({ ok: false, error: 'mp disabled' }, 503);
if (p === '/mp/host/leave') {
const c = normCode(body.code);
if (!c) return j({ ok: false, error: 'bad code' }, 400);
await env.DB.prepare('DELETE FROM mp_hosts WHERE code=?').bind(c).run();
return j({ ok: true });
}
const h = hostBody(body);
if (!h) return j({ ok: false, error: 'bad host' }, 400);
await env.DB.prepare('DELETE FROM mp_hosts WHERE updated_ms < ?').bind(now - MP_TTL_MS).run();
const keys = await mpKeys(env, req);
const ttl = MP_TTL_MS / 1000;
if (h.code) {
const row = await env.DB.prepare('SELECT net_keys FROM mp_hosts WHERE code=?').bind(h.code).first();
if (row) {
const merged = [...new Set([...JSON.parse(row.net_keys), ...keys])].slice(-4);
await env.DB.prepare('UPDATE mp_hosts SET net_keys=?, name=?, build=?, port=?, lan_ips=?, updated_ms=? WHERE code=?')
.bind(JSON.stringify(merged), h.name, h.build, h.port, JSON.stringify(h.lan_ips), now, h.code).run();
return j({ code: h.code, ttl_s: ttl });
}
}
// A refresh whose row expired takes its old code back if still free; otherwise a fresh one.
const tries = h.code ? [h.code] : [];
for (let i = 0; i < 8; i++) tries.push(allocCode(env.MP_RAND || Math.random));
for (const c of tries) {
const r = await env.DB.prepare(MP_INSERT)
.bind(c, JSON.stringify(keys), h.name, h.build, h.port, JSON.stringify(h.lan_ips), now).run();
if (r.meta && r.meta.changes === 1) return j({ code: c, ttl_s: ttl });
}
return j({ ok: false, error: 'no free code' }, 503);
}
if (req.method === 'GET' && (p === '/mp/nearby' || p.startsWith('/mp/code/'))) {
if (!keyOk(env.CONTROL_KEY, url.searchParams.get('key'))) return j({ ok: false, error: 'unauthorized' }, 401);
if (!env.MP_SALT) return j({ ok: false, error: 'mp disabled' }, 503);
if (p === '/mp/nearby') {
const keys = await mpKeys(env, req);
if (!keys.length) return j({ hosts: [] });
const rows = (await env.DB.prepare('SELECT * FROM mp_hosts WHERE updated_ms >= ? ORDER BY updated_ms DESC')
.bind(now - MP_TTL_MS).all()).results || [];
return j({ hosts: rows.filter((r) => JSON.parse(r.net_keys).some((k) => keys.includes(k))).map(hostView) });
}
let raw = '';
try { raw = decodeURIComponent(p.slice('/mp/code/'.length)); } catch { raw = ''; }
const c = normCode(raw);
const row = c ? await env.DB.prepare('SELECT * FROM mp_hosts WHERE code=? AND updated_ms >= ?').bind(c, now - MP_TTL_MS).first() : null;
return row ? j(hostView(row)) : j({ ok: false, error: 'not found' }, 404);
}
return null;
}

In fetch, just before return new Response('not found', { status: 404 });:

if (url.pathname.startsWith('/mp/')) {
const r = await mpRoute(req, env, url);
if (r) return r;
}
  • Step 6: Run the tests

Run: node --test control/test/*.test.mjs Expected: all pass, including the 15 new ones; no existing test changes.

  • Step 7: Document it in control/README.md

Add four rows to the endpoint table (one per route, same column style, Auth = “key in body” / “key in query”, descriptions from the Interfaces block above). Add a section:

## `MP_SALT` secret (LAN co-op easy join, 2026-10-07)
The `/mp/*` host registry stores only `SHA-256(MP_SALT|network)` for "same network" matching, never an
IP. Without the secret every `/mp/*` route answers 503. Set once (value lives in `~/.secrets` as
`BULLET_HEAVEN_MP_SALT`, added via `lumara-passwords`):
echo "$BULLET_HEAVEN_MP_SALT" | npx wrangler secret put MP_SALT

In the Deploy block add after the 0001 line: npx wrangler d1 execute bullet-heaven-control --remote --file=migrations/0002_mp_hosts.sql # 2026-10-07: mp_hosts. In “D1 schema” add: - `mp_hosts` — live LAN co-op hosts by room code, hashed network keys, 60 s TTL (`migrations/0002_mp_hosts.sql`).

  • Step 8: Commit
Terminal window
git add control/src/mp.js control/src/worker.js control/migrations/0002_mp_hosts.sql control/schema.sql control/test/mp-registry.test.mjs control/README.md
git commit -m "feat(control): /mp host registry for LAN co-op easy join (room codes, hashed same-network match)"

Task 2: LanRegistry (game-side registry client)

Section titled “Task 2: LanRegistry (game-side registry client)”

Files:

  • Create: net/lan_registry.gd, tests/test_lan_registry.gd
  • Modify: tests/gut_pre_run.gd (add LanRegistry.io_enabled = false after the ControlClient line)

Interfaces:

  • Consumes: Task 1’s HTTP routes; ControlClient.ENDPOINT, ControlClient.CONTROL_KEY (net/control_client.gd).

  • Produces (class_name LanRegistry extends Node):

    • signals code_ready(code: String), nearby(hosts: Array[Dictionary]), resolved(host: Dictionary), failed(reason: String)
    • var requester: Callable — called as requester.call(method: int, path: String, body: Dictionary, done: Callable); done.call(status: int, data: Variant), status == 0 = transport failure. Default = real HTTP.
    • var host_code: String
    • func host_start(build: int, port: int, name: String, lan_ips: Array[String]) -> void, func host_stop() -> void, func is_hosting() -> bool
    • func start_nearby() -> void, func stop_nearby() -> void, func refresh_nearby() -> void
    • func resolve_code(code: String) -> void
    • func tick(dt: float) -> void (called by _process; tests call it directly)
    • statics: normalize_code(v: Variant) -> String (“” if invalid), sanitize_host(d: Variant) -> Dictionary ({} if invalid; else {code, name, build, port, lan_ips: Array[String]}), order_ips(host_ips: Array, own_ips: Array) -> Array[String], private_ipv4s(ips: Array) -> Array[String]
    • consts REFRESH_S := 15.0, NEARBY_S := 3.0, CODE_ALPHABET, CODE_LEN := 5
  • Step 1: Write the failing test — tests/test_lan_registry.gd:

extends GutTest
# LanRegistry (LAN co-op easy join, 2026-10-07) against a stub requester: no network in tests.
# Every request lands in `calls`; a test answers one with calls[i]["done"].call(status, data).
var calls: Array[Dictionary] = []
func _stub(method: int, path: String, body: Dictionary, done: Callable) -> void:
calls.append({"method": method, "path": path, "body": body, "done": done})
func _reg() -> LanRegistry:
calls.clear()
var r := LanRegistry.new()
r.requester = _stub
add_child_autofree(r)
return r
func _host_calls() -> Array[Dictionary]:
return calls.filter(func(c: Dictionary) -> bool: return c["path"] == "/mp/host")
func test_host_start_registers_then_refreshes_with_the_code() -> void:
var r := _reg()
watch_signals(r)
var ips: Array[String] = ["192.168.1.20"]
r.host_start(265, 47610, "Mac · macOS", ips)
assert_eq(calls.size(), 1)
assert_eq(calls[0]["method"], HTTPClient.METHOD_POST)
var b: Dictionary = calls[0]["body"]
assert_eq(b["build"], 265)
assert_eq(b["port"], 47610)
assert_eq(b["name"], "Mac · macOS")
assert_eq(b["lan_ips"], ips)
assert_eq(b["key"], ControlClient.CONTROL_KEY)
assert_false(b.has("code"), "first registration asks for a code")
(calls[0]["done"] as Callable).call(200, {"code": "ABCDE", "ttl_s": 60})
assert_signal_emitted_with_parameters(r, "code_ready", ["ABCDE"])
assert_eq(r.host_code, "ABCDE")
r.tick(LanRegistry.REFRESH_S - 0.1)
assert_eq(_host_calls().size(), 1, "no refresh before 15 s")
r.tick(0.2)
assert_eq(_host_calls().size(), 2)
assert_eq((_host_calls()[1]["body"] as Dictionary)["code"], "ABCDE", "a refresh resends its code")
func test_no_second_host_post_while_one_is_in_flight() -> void:
var r := _reg()
var ips: Array[String] = []
r.host_start(265, 47610, "x", ips)
r.tick(LanRegistry.REFRESH_S * 3)
assert_eq(_host_calls().size(), 1)
func test_host_stop_leaves_and_stops_refreshing() -> void:
var r := _reg()
var ips: Array[String] = []
r.host_start(265, 47610, "x", ips)
(calls[0]["done"] as Callable).call(200, {"code": "ABCDE"})
r.host_stop()
assert_false(r.is_hosting())
var last: Dictionary = calls[-1]
assert_eq(last["path"], "/mp/host/leave")
assert_eq((last["body"] as Dictionary)["code"], "ABCDE")
var n := calls.size()
r.tick(LanRegistry.REFRESH_S * 3)
assert_eq(calls.size(), n, "nothing after stop")
assert_eq(r.host_code, "")
func test_a_code_arriving_after_stop_is_released() -> void:
var r := _reg()
var ips: Array[String] = []
r.host_start(265, 47610, "x", ips)
r.host_stop() # before the first answer: no code to leave yet
(calls[0]["done"] as Callable).call(200, {"code": "ABCDE"})
assert_eq(calls[-1]["path"], "/mp/host/leave", "the late code is deleted, not orphaned")
assert_eq(r.host_code, "")
func test_nearby_polls_every_3s_and_sanitises() -> void:
var r := _reg()
watch_signals(r)
r.start_nearby()
assert_eq(calls.size(), 1)
assert_eq(calls[0]["method"], HTTPClient.METHOD_GET)
assert_true(String(calls[0]["path"]).begins_with("/mp/nearby?key="))
(calls[0]["done"] as Callable).call(200, {"hosts": [
{"code": "abcde", "name": "Mac", "build": 265, "port": 47610, "lan_ips": ["192.168.1.20", "fe80::1", 7]},
{"code": "0OO1L", "name": "bad code", "build": 265, "port": 47610, "lan_ips": []},
"junk"]})
var got: Array = get_signal_parameters(r, "nearby")[0]
assert_eq(got.size(), 1)
assert_eq((got[0] as Dictionary)["code"], "ABCDE")
assert_eq((got[0] as Dictionary)["lan_ips"], ["192.168.1.20"] as Array[String])
r.tick(LanRegistry.NEARBY_S + 0.01)
assert_eq(calls.size(), 2)
r.stop_nearby()
(calls[1]["done"] as Callable).call(200, {"hosts": []})
r.tick(LanRegistry.NEARBY_S * 3)
assert_eq(calls.size(), 2, "stopped")
assert_signal_emit_count(r, "nearby", 1, "an answer after stop is dropped")
func test_nearby_failure_is_silent() -> void:
var r := _reg()
watch_signals(r)
r.start_nearby()
(calls[0]["done"] as Callable).call(0, null)
assert_signal_not_emitted(r, "nearby")
assert_signal_not_emitted(r, "failed")
func test_resolve_code_paths() -> void:
var r := _reg()
watch_signals(r)
r.resolve_code(" abcde ")
assert_true(String(calls[0]["path"]).begins_with("/mp/code/ABCDE?key="))
(calls[0]["done"] as Callable).call(200, {"code": "ABCDE", "name": "Mac", "build": 265, "port": 47610, "lan_ips": ["10.0.0.5"]})
assert_signal_emitted(r, "resolved")
assert_eq((get_signal_parameters(r, "resolved")[0] as Dictionary)["lan_ips"], ["10.0.0.5"] as Array[String])
r.resolve_code("FGHJK")
(calls[1]["done"] as Callable).call(404, {"ok": false})
assert_string_contains(String(get_signal_parameters(r, "failed")[0]), "FGHJK")
r.resolve_code("FGHJK")
(calls[2]["done"] as Callable).call(0, null)
assert_string_contains(String(get_signal_parameters(r, "failed")[0]), "matchmaker")
var n := calls.size()
r.resolve_code("0OO1L")
assert_eq(calls.size(), n, "an impossible code never hits the network")
assert_string_contains(String(get_signal_parameters(r, "failed")[0]), "room code")
func test_order_ips_puts_own_slash24_first() -> void:
assert_eq(LanRegistry.order_ips(["10.0.0.9", "192.168.1.20", "10.8.0.2"], ["192.168.1.33"]),
["192.168.1.20", "10.0.0.9", "10.8.0.2"] as Array[String])
assert_eq(LanRegistry.order_ips(["10.0.0.9"], []), ["10.0.0.9"] as Array[String])
func test_private_ipv4s_and_normalize_code() -> void:
assert_eq(LanRegistry.private_ipv4s(["8.8.8.8", "10.1.2.3", "172.20.0.1", "172.40.0.1", "192.168.0.9", "fe80::1"]),
["10.1.2.3", "172.20.0.1", "192.168.0.9"] as Array[String])
assert_eq(LanRegistry.normalize_code("abcde"), "ABCDE")
assert_eq(LanRegistry.normalize_code("ABCD0"), "")
assert_eq(LanRegistry.normalize_code(5), "")
  • Step 2: Run to verify it fails

Run: godot --headless --path . --import (once per worktree), then godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_lan_registry.gd -gexit 2>&1 | tail -20 Expected: FAIL — LanRegistry not found / parse error.

  • Step 3: Write net/lan_registry.gd
class_name LanRegistry
extends Node
# LAN co-op easy join (2026-10-07, dev-only). Talks to the control Worker's /mp/* host registry so a
# second device can join with no typing: the host registers {build, port, name, private IPs} and gets
# a 5-letter room code (refreshed every 15 s, gone 60 s after the last refresh); a joiner lists hosts
# seen from the same public network (`nearby`) or resolves a code. HTTP only, never touches the sim.
# Failures are quiet: the beacon + typed IP keep working offline. Spec:
# docs/superpowers/specs/2026-10-07-lan-easy-join-design.md. main.gd constructs this only in a dev build.
signal code_ready(code: String)
signal nearby(hosts: Array[Dictionary])
signal resolved(host: Dictionary)
signal failed(reason: String)
const REFRESH_S := 15.0
const NEARBY_S := 3.0
const CODE_LEN := 5
const CODE_ALPHABET := "ABCDEFGHJKMNPQRSTUVWXYZ23456789" # = control/src/mp.js MP_CODE_ALPHABET
# Off under GUT (tests/gut_pre_run.gd): the default requester then answers status 0 at once.
static var io_enabled: bool = true
# requester.call(method, path, body, done); done.call(status, data). status 0 = transport failure.
var requester: Callable
var host_code := ""
var _hosting := false
var _host_body: Dictionary = {}
var _host_busy := false
var _host_accum := 0.0
var _nearby_on := false
var _nearby_busy := false
var _nearby_accum := 0.0
func _init() -> void:
requester = _http
func _process(dt: float) -> void:
tick(dt)
func tick(dt: float) -> void:
if _hosting:
_host_accum += dt
if _host_accum >= REFRESH_S:
_host_accum = 0.0
_send_host()
if _nearby_on:
_nearby_accum += dt
if _nearby_accum >= NEARBY_S:
_nearby_accum = 0.0
refresh_nearby()
# ── Host ──────────────────────────────────────────────────────────────────────────────────────────
func host_start(build: int, port: int, name: String, lan_ips: Array[String]) -> void:
_hosting = true
host_code = ""
_host_accum = 0.0
_host_body = {"build": build, "port": port, "name": name.left(24), "lan_ips": lan_ips}
_send_host()
func is_hosting() -> bool:
return _hosting
func host_stop() -> void:
if _hosting and host_code != "":
_leave(host_code)
_hosting = false
host_code = ""
func _send_host() -> void:
if _host_busy:
return
_host_busy = true
var body := _host_body.duplicate()
if host_code != "":
body["code"] = host_code
body["key"] = ControlClient.CONTROL_KEY
requester.call(HTTPClient.METHOD_POST, "/mp/host", body, _on_host)
func _on_host(status: int, data: Variant) -> void:
_host_busy = false
if status != 200 or not (data is Dictionary):
return
var code := normalize_code((data as Dictionary).get("code", ""))
if code == "":
return
if not _hosting:
_leave(code) # stopped while the registration was in flight: release the code at once
return
if code != host_code:
host_code = code
code_ready.emit(code)
func _leave(code: String) -> void:
requester.call(HTTPClient.METHOD_POST, "/mp/host/leave", {"code": code, "key": ControlClient.CONTROL_KEY},
func(_s: int, _d: Variant) -> void: pass)
# ── Join ──────────────────────────────────────────────────────────────────────────────────────────
func start_nearby() -> void:
_nearby_on = true
_nearby_accum = 0.0
refresh_nearby()
func stop_nearby() -> void:
_nearby_on = false
func refresh_nearby() -> void:
if _nearby_busy:
return
_nearby_busy = true
requester.call(HTTPClient.METHOD_GET, "/mp/nearby?key=%s" % ControlClient.CONTROL_KEY.uri_encode(), {}, _on_nearby)
func _on_nearby(status: int, data: Variant) -> void:
_nearby_busy = false
if not _nearby_on or status != 200 or not (data is Dictionary):
return
var raw: Variant = (data as Dictionary).get("hosts", [])
if not (raw is Array):
return
var out: Array[Dictionary] = []
for item: Variant in raw:
var h := sanitize_host(item)
if not h.is_empty():
out.append(h)
nearby.emit(out)
func resolve_code(code: String) -> void:
var c := normalize_code(code)
if c == "":
failed.emit("'%s' is not a room code (5 letters/digits, shown on the host)." % code.strip_edges())
return
requester.call(HTTPClient.METHOD_GET, "/mp/code/%s?key=%s" % [c, ControlClient.CONTROL_KEY.uri_encode()], {},
func(status: int, data: Variant) -> void: _on_resolve(c, status, data))
func _on_resolve(code: String, status: int, data: Variant) -> void:
if status == 200:
var h := sanitize_host(data)
if not h.is_empty():
resolved.emit(h)
return
if status == 404:
failed.emit("No game with code %s (it may have ended)." % code)
else:
failed.emit("Couldn't reach the matchmaker. Enter the host's IP instead.")
# ── Pure helpers ──────────────────────────────────────────────────────────────────────────────────
static func normalize_code(v: Variant) -> String:
if not (v is String):
return ""
var c := (v as String).strip_edges().to_upper()
if c.length() != CODE_LEN:
return ""
for ch in c:
if not CODE_ALPHABET.contains(ch):
return ""
return c
# {code, name, build, port, lan_ips: Array[String]} from a Worker row, or {} if it is not one.
static func sanitize_host(d: Variant) -> Dictionary:
if not (d is Dictionary):
return {}
var h: Dictionary = d
var code := normalize_code(h.get("code", ""))
if code == "":
return {}
var ips: Array[String] = []
var raw: Variant = h.get("lan_ips", [])
if raw is Array:
for a: Variant in raw:
if a is String and (a as String).is_valid_ip_address() and not (a as String).contains(":"):
ips.append(a as String)
var name_v: Variant = h.get("name", "")
var build_v: Variant = h.get("build", 0)
var port_v: Variant = h.get("port", 0)
return {
"code": code,
"name": (name_v as String).left(24) if name_v is String else "Device",
"build": int(build_v) if (build_v is int or build_v is float) else 0,
"port": int(port_v) if (port_v is int or port_v is float) else 0,
"lan_ips": ips,
}
# The host's IPs with any on one of our own /24s first (a VPN / second NIC address last).
static func order_ips(host_ips: Array, own_ips: Array) -> Array[String]:
var nets := {}
for o: Variant in own_ips:
nets[_slash24(String(o))] = true
var same: Array[String] = []
var rest: Array[String] = []
for a: Variant in host_ips:
var s := String(a)
if nets.has(_slash24(s)):
same.append(s)
else:
rest.append(s)
same.append_array(rest)
return same
static func private_ipv4s(ips: Array) -> Array[String]:
var out: Array[String] = []
for a: Variant in ips:
var s := String(a)
if s.contains(":") or not s.is_valid_ip_address():
continue
var o := s.split(".")
var a0 := o[0].to_int()
var a1 := o[1].to_int()
if a0 == 10 or (a0 == 172 and a1 >= 16 and a1 <= 31) or (a0 == 192 and a1 == 168):
out.append(s)
return out
static func _slash24(a: String) -> String:
return a.substr(0, a.rfind("."))
# Default requester: one short-lived HTTPRequest per call, so host refresh, nearby and resolve never
# block each other (an HTTPRequest refuses a second request while busy).
func _http(method: int, path: String, body: Dictionary, done: Callable) -> void:
if not io_enabled or not is_inside_tree():
done.call(0, null)
return
var h := HTTPRequest.new()
h.timeout = 6.0
add_child(h)
h.request_completed.connect(func(result: int, code: int, _hd: PackedStringArray, bytes: PackedByteArray) -> void:
h.queue_free()
if result != HTTPRequest.RESULT_SUCCESS:
done.call(0, null)
return
var parsed: Variant = JSON.parse_string(bytes.get_string_from_utf8())
done.call(code, parsed))
var payload := JSON.stringify(body) if method == HTTPClient.METHOD_POST else ""
var err := h.request(ControlClient.ENDPOINT + path, PackedStringArray(["Content-Type: text/plain"]), method, payload)
if err != OK:
h.queue_free()
done.call(0, null)

Add to tests/gut_pre_run.gd run(): LanRegistry.io_enabled = false # LAN easy join: no Worker traffic from a test run.

  • Step 4: Run the test

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_lan_registry.gd -gexit 2>&1 | tail -20 Expected: all 9 tests pass. If the typed-array equality asserts (as Array[String]) misbehave, compare with assert_eq(str(a), str(b)) rather than loosening the production types.

  • Step 5: Commit
Terminal window
git add net/lan_registry.gd net/lan_registry.gd.uid tests/test_lan_registry.gd tests/gut_pre_run.gd
git commit -m "feat(net): LanRegistry — room-code host registration, nearby list, code resolve (dev LAN co-op)"

(The .uid is generated by the import pass; add it if it exists.)


Task 3: LanLobby — code display, merged tap-to-join list, code entry, collapsed IP

Section titled “Task 3: LanLobby — code display, merged tap-to-join list, code entry, collapsed IP”

Files:

  • Modify: ui/lan_lobby.gd (whole file is 118 lines; changes below)
  • Create: tests/test_lan_lobby.gd

Interfaces:

  • Consumes: LanRegistry.order_ips, LanRegistry.normalize_code (Task 2); beacon rows {ip, build, port, name, seen} from LanBeacon.hosts().

  • Produces:

    • new signals join_host_requested(ips: Array[String]), code_requested(code: String); existing cancelled, join_requested(ip: String) stay (typed-IP path).
    • static func merge_hosts(beacon: Array[Dictionary], registry: Array[Dictionary], own_ips: Array[String]) -> Array[Dictionary] — rows {name: String, build: int, ips: Array[String], code: String}; registry rows first (IPs ordered by order_ips), then beacon rows whose IP no earlier row carries.
    • func set_hosts(rows: Array[Dictionary], own_build: int) -> void — CHANGED signature (was beacon rows). Rows with build != own_build are disabled buttons reading "<name> · needs BUILD <build>".
    • func show_hosting(ips: Array[String], port: int, code: String = "") -> void, func set_code(code: String) -> void
    • func show_joining(default_ip: String) -> void — IP row collapsed unless default_ip != "".
    • func host_count() -> int unchanged.
  • Step 1: Write the failing test — tests/test_lan_lobby.gd:

extends GutTest
# LanLobby join list + code entry (LAN co-op easy join, 2026-10-07), through the real CanvasLayer.
func _lobby() -> LanLobby:
var l := LanLobby.new()
add_child_autofree(l)
return l
func _beacon(ip: String, build: int, name: String) -> Dictionary:
return {"ip": ip, "build": build, "port": 47610, "name": name, "seen": 0}
func _reg_host(code: String, build: int, ips: Array[String]) -> Dictionary:
return {"code": code, "name": "Mac", "build": build, "port": 47610, "lan_ips": ips}
func test_merge_dedupes_by_ip_registry_first_with_ordered_ips() -> void:
var beacon: Array[Dictionary] = [_beacon("192.168.1.20", 265, "Mac"), _beacon("192.168.1.40", 265, "ATV")]
var reg: Array[Dictionary] = [_reg_host("ABCDE", 265, ["10.8.0.2", "192.168.1.20"] as Array[String])]
var own: Array[String] = ["192.168.1.33"]
var rows := LanLobby.merge_hosts(beacon, reg, own)
assert_eq(rows.size(), 2, "the Mac heard twice is one row")
assert_eq(rows[0]["code"], "ABCDE")
assert_eq(rows[0]["ips"], ["192.168.1.20", "10.8.0.2"] as Array[String], "own /24 first")
assert_eq(rows[1]["ips"], ["192.168.1.40"] as Array[String])
assert_eq(rows[1]["code"], "")
func test_set_hosts_disables_other_builds_and_emits_ips() -> void:
var l := _lobby()
watch_signals(l)
var rows: Array[Dictionary] = [
{"name": "Mac", "build": 265, "ips": ["192.168.1.20", "10.8.0.2"] as Array[String], "code": "ABCDE"},
{"name": "Old", "build": 250, "ips": ["192.168.1.41"] as Array[String], "code": ""}]
l.set_hosts(rows, 265)
assert_eq(l.host_count(), 2)
var ok: Button = l._hosts_box.get_child(0)
var old: Button = l._hosts_box.get_child(1)
assert_false(ok.disabled)
assert_string_contains(ok.text, "ABCDE")
assert_true(old.disabled)
assert_string_contains(old.text, "needs BUILD 250")
ok.pressed.emit()
assert_signal_emitted_with_parameters(l, "join_host_requested", [["192.168.1.20", "10.8.0.2"]])
func test_set_hosts_rebuilds_only_on_change() -> void:
var l := _lobby()
var rows: Array[Dictionary] = [{"name": "Mac", "build": 265, "ips": ["192.168.1.20"] as Array[String], "code": ""}]
l.set_hosts(rows, 265)
var first: Node = l._hosts_box.get_child(0)
l.set_hosts(rows.duplicate(true), 265)
assert_eq(l._hosts_box.get_child(0), first, "same list keeps the same button (focus survives)")
func test_code_entry_normalises_or_explains() -> void:
var l := _lobby()
watch_signals(l)
l.show_joining("")
l._code_edit.text = " abcde "
l._submit_code()
assert_signal_emitted_with_parameters(l, "code_requested", ["ABCDE"])
l._code_edit.text = "AB"
l._submit_code()
assert_signal_emit_count(l, "code_requested", 1)
assert_string_contains(l._status.text, "room code")
func test_ip_row_collapsed_unless_prefilled_and_toggle_opens_it() -> void:
var l := _lobby()
l.show_joining("")
assert_false(l._ip_row.visible, "typed IP is the last resort: collapsed")
assert_true(l._ip_toggle.visible)
l._ip_toggle.pressed.emit()
assert_true(l._ip_row.visible)
l.show_joining("192.168.1.20")
assert_true(l._ip_row.visible, "a remembered IP opens it")
assert_eq(l._ip_edit.text, "192.168.1.20")
func test_hosting_shows_the_code_once_it_arrives() -> void:
var l := _lobby()
var ips: Array[String] = ["192.168.1.20"]
l.show_hosting(ips, 47610)
assert_false(l._code_row.visible, "join widgets hidden on the host")
assert_false(l._ip_toggle.visible)
assert_string_contains(l._status.text, "192.168.1.20")
l.set_code("ABCDE")
assert_string_contains(l._status.text, "ABCDE")
assert_string_contains(l._status.text, "192.168.1.20")
  • Step 2: Run to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_lan_lobby.gd -gexit 2>&1 | tail -20 Expected: FAIL (no merge_hosts, _code_edit, _ip_toggle, _code_row, set_code).

  • Step 3: Implement in ui/lan_lobby.gd

Update the header comment to: # ... the join view lists hosts from the control Worker's registry (same public network) merged with the beacon (Mac/Android/tvOS), then a room-code box, then a collapsed manual IP box (LAN co-op easy join, 2026-10-07).

New signals and fields (beside the existing ones):

signal join_host_requested(ips: Array[String])
signal code_requested(code: String)
var _code_row: HBoxContainer
var _code_edit: LineEdit
var _ip_toggle: Button
var _host_ips: Array[String] = []
var _host_port := 0
var _host_code := ""

In _ready(), between box.add_child(_hosts_box) and the _ip_row block, add the code row and the IP toggle; keep the existing _ip_row creation after them:

_code_row = HBoxContainer.new()
TokenBind.theme_constant(_code_row, &"separation", func() -> int: return Tokens.space(2))
_code_edit = LineEdit.new()
_code_edit.placeholder_text = "ROOM CODE"
_code_edit.max_length = LanRegistry.CODE_LEN
_code_edit.custom_minimum_size = Vector2(200, 48)
TokenBind.theme_font_size(_code_edit, &"font_size", &"font.lg")
_code_edit.text_submitted.connect(func(_t: String) -> void: _submit_code())
_code_row.add_child(_code_edit)
_code_row.add_child(_button("Join code", _submit_code))
box.add_child(_code_row)
_ip_toggle = _button("Enter IP…", func() -> void:
_ip_row.visible = true
_ip_toggle.visible = false
_ip_edit.grab_focus.call_deferred())
box.add_child(_ip_toggle)

(_ip_row is assigned after this lambda is created; the lambda only runs on press, so it reads the assigned field.)

Replace show_hosting, show_joining, set_hosts and add set_code, merge_hosts, _submit_code, _render_hosting:

func show_hosting(ips: Array[String], port: int, code: String = "") -> void:
_title.text = "LAN CO-OP · HOSTING"
_host_ips = ips
_host_port = port
_host_code = code
_code_row.visible = false
_ip_toggle.visible = false
_ip_row.visible = false
_render_hosting()
_cancel.grab_focus.call_deferred() # a controller / Apple TV host needs a focused control to reach Cancel
func set_code(code: String) -> void:
_host_code = code
_render_hosting()
func _render_hosting() -> void:
var where := ", ".join(_host_ips) if not _host_ips.is_empty() else "(no LAN address found)"
var code_line := "Room code: %s" % _host_code if _host_code != "" else "Room code: (registering…)"
set_status("%s\nWaiting for a player to join: they pick this device in their list or type the code.\nThis device: %s (port %d)" % [code_line, where, _host_port])
func show_joining(default_ip: String) -> void:
_title.text = "LAN CO-OP · JOIN"
set_status("Pick a host below, or type its room code. An IP works too (Enter IP).")
_code_row.visible = true
_ip_edit.text = default_ip
_ip_row.visible = default_ip != ""
_ip_toggle.visible = default_ip == ""
_code_edit.grab_focus.call_deferred()
# One row per host: registry hosts first (IPs on our own /24 first), then beacon hosts whose IP no
# earlier row already carries. Row = {name, build, ips: Array[String], code}.
static func merge_hosts(beacon: Array[Dictionary], registry: Array[Dictionary], own_ips: Array[String]) -> Array[Dictionary]:
var out: Array[Dictionary] = []
var seen := {}
for h: Dictionary in registry:
var raw: Variant = h.get("lan_ips", [])
var fresh: Array[String] = []
for ip: String in LanRegistry.order_ips(raw if raw is Array else [], own_ips):
if not seen.has(ip):
seen[ip] = true
fresh.append(ip)
if fresh.is_empty():
continue
var name_v: Variant = h.get("name", "")
var build_v: Variant = h.get("build", 0)
var code_v: Variant = h.get("code", "")
out.append({"name": String(name_v), "build": int(build_v), "ips": fresh, "code": String(code_v)})
for h: Dictionary in beacon:
var ip := String(h["ip"])
if seen.has(ip):
continue
seen[ip] = true
var one: Array[String] = [ip]
out.append({"name": String(h["name"]), "build": int(h["build"]), "ips": one, "code": ""})
return out
# Rebuilt only when the list changes, so focus stays put.
func set_hosts(rows: Array[Dictionary], own_build: int) -> void:
var key := "%s|%d" % [str(rows), own_build]
if key == _shown_hosts:
return
_shown_hosts = key
for c in _hosts_box.get_children():
c.queue_free()
_hosts_box.remove_child(c) # host_count() must not count rows already on their way out
for r: Dictionary in rows:
var ips: Array[String] = r["ips"]
var name := String(r["name"])
var build := int(r["build"])
var code := String(r["code"])
if build != own_build:
var b := _button("%s · needs BUILD %d" % [name, build], func() -> void: pass)
b.disabled = true
b.focus_mode = Control.FOCUS_NONE
_hosts_box.add_child(b)
continue
var tag := " · %s" % code if code != "" else ""
_hosts_box.add_child(_button("Join %s · %s%s · BUILD %d" % [name, ips[0], tag, build],
func() -> void: join_host_requested.emit(ips)))
func _submit_code() -> void:
var c := LanRegistry.normalize_code(_code_edit.text)
if c == "":
set_status("'%s' is not a room code (5 letters/digits, shown on the host)." % _code_edit.text.strip_edges())
return
code_requested.emit(c)

Note: the old set_hosts used queue_free() alone, which left children counted by host_count() until the next frame; remove_child fixes that for the test and for real use.

  • Step 4: Fix the one existing caller so the project parses

main.gd _lan_pump (~L3719) calls _net_lobby.set_hosts(_net_beacon.hosts()). Change it to the new signature for now (Task 4 replaces it with the registry merge):

var none: Array[Dictionary] = []
_net_lobby.set_hosts(LanLobby.merge_hosts(_net_beacon.hosts(), none, LanBeacon.local_ipv4()), Sim_Const.BUILD)

Grep for any other caller first: git grep -n "set_hosts(\|show_hosting(\|show_joining(" -- '*.gd' — the only callers today are in main.gd (show_hosting/show_joining keep compatible signatures).

  • Step 5: Run the lobby test and the existing LAN tests

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_lan_lobby.gd -gexit 2>&1 | tail -20, then the same with -gselect=test_lan_ (matches test_lan_beacon, test_lan_coop_main, test_lan_lobby, test_lan_registry). Expected: all pass.

  • Step 6: Commit
Terminal window
git add ui/lan_lobby.gd tests/test_lan_lobby.gd tests/test_lan_lobby.gd.uid main.gd
git commit -m "feat(ui): LAN lobby — merged tap-to-join list, room-code entry, collapsed IP, host shows its code"

Task 4: Wire it into main.gd (host registration, nearby, code join, multi-IP fallover, console code, status hosting)

Section titled “Task 4: Wire it into main.gd (host registration, nearby, code join, multi-IP fallover, console code, status hosting)”

Files:

  • Modify: main.gd — LAN section (~L3588-3720: vars, _lan_dev_command, _lan_open, _lan_host_with, _lan_join, _lan_close, _lan_pump, _start_net_run), build_status (~L3299, BOTH return dicts), beacon name (L3666)
  • Create: tests/test_lan_easy_join_main.gd
  • Modify: docs/architecture/coop-and-pilots.md § LAN co-op (the net/lan_beacon.gd and main.gd LAN bullets ~L112-125)

Interfaces:

  • Consumes: LanRegistry (Task 2: signals, host_start/host_stop/start_nearby/stop_nearby/resolve_code, order_ips, private_ipv4s), LanLobby (Task 3: join_host_requested, code_requested, merge_hosts, set_hosts(rows, own_build), set_code).

  • Produces:

    • Remote Console command: {kind:'lan_coop', action:'join', code:'ABCDE'} (code optional; ip still accepted).
    • build_status()["hosting"]: {"code": String, "ips": Array[String]} while this device’s host lobby is open (before the run starts), else null. Consumed by Task 5.
    • Test seams: var _net_registry: LanRegistry (a test assigns a stub-requester registry before _lan_open), var _net_new_transport: Callable (returns an ENetTransport; tests return a fake).
  • Step 1: Write the failing test — tests/test_lan_easy_join_main.gd:

extends GutTest
# LAN co-op easy join through the REAL main.tscn (2026-10-07): the registry (stub requester, no
# network) feeds the lobby list, a room code resolves into ordered ENet joins (fake transport), a
# host registers and reports `hosting` on the console status. Pure wiring: no sim is touched.
class FakeJoin extends ENetTransport:
var address := ""
func join(addr: String, _port: int) -> Error:
address = addr
return OK
var calls: Array[Dictionary] = []
func _stub(method: int, path: String, body: Dictionary, done: Callable) -> void:
calls.append({"method": method, "path": path, "body": body, "done": done})
func _boot() -> Node:
calls.clear()
var m: Node = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
var reg := LanRegistry.new()
reg.requester = _stub
m.add_child(reg)
m._net_registry = reg
m._net_new_transport = func() -> ENetTransport: return FakeJoin.new()
return m
func _last(path_prefix: String) -> Dictionary:
for i in range(calls.size() - 1, -1, -1):
if String(calls[i]["path"]).begins_with(path_prefix):
return calls[i]
return {}
func _host(code: String, build: int, ips: Array) -> Dictionary:
return {"code": code, "name": "Mac · macOS", "build": build, "port": 47610, "lan_ips": ips}
func test_host_registers_shows_the_code_and_reports_hosting_until_closed() -> void:
var m: Node = await _boot()
m._lan_open("host")
m._lan_host_with(LoopbackTransport.make_pair(0, 0, 0, 5)[0])
var reg_call := _last("/mp/host")
assert_false(reg_call.is_empty(), "the host registered")
assert_eq((reg_call["body"] as Dictionary)["build"], Sim_Const.BUILD)
assert_eq((reg_call["body"] as Dictionary)["port"], NetCoopRun.PORT)
(reg_call["done"] as Callable).call(200, {"code": "ABCDE", "ttl_s": 60})
assert_string_contains(m._net_lobby._status.text, "ABCDE")
var st: Dictionary = m.build_status()
assert_eq((st["hosting"] as Dictionary)["code"], "ABCDE")
m._lan_close()
assert_false(_last("/mp/host/leave").is_empty(), "closing the lobby releases the code")
assert_null(m.build_status()["hosting"])
func test_join_lists_registry_hosts_with_build_gating() -> void:
var m: Node = await _boot()
m._lan_open("join")
var near := _last("/mp/nearby")
assert_false(near.is_empty(), "the join lobby polls nearby hosts")
(near["done"] as Callable).call(200, {"hosts": [
_host("ABCDE", Sim_Const.BUILD, ["10.9.9.9"]), _host("FGHJK", 1, ["10.9.9.10"])]})
m._lan_pump()
assert_eq(m._net_lobby.host_count(), 2)
var old: Button = m._net_lobby._hosts_box.get_child(1)
assert_true(old.disabled, "a different BUILD is listed but not joinable")
func test_a_room_code_joins_its_ips_in_order_and_falls_over_on_timeout() -> void:
var m: Node = await _boot()
m._lan_open("join")
m._net_lobby.code_requested.emit("ABCDE")
var res := _last("/mp/code/ABCDE")
assert_false(res.is_empty(), "the code was looked up")
(res["done"] as Callable).call(200, _host("ABCDE", Sim_Const.BUILD, ["10.9.9.9", "10.9.9.8"]))
assert_eq(m._net_last_ip, "10.9.9.9", "first IP tried first")
assert_eq(m._net_join_queue, ["10.9.9.8"] as Array[String])
m._net.session._end("host_timeout")
m._lan_pump()
assert_eq(m._net_last_ip, "10.9.9.8", "a timed-out IP falls over to the host's next one")
m._net.session._end("host_timeout")
m._lan_pump()
assert_string_contains(m._net_lobby._status.text, "host_timeout", "out of IPs: the reason shows")
func test_a_code_for_another_build_is_refused_without_connecting() -> void:
var m: Node = await _boot()
m._lan_open("join")
m._net_lobby.code_requested.emit("ABCDE")
(_last("/mp/code/ABCDE")["done"] as Callable).call(200, _host("ABCDE", Sim_Const.BUILD + 7, ["10.9.9.9"]))
assert_null(m._net, "no connection attempt")
assert_string_contains(m._net_lobby._status.text, "BUILD %d" % (Sim_Const.BUILD + 7))
func test_a_failed_lookup_shows_its_reason() -> void:
var m: Node = await _boot()
m._lan_open("join")
m._net_lobby.code_requested.emit("ABCDE")
(_last("/mp/code/ABCDE")["done"] as Callable).call(0, null)
assert_string_contains(m._net_lobby._status.text, "matchmaker")
func test_console_join_with_a_code_resolves_it_on_the_device() -> void:
var m: Node = await _boot()
assert_true(m.apply_dev_command({"kind": "lan_coop", "action": "join", "code": "abcde"}))
assert_false(_last("/mp/code/ABCDE").is_empty())
func test_tapping_a_listed_host_joins_its_ips() -> void:
var m: Node = await _boot()
m._lan_open("join")
var ips: Array[String] = ["10.9.9.9", "10.9.9.8"]
m._net_lobby.join_host_requested.emit(ips)
assert_eq(m._net_last_ip, "10.9.9.9")
assert_eq(m._net_join_queue, ["10.9.9.8"] as Array[String])
  • Step 2: Run to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_lan_easy_join_main.gd -gexit 2>&1 | tail -30 Expected: FAIL (_net_registry, _net_new_transport, _net_join_queue don’t exist; no /mp/ calls).

  • Step 3: Implement in main.gd

Add to the LAN vars block (after var _net_last_ip := ""):

var _net_registry: LanRegistry = null # LAN easy join (2026-10-07): room code + nearby list; dev builds only
var _net_registry_hosts: Array[Dictionary] = []
var _net_join_queue: Array[String] = [] # a host's remaining IPs, tried in order on host_timeout
var _net_host_code := ""
var _net_new_transport: Callable = func() -> ENetTransport: return ENetTransport.new() # test seam

Add helpers (after _net_factory):

# The registry node, created on first use in a dev build with networking allowed. A test assigns one
# with a stub requester first; that one is used even headless.
func _lan_reg() -> LanRegistry:
if _net_registry == null and net_allowed and BuildConfig.dev_tools():
_net_registry = LanRegistry.new()
add_child(_net_registry)
if _net_registry != null and not _net_registry.code_ready.is_connected(_on_lan_code):
_net_registry.code_ready.connect(_on_lan_code)
_net_registry.nearby.connect(_on_lan_nearby)
_net_registry.resolved.connect(_on_lan_resolved)
_net_registry.failed.connect(_on_lan_failed)
return _net_registry
static func _lan_display_name() -> String:
return ("%s · %s" % [OS.get_model_name(), OS.get_name()]).left(24)
func _on_lan_code(code: String) -> void:
_net_host_code = code
if _net_lobby != null and is_instance_valid(_net_lobby):
_net_lobby.set_code(code)
func _on_lan_nearby(hosts: Array[Dictionary]) -> void:
_net_registry_hosts = hosts
func _on_lan_resolved(host: Dictionary) -> void:
if _net_lobby == null:
return
if int(host["build"]) != Sim_Const.BUILD:
_net_lobby.set_status("That game is BUILD %d; this device is BUILD %d. Install the same build on both." % [int(host["build"]), Sim_Const.BUILD])
return
var ips: Array[String] = host["lan_ips"]
_lan_join_ips(LanRegistry.order_ips(ips, LanBeacon.local_ipv4()))
func _on_lan_failed(reason: String) -> void:
if _net_lobby != null:
_net_lobby.set_status(reason)
func _lan_join_code(code: String) -> void:
var reg := _lan_reg()
if reg == null:
if _net_lobby != null:
_net_lobby.set_status("Room codes need the matchmaker, which this build can't reach. Enter the host's IP.")
return
if _net_lobby != null:
_net_lobby.set_status("Looking up %s…" % code.strip_edges().to_upper())
reg.resolve_code(code)
# Try a host's IPs in order: the first now, the rest on host_timeout (see _lan_pump).
func _lan_join_ips(ips: Array[String]) -> void:
if ips.is_empty():
if _net_lobby != null:
_net_lobby.set_status("That host has no LAN address. Enter its IP.")
return
_lan_join(ips[0], false)
_net_join_queue = ips.slice(1)
# `hosting` on the console status: the open host lobby's code + IPs, so the console can pre-fill the
# other device's Join box. null once the run starts or the lobby closes.
func _lan_hosting_status() -> Variant:
if _net_lobby == null or _net == null or not _net.is_host() or _net_started:
return null
return {"code": _net_host_code, "ips": LanRegistry.private_ipv4s(LanBeacon.local_ipv4())}

_lan_dev_command join branch:

"join":
_lan_open("join", String(cmd.get("ip", "")), String(cmd.get("code", "")))

_lan_open — signature func _lan_open(action: String, ip: String = "", code: String = "") -> void:; after the existing join_requested.connect(_lan_join) add:

_net_lobby.join_host_requested.connect(_lan_join_ips)
_net_lobby.code_requested.connect(_lan_join_code)

In the host branch change the beacon name to _lan_display_name(). Replace the join branch with:

else:
_net_lobby.show_joining(ip if ip != "" else _net_last_ip)
var reg := _lan_reg()
if reg != null:
reg.start_nearby()
if code != "":
_lan_join_code(code)
if not net_allowed:
if code == "":
_net_lobby.set_status("Networking is off in headless test runs.")
return
_net_beacon = LanBeacon.new()
if _net_beacon.start_listen(NetCoopRun.PORT + 1) != OK:
_net_beacon = null # someone else holds the beacon port (a second instance): IP only
if ip != "" and code == "":
_lan_join(ip)

_lan_host_with — register after creating the session (this is also the test seam):

func _lan_host_with(t: NetTransport) -> void:
_net = NetCoopRun.create_host(t, randi(), _net_factory, Sim_Const.BUILD, RemoteConfig.active_hash)
_net_started = false
var reg := _lan_reg()
if reg != null:
reg.host_start(Sim_Const.BUILD, NetCoopRun.PORT, _lan_display_name(), LanRegistry.private_ipv4s(LanBeacon.local_ipv4()))

_lan_join — new signature, transport seam, queue reset for a typed/single IP:

func _lan_join(ip: String, clear_queue: bool = true) -> void:
if clear_queue:
_net_join_queue.clear()
_net_last_ip = ip
var t: ENetTransport = _net_new_transport.call()
var err := t.join(ip, NetCoopRun.PORT)
... # rest unchanged

_lan_close — add before _net_lobby teardown:

if _net_registry != null:
_net_registry.host_stop()
_net_registry.stop_nearby()
_net_registry_hosts.clear()
_net_join_queue.clear()
_net_host_code = ""

_lan_pump — replace the beacon block and the not-started elif:

func _lan_pump() -> void:
var now := Time.get_ticks_msec()
if _net_beacon != null:
_net_beacon.poll(now)
if _net_lobby != null and (_net == null or not _net.is_host()):
var heard: Array[Dictionary] = _net_beacon.hosts() if _net_beacon != null else ([] as Array[Dictionary])
_net_lobby.set_hosts(LanLobby.merge_hosts(heard, _net_registry_hosts, LanBeacon.local_ipv4()), Sim_Const.BUILD)
if _net == null:
return
if not _net_started:
_net.frame(InputState.new(), now, true) # handshake only: nothing ticks before the run starts
if _net.is_running():
_start_net_run()
elif _net.is_ended() and _net_lobby != null:
if _net.session.end_reason == "host_timeout" and not _net_join_queue.is_empty():
_lan_join(_net_join_queue.pop_front(), false) # the host's next IP (VPN / second NIC first tried)
else:
_net_lobby.set_status("Could not join: %s" % _net.session.end_reason)
return
if _net_run():
_net_physics(now)

Note: _net.frame on an already-ended session must not throw; the test calls _lan_pump() right after _end, which reaches the elif — if frame() on an ENDED session misbehaves, guard the frame call with if not _net.is_ended():.

_start_net_run — after closing the beacon add:

if _net_registry != null:
_net_registry.host_stop() # the room is full (one guest): drop the code
_net_registry.stop_nearby()

build_status — add "hosting": _lan_hosting_status(), to BOTH returned dictionaries (the sim == null early return and the full one).

  • Step 4: Run the new and existing LAN tests

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_lan_ -gexit 2>&1 | tail -30 Expected: all pass, including test_lan_coop_main.gd unchanged (it never injects a registry, and net_allowed is false headless, so _lan_reg() returns null there).

  • Step 5: Update docs/architecture/coop-and-pilots.md § LAN co-op

Add a bullet after the net/lan_beacon.gd one:

- **Easy join (2026-10-07, dev-only):** `net/lan_registry.gd` (`LanRegistry`) talks to the control
Worker's `/mp/*` registry (CONTROL_KEY-gated, salted-hash "same public network" match, 60 s TTL;
`control/src/mp.js`). The host registers and shows a 5-letter room code (`LanLobby.set_code`); the
joiner's lobby lists registry + beacon hosts merged by IP (`LanLobby.merge_hosts`, other BUILDs
greyed), then a code box, then a collapsed IP box. A host's private IPs are tried in order (own /24
first) and fall over on `host_timeout` (`_net_join_queue`). Worker unreachable = the old beacon +
typed-IP behaviour. The console status carries `hosting: {code, ips}` while a host lobby is open.
Spec: `docs/superpowers/specs/2026-10-07-lan-easy-join-design.md`.

In the main.gd LAN bullet change “lan_coop command (host / join ip / leave)” to “lan_coop command (host / join ip|code / leave)”.

  • Step 6: Full gate

Run: bash scripts/gate.sh Expected: exit 0; determinism baselines unmoved (nothing in /sim changed). Then git status — restore project.godot / audio/bus_layout.tres if anything rewrote them.

  • Step 7: Commit
Terminal window
git add main.gd tests/test_lan_easy_join_main.gd tests/test_lan_easy_join_main.gd.uid docs/architecture/coop-and-pilots.md
git commit -m "feat(lan): easy join in main — room code host registration, nearby list, code join with IP fallover"

Task 5: Remote Console — Join box takes a code or IP, hosting line, last-host prefill

Section titled “Task 5: Remote Console — Join box takes a code or IP, hosting line, last-host prefill”

Files:

  • Modify: control/src/panel/markup.js (LAN card, L52-61), control/src/panel/client.js (applyStatus L30-40 + new LAN block), control/src/panel/tooltips.js (run-lan-join, L22), control/src/whats_new.js (the build: null entry), control/test/_panel-harness.mjs (expose new fns, injectable window)
  • Create: control/test/panel-lan.test.mjs

Interfaces:

  • Consumes: Task 4’s status.hosting ({code, ips} or null) and lan_coop code field.

  • Produces: panel functions lanJoin(), renderLan(s); DOM ids lan-hosting, lan-msg; localStorage key dc.lan.lastHost.

  • Step 1: Extend the harness

In control/test/_panel-harness.mjs: add lanJoin, renderLan to the returned-names string (e.g. after applyStatus,), and pass opts.window ?? {} instead of the literal {} as the window argument.

  • Step 2: Write the failing test — control/test/panel-lan.test.mjs:
// LAN co-op easy join (2026-10-07): the console's Join box takes a room code or an IP, refuses an
// empty box, shows the selected device's hosting line and pre-fills the other device's box.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadPanel } from './_panel-harness.mjs';
import { panelHtml } from '../src/panel.html.js';
import { CLIENT } from '../src/panel/client.js';
const memStore = () => { const m = new Map(); return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)) }; };
const box = (p) => p.document.getElementById('lan-ip');
const text = (p, id) => p.document.getElementById(id).textContent;
test('the Join box placeholder can no longer pass for a value', () => {
const html = panelHtml();
assert.match(html, /id="lan-ip" placeholder="code or IP"/);
assert.ok(!html.includes('placeholder="192.168.1.20"'));
assert.match(html, /id="lan-join"[^>]*onclick="lanJoin[(][)]"/);
});
test('Join with an empty box sends nothing and says why', async () => {
const p = loadPanel();
box(p).value = ' ';
await p.api.lanJoin();
assert.deepEqual(p.sent, []);
assert.match(text(p, 'lan-msg'), /code or IP/);
});
test('a code-shaped value sends {kind:lan_coop, action:join, code} upper-cased', async () => {
const p = loadPanel();
box(p).value = ' abcde ';
await p.api.lanJoin();
assert.deepEqual(p.sent, [{ kind: 'lan_coop', action: 'join', code: 'ABCDE' }]);
});
test('a dotted quad sends ip; junk sends nothing', async () => {
const p = loadPanel();
box(p).value = '192.168.1.20';
await p.api.lanJoin();
assert.deepEqual(p.sent, [{ kind: 'lan_coop', action: 'join', ip: '192.168.1.20' }]);
box(p).value = 'not a host';
await p.api.lanJoin();
assert.equal(p.sent.length, 1);
assert.match(text(p, 'lan-msg'), /not a host/);
});
test('hosting shows a line, is remembered, and pre-fills the box for the other device', () => {
const p = loadPanel({ window: { localStorage: memStore() } });
p.api.renderLan({ hosting: { code: 'ABCDE', ips: ['192.168.1.20'] } });
assert.equal(text(p, 'lan-hosting'), 'Hosting · ABCDE · 192.168.1.20');
assert.equal(box(p).value, '', 'the host device itself is not pre-filled');
p.api.renderLan({ hosting: null }); // console switched to the other device
assert.equal(text(p, 'lan-hosting'), '');
assert.equal(box(p).value, 'ABCDE');
box(p).value = '10.0.0.9';
p.api.renderLan({});
assert.equal(box(p).value, '10.0.0.9', 'never overwrites what was typed');
});
test('hosting with no code yet falls back to the IP; no localStorage never throws', () => {
const p = loadPanel(); // window = {} (no localStorage)
p.api.renderLan({ hosting: { code: '', ips: ['192.168.1.20', { evil: 1 }] } });
assert.equal(text(p, 'lan-hosting'), 'Hosting · (no code yet) · 192.168.1.20');
p.api.renderLan({ hosting: 'junk' });
assert.equal(text(p, 'lan-hosting'), '');
});
test('applyStatus drives the LAN card', () => {
assert.match(CLIENT, /renderLan[(]s[)]/);
});
  • Step 3: Run to verify it fails

Run: node --test control/test/panel-lan.test.mjs Expected: FAIL (lanJoin not defined / placeholder mismatch).

  • Step 4: Implement

control/src/panel/markup.js LAN card becomes:

<div class="card">
<h3>LAN co-op <small>dev</small></h3>
<p class="hint">Host on one device, join from the other: tap it in the in-game list, or type its room code (or IP) here.</p>
<div class="row">
<button id="lan-host" data-tip="run-lan-host" onclick="send({kind:'lan_coop',action:'host'})">Host</button>
<input id="lan-ip" placeholder="code or IP" aria-label="Host room code or IP">
<button id="lan-join" data-tip="run-lan-join" onclick="lanJoin()">Join</button>
<button id="lan-leave" data-tip="run-lan-leave" onclick="send({kind:'lan_coop',action:'leave'})">Leave</button>
</div>
<p class="hint" id="lan-hosting"></p>
<p class="hint" id="lan-msg"></p>
</div>

control/src/panel/client.js: add renderLan(s); as the last line of applyStatus, and add this block after window.send = send; (NO backslashes, backticks or ${ — string-module rule):

// ── LAN co-op easy join (2026-10-07) ─────────────────────────────
// The Join box takes a 5-char room code or a dotted-quad IP; an empty box sends nothing (an empty
// join used to open a beacon-only join an iPhone can never complete). The last host seen is kept in
// localStorage so switching the console to the other device pre-fills its box.
const LAN_CODE = /^[A-Za-z2-9]{5}$/;
const LAN_IP = /^[0-9]{1,3}([.][0-9]{1,3}){3}$/;
const LAN_STORE = 'dc.lan.lastHost';
function lanStore(v){
try{
if(v === undefined) return JSON.parse(window.localStorage.getItem(LAN_STORE) || 'null');
window.localStorage.setItem(LAN_STORE, JSON.stringify(v));
}catch(_){}
return null;
}
function lanJoin(){
const v = String(document.getElementById('lan-ip').value || '').trim();
if(LAN_CODE.test(v)){ setText('lan-msg', ''); return send({kind:'lan_coop',action:'join',code:v.toUpperCase()}); }
if(LAN_IP.test(v)){ setText('lan-msg', ''); return send({kind:'lan_coop',action:'join',ip:v}); }
setText('lan-msg', v ? 'Not a room code or IP: ' + v : "Type the host's code or IP");
}
window.lanJoin = lanJoin;
function renderLan(s){
const h = s && s.hosting && typeof s.hosting === 'object' ? s.hosting : null;
const code = h && typeof h.code === 'string' ? h.code : '';
const ips = h && Array.isArray(h.ips) ? h.ips.filter((x) => typeof x === 'string').slice(0, 4) : [];
setText('lan-hosting', h ? ['Hosting', code || '(no code yet)'].concat(ips).join(' · ') : '');
if(h && (code || ips.length)) lanStore({code: code, ips: ips});
const input = document.getElementById('lan-ip');
if(!h && !input.value){
const last = lanStore();
const fill = last && (last.code || (Array.isArray(last.ips) && last.ips[0]));
if(typeof fill === 'string') input.value = fill;
}
}

(setText is defined further down in the same script; function declarations hoist, so the order is fine.)

control/src/panel/tooltips.js run-lan-join body: 'Joins the host by its room code (5 letters, shown on the host) or its IP. An empty box sends nothing. Both devices need the same build.'

control/src/whats_new.js: add to the build: null entry’s items (first position):

{ text: 'Dev builds only: LAN co-op is easier to join. The host shows a 5-letter room code; the joining device lists games on the same network to tap, or takes the code, so nobody types an IP (Enter IP is still there as a last resort). Games on another BUILD are listed greyed. In this console the Join box takes a code or an IP, refuses an empty box, and remembers the last host so switching to the other device pre-fills it.', where: 'Debug Settings: Host / Join LAN co-op; this console: LAN co-op card' },
  • Step 5: Run all node tests

Run: node --test control/test/*.test.mjs Expected: all pass (including panel-modules.test.mjs’s no-backslash rule and panel-tooltips.test.mjs).

  • Step 6: Commit
Terminal window
git add control/src/panel/markup.js control/src/panel/client.js control/src/panel/tooltips.js control/src/whats_new.js control/test/_panel-harness.mjs control/test/panel-lan.test.mjs
git commit -m "feat(console): LAN Join takes a room code or IP, refuses an empty box, pre-fills the last host"

Task 6: Review, merge into the overnight branch, rollout (Chris approves before any outward step)

Section titled “Task 6: Review, merge into the overnight branch, rollout (Chris approves before any outward step)”
  • Step 1: Adversarial review — dispatch bh-reviewer on the integration diff (git diff origin/overnight-2026-10-07...HEAD), one fix round, then bash scripts/gate.sh green.
  • Step 2: Merge into overnight-2026-10-07 from its worktree (.claude/worktrees/overnight-2026-10-07), gate again, git push origin overnight-2026-10-07.
  • Step 3: Tell Chris and get a go for the outward steps below (Worker secret + D1 migration + deploy are production-facing for the console).
  • Step 4: Worker rollout (from the overnight worktree; rm platform/tvos/build platform/tvos/build-ios first if those symlinks exist — re-create them for the device install). Write the commands to a script file and bash it (secrets-guard), never echo the salt:
#!/bin/bash
set -euo pipefail
source ~/.secrets
if [ -z "${BULLET_HEAVEN_MP_SALT:-}" ]; then
openssl rand -hex 24 | lumara-passwords add BULLET_HEAVEN_MP_SALT --group "$(lumara-passwords group-of BULLET_HEAVEN_CONTROL_KEY 2>/dev/null || echo BulletHeaven)"
source ~/.secrets
fi
cd control
export CLOUDFLARE_API_TOKEN="$CF_LUMARA_DEPLOY_TOKEN" CLOUDFLARE_ACCOUNT_ID="$CF_ACCOUNT_ID"
printf '%s' "$BULLET_HEAVEN_MP_SALT" | npx wrangler secret put MP_SALT
npx wrangler d1 execute bullet-heaven-control --remote --file=migrations/0002_mp_hosts.sql
npx wrangler deploy
curl -fsS "https://bullet-heaven-control.chris-allen-06f.workers.dev/mp/nearby?key=$BULLET_HEAVEN_CONTROL_KEY" ; echo

Check lumara-passwords --help for the right group flag/value before running (the group-of subcommand above is a guess; use whatever group BULLET_HEAVEN_CONTROL_KEY lives in). Expected smoke output: {"hosts":[]}.

  • Step 5: Dev BUILD ≥ 265 on the devices via skill bh-worktree-device-install (bump past the highest BUILD on live branches first, regenerate the build manifest + control/src/tunable_docs.js if the gate asks).
  • Step 6: Manual device test (Chris) per spec § Testing: Mac or ATV hosts → iPhone joins from the nearby list with no typing → then by code → then Worker unreachable (e.g. Wi-Fi with no internet) falls back to typed IP. Record whether the nearby list ever missed a same-network host (the v4/v6 mismatch risk) in memory bullet-heaven-overnight-2026-10-07.

Written 2026-10-07 by the planning session (Chris said “continue with the net improvements” after a /clear; that resolved to this spec’s next step, writing-plans).

  • Decisions already made (spec): nearby list → room code → typed IP; dev/testers only behind CONTROL_KEY; Bonjour, the multicast entitlement and QR codes rejected. Planning-time calls on the spec’s “Open” items: host display name = "<OS.get_model_name()> · <OS.get_name()>" capped at 24 chars (also used for the beacon now); cross-network “same account” listing stays out of scope.
  • Planning-time rulings (not in the spec): MP_SALT unset → /mp/* answers 503 after the key check (fail closed); a refresh whose row expired re-takes its old code if still free; the host deregisters when the run starts (room is full, one guest); a code that arrives after host_stop is released at once; the console never overwrites what was typed and only pre-fills when the selected device is not hosting; a registry row’s port is ignored on join (always NetCoopRun.PORT, as today). No ownership check on refresh-by-code (dev-only, 31^5 codes): revisit before any player-facing promotion.
  • Authorized: implementing Tasks 1-5 on a branch off origin/overnight-2026-10-07, merging into that branch and pushing it (repo rule: push after every merge).
  • Still needs Chris’s same-day OK: Task 6 Step 4 (Worker secret MP_SALT, remote D1 migration 0002, console Worker deploy from the branch) and Step 5 (Dev BUILD ≥ 265 device installs). Merging the overnight branch into main is a separate pending Chris call (playtest verdict first; memory bullet-heaven-overnight-2026-10-07).
  • State outside the plan: the console Worker is ALREADY deployed from the overnight branch (version 1cd14251, LAN Host/Join buttons); a console deploy from main drops them. platform/tvos/build{,-ios} symlinks exist untracked in the overnight worktree: rm them before a Worker deploy (a PreToolUse hook also blocks deploys with untracked files), re-create for an install. main has unrelated uncommitted economy-sim edits (another session’s work): don’t touch.
  • Gotchas: -gselect is a substring match; a fresh worktree needs godot --headless --path . --import once; panel string modules forbid backslashes (use [.] / [0-9]); the security-guidance hook flags const KEY = 'k' in tests as a credential (test key, ignore); the deploy-guard hook fires on any Bash command whose TEXT contains the deploy command, even a heredoc writing docs.
  • Next action: consult bh-sdd-concurrent, then subagent-driven-development. Task 1 (Worker) and Task 2 (LanRegistry) are independent and can run in parallel worktrees; Task 3 needs Task 2; Task 4 needs 2 + 3; Task 5 depends on Task 4 only through the status.hosting contract, so it can run after Task 1 alongside 3/4.