LAN co-op easy join (nearby list + room code) — design
LAN co-op easy join (nearby list + room code) — design
Section titled “LAN co-op easy join (nearby list + room code) — design”Status: approved in chat by Chris 2026-10-07; next step is an implementation plan (writing-plans).
Branch: overnight-2026-10-07 (builds on T9’s LAN host/join; not on main yet).
Implements early: option (c) “relay-assisted discovery” from
2026-10-07-networked-coop-design.md § 5, minus the Durable Object relay (that stays Phase 4).
Confidence tags: [Certain] checked in code or on device today; [Likely] strong inference; [Guessing] needs a measurement.
First real two-device LAN test (Dev BUILD 264, 2026-10-07): join WORKED once Chris typed the host’s IP. Before that it timed out, because:
- The Remote Console’s Join box showed a greyed placeholder
192.168.1.20that read like a filled value; Join with an empty box opened a beacon-only join, and an iPhone cannot hear the UDP broadcast beacon without Apple’s multicast entitlement, so the client hit the 5 shost_timeout(net/lockstep_session.gd). [Certain] - Typing an IP is the only way an iPhone/iPad can join today, and on Apple TV it means the remote’s on-screen keyboard. [Certain]
Goal: the second device joins with no typing in the common case (same home network, internet up), a short code when that fails, and a typed IP only as a last resort.
Decisions (Chris, 2026-10-07)
Section titled “Decisions (Chris, 2026-10-07)”- Join UX: nearby list (tap to join) first, 5-letter room code as fallback, typed IP hidden last.
- Audience: dev/testers only for now — Dev builds + Remote Console, behind the existing
CONTROL_KEYon the control Worker. No privacy-page / ASC label change yet; designed so it can be promoted to players later (that promotion is a separate spec: own route, rate limiting, no shared key, privacy page + label). - Rejected: Bonjour (native GDExtension on iOS/tvOS/macOS), applying for the multicast entitlement (approval lead time, same-Wi-Fi only), QR codes (Apple TV can’t scan).
1. Worker: host registry (control Worker, control/src/worker.js)
Section titled “1. Worker: host registry (control Worker, control/src/worker.js)”All three routes require CONTROL_KEY exactly like the existing game-facing routes. POST bodies are
guarded (malformed → 400, never 500).
| Route | Body / params | Returns |
|---|---|---|
POST /mp/host |
{code?, build, port, name, lan_ips: [..]} |
{code, ttl_s} — allocates a code on first call, refreshes on later calls (client resends its code) |
POST /mp/host/leave |
{code} |
{ok} — deletes the row |
GET /mp/nearby |
— | {hosts: [{code, name, build, port, lan_ips}]} — live rows whose network key matches the caller’s |
GET /mp/code/<CODE> |
— | {code, name, build, port, lan_ips} or 404 |
- Room code: 5 chars from an unambiguous alphabet (
ABCDEFGHJKMNPQRSTUVWXYZ23456789, no 0/O/1/I/L), allocated at random with a collision retry; case-insensitive on lookup. - Liveness: host re-POSTs every 15 s; a row expires 60 s after its last refresh;
leavedeletes at once. Expired rows are ignored on read and purged in the Worker’s existing cleanup pass (the same placestatus/capsrows are pruned). - “Same network” key: from
CF-Connecting-IP. IPv4 → the full address; IPv6 → the /64 prefix. The Worker stores only a salted SHA-256 of each key (salt = a Worker secret), never the raw IP. A host row records the key of every family it has been seen from (a refresh over v6 after a v4 register adds the v6 key), andnearbymatches if ANY of the caller’s keys equals ANY of the row’s. [Likely] risk: one device reaching the Worker over IPv4 and the other over IPv6 for every request → no match. Mitigation is the room code, which never depends on the IP match. Measure how often it happens on Chris’s network before adding anything cleverer. - Storage: D1 table
mp_hosts(code TEXT PRIMARY KEY, net_keys TEXT /*json array of hashes*/, name TEXT, build INTEGER, port INTEGER, lan_ips TEXT /*json*/, updated_ms INTEGER), new migrationcontrol/migrations/0002_mp_hosts.sql. - Limits:
lan_ipscapped at 4 entries, each must be a private IPv4 (10/8, 172.16/12, 192.168/16) — anything else is dropped;namecapped at 24 chars; rejectportoutside 1024–65535.
2. Device
Section titled “2. Device”- New
net/lan_registry.gd(Node; HTTP only — no sim access, so determinism is untouched):host_start(build, port, name, lan_ips)→ POST, keeps the returned code, refreshes every 15 s, emitscode_ready(code);host_stop()→ leave.refresh_nearby()every 3 s while the join lobby is open → emitsnearby(hosts).resolve_code(code)→ emitsresolved(host)orfailed(reason).- Uses the same base URL + embedded key as
net/control_client.gd(dev builds only; a build without dev tools never constructs it). Works without the console being armed/paired. - HTTP failure (offline, Worker down) is silent for the list (beacon-only fallback) and a status line for code resolve (“couldn’t reach the matchmaker — enter IP”).
- Lobby (
ui/lan_lobby.gd):- Host view: room code in large type, the IP(s) underneath (
show_hostinggains acode). - Join view, top to bottom: the merged nearby list (tap to join) → “Enter code” (5-char field; on-screen keyboard on TV) → “Enter IP” (collapsed by default).
- The list merges Worker hosts and beacon hosts (
set_hosts), de-duplicated by IP; each row shows name + BUILD; a different BUILD is shown greyed with “needs BUILD N” and is not joinable (the lockstep hello would refuse it anyway).
- Host view: room code in large type, the IP(s) underneath (
- IP choice: a host can have several private IPs (Wi-Fi + Ethernet + VPN). The joiner orders them:
same /24 as one of its own
LanBeacon.local_ipv4()addresses first, then the rest; tries each with the existing connect timeout before reporting failure. - Offline: if the Worker is unreachable, everything behaves as today (beacon + typed IP).
3. Remote Console
Section titled “3. Remote Console”- The game’s console status gains
hosting: {code, ips}while a LAN host session is open (andnullotherwise). - The LAN card shows “Hosting · ABCDE · 192.168.x.x” when the selected device is hosting, and stores
the last seen
{code, ips}inlocalStorage(try/catch), so switching the console to the other device pre-fills the Join box. - The Join box accepts a code OR an IP (
/^[A-Za-z2-9]{5}$/→ code, dotted quad → IP); the game’slan_coopjoincommand gains an optionalcodefield (resolved on the device vialan_registry). - Join with an empty box is refused in the console with “type the host’s code or IP”; the placeholder
becomes
code or IPso it can’t be mistaken for a value. - Parity: the Debug Settings rows stay as they are (host / join open the lobby, which now has the list
and code entry), so
tests/test_debug_console_parity.gdneeds no new row.
Testing
Section titled “Testing”- Worker (node –test,
control/test/): code alphabet + collision retry; refresh keeps the code; expiry at 60 s; leave deletes;nearbymatches same v4, same v6 /64, mixed-family host row, and does NOT match a different network; only hashes are stored (assert no raw IP in the row); non-privatelan_ipsdropped; bad bodies → 400; missing key → 401. - GUT:
lan_registryagainst an injectable HTTP stub (no network in tests); lobby list merge and de-dupe through the realLanLobby(beacon hosts + registry hosts → one row per IP, BUILD-mismatch rows disabled); IP ordering by own /24. - Console: node test that Join with an empty box sends nothing; a code-shaped value sends
{kind:'lan_coop', action:'join', code}. - Manual (devices): Mac or ATV hosts; iPhone joins from the nearby list with no typing; then by code; then with Wi-Fi-only (Worker blocked) falls back to typed IP. Record whether the v4/v6 match ever failed.
Rollout
Section titled “Rollout”Dev builds only. Needs: a control Worker deploy from this branch (D1 migration + the salt secret via
wrangler secret put), a new Dev BUILD (≥ 265) on the devices. No store build, no privacy/ASC change
while it stays behind CONTROL_KEY.
Open (decide during planning, not blocking)
Section titled “Open (decide during planning, not blocking)”- Host display name: device name if Godot exposes it on iOS/tvOS (
OS.get_model_name()gives the model, not the user’s name) — default to “· ”. - Whether
nearbyshould also list games from the same account across networks (would need accounts; out of scope).