← All docs Games for Group Β· Architecture

LAN Mode β€” No Internet Needed

The host's phone becomes the game server, and friends play from their phone's browser over shared wifi or the host's hotspot. This page describes how it works β€” all of it still true, and still tested.

⏸️ Not offered to players right now The mode is hidden behind a launch flag: the product ships Same Device and Online, and no screen leads here. The code, the tests and the native server are untouched. Why, and the one-line flip that brings it back: lan-pause.html.
πŸ“‘ The host's phone runs the referee and a tiny web server. Friends don't install anything β€” their browser loads the game from the phone itself. Every game that plays online plays here too.

How a game night works

  1. The host opens the app β†’ Local Network β†’ picks a game. The phone starts a small web server on itself (port 4747).
  2. The screen shows a QR code and an address like 192.168.1.42:4747 β€” that's the host phone's own address on the local network.
  3. Friends hop onto the same wifi or the host's hotspot and scan the QR. Their browser opens the game β€” a full copy of the app's web version, served straight from the host's phone. Friends with the app installed can join in-app instead (scan or type the address).
  4. Everyone types a name, the host starts, and it plays exactly like online mode β€” because under the hood it's the same referee logic, just running on the phone instead of in the cloud.

The three parts

πŸ“± Host's phone
The antenna β€” modules/expo-lan-server
A native web server inside the app: it hands browsers the game page and keeps a live two-way line (a WebSocket) open to every guest. Built on Telegraph (iOS) and Ktor (Android).
The referee β€” packages/match-host
Pure match logic: who's in the roster, who's seated, run the rulebook for each move, send each player their own filtered view. The on-phone twin of what Convex does online. Each match is dealt from a seed of its own, 256 bits from the phone's strong generator, which the host sends to nobody: a guest cannot work out the host's hand from their own.
The browser app β€” guest-dist/
A complete web build of the app packed inside the app binary, so it can be served with zero internet.
⇆ wifi / hotspot β€” no internet ⇆
Guest Β· browserScans the QR β€” plays in Safari/Chrome, nothing installed.
Guest Β· appJoins in-app over the same connection.
Guest Β· browserNo seat limit β€” however many people are in the room.
✨ Zero extra wiring per game The LAN referee reads the app's own game registry and just strips off the visuals (src/modes/lan/serverRegistry.ts). Register a game once and it plays on LAN automatically β€” every game whose meta.modes.online is on does.

What travels over the wifi

Guests and host talk in tiny labeled messages ("frames") defined in one shared file β€” src/modes/lan/protocol.ts β€” so both ends can never drift apart. Version 1, human translation:

DirectionFrameMeaning
guest β†’ hosthello"Do we speak the same version?"
guest β†’ hostjoin"Seat me β€” here's my name and my remembered ID"
guest β†’ hostaction"I made a move". When the game's screen sent it for the player (time ran out), it says so, and the host does not answer a refusal of it: there is nobody to tell
guest β†’ hostleave / ping"I'm off" / "still there?"
host β†’ guestjoined"Welcome β€” this is your player ID"
host β†’ guestroster"Here's who's in the room" (sent to everyone)
host β†’ gueststate"Here's your view of the game" β€” secrets filtered per player, just like online
host β†’ guesterror"That didn't work" β€” a code, the game's own name for the refusal, and any numbers its sentence needs. The guest turns those into friendly words in the language it is reading; the sentence itself never travels
host β†’ guesthost-paused / host-resumed"The host switched apps β€” hold on / we're back"
πŸ›‘οΈ Built to not crash A garbled or malicious message is simply ignored β€” a bad guest can never take down the host's game. Wrong protocol version? Politely rejected with VERSION_MISMATCH.

The fragile part: the packed-in browser app

That guest-dist/ copy of the web app must be rebuilt (bun run export:lan-guest) and packed into every app build. If it's missing, the app looks perfectly fine β€” until a browser guest gets a blank page at the party. It has silently vanished three times, in three different ways:

πŸ“– True story #1 β€” cloud builds skipped it Builds made on Expo's cloud servers never ran the export step, so they shipped without the browser app entirely. Fixed: the export now runs automatically inside every cloud build (a postinstall hook that activates only there).
πŸ“– True story #2 β€” local builds skipped it too Builds on the Mac install at the monorepo root, where the app package's own install hook is not the package manager's to run. Fixed: a second hook that the build service itself calls from inside the app package.
πŸ“– True story #3 β€” Android threw away the JavaScript Android's packaging tool silently drops folders whose names start with an underscore β€” and the web build keeps its JavaScript in _expo/. The page shipped, its brain didn't. Fixed with a packaging override in the LAN module's build config.
⚠️ The guards in place Android release builds fail loudly if the export is missing, and every build made through the build service refuses to upload an app whose packed copy is empty or older than the export (scripts/assert-guest-bundle.mjs). A raw Xcode archive runs neither hook, so that path is checked by hand against the fresh export β€” iOS snapshots the copy at pod install time, so exporting without re-podding ships the previous one. And because the packed copy is frozen, a game added after the last export exists everywhere except for browser guests β€” a freshness test (guestBundle.test.ts) fails whenever the built export is missing a registered game, so a stale guest game list can't slip through a green test run. Steps: docs/releasing.html.

Phone-platform notes

πŸ€– Android

Hosting runs as a small "foreground service" β€” Android's way of letting an app keep working when you briefly switch away. Verified: the room survives the host app being backgrounded.

🍎 iOS

iOS pauses apps in the background, so the host pausing is a designed-for event: guests get host-paused, the room freezes politely, and host-resumed picks it back up.

πŸ”§ Dev vs release

In development the server can point at a fresh export on the Mac (EXPO_PUBLIC_LAN_STATIC_ROOT); release builds always serve the copy packed inside the app (src/modes/lan/staticRoot.ts decides).

Decision log

Last updated: 2026-10-08 Β· Maintained under the CLAUDE.md rule "Architecture docs β€” keep them in sync". If the code changed and this page didn't, that's a bug β€” fix the page.