Skip to content

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.20 that 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 s host_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.

  • 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_KEY on 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; leave deletes at once. Expired rows are ignored on read and purged in the Worker’s existing cleanup pass (the same place status/caps rows 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), and nearby matches 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 migration control/migrations/0002_mp_hosts.sql.
  • Limits: lan_ips capped at 4 entries, each must be a private IPv4 (10/8, 172.16/12, 192.168/16) — anything else is dropped; name capped at 24 chars; reject port outside 1024–65535.
  • 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, emits code_ready(code); host_stop() → leave.
    • refresh_nearby() every 3 s while the join lobby is open → emits nearby(hosts).
    • resolve_code(code) → emits resolved(host) or failed(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_hosting gains a code).
    • 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).
  • 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).
  • The game’s console status gains hosting: {code, ips} while a LAN host session is open (and null otherwise).
  • The LAN card shows “Hosting · ABCDE · 192.168.x.x” when the selected device is hosting, and stores the last seen {code, ips} in localStorage (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’s lan_coop join command gains an optional code field (resolved on the device via lan_registry).
  • Join with an empty box is refused in the console with “type the host’s code or IP”; the placeholder becomes code or IP so 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.gd needs no new row.
  • Worker (node –test, control/test/): code alphabet + collision retry; refresh keeps the code; expiry at 60 s; leave deletes; nearby matches 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-private lan_ips dropped; bad bodies → 400; missing key → 401.
  • GUT: lan_registry against an injectable HTTP stub (no network in tests); lobby list merge and de-dupe through the real LanLobby (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.

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 nearby should also list games from the same account across networks (would need accounts; out of scope).