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
The host opens the app β Local Network β picks a game. The phone starts
a small web server on itself (port 4747).
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.
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).
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:
Direction
Frame
Meaning
guest β host
hello
"Do we speak the same version?"
guest β host
join
"Seat me β here's my name and my remembered ID"
guest β host
action
"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 β host
leave / ping
"I'm off" / "still there?"
host β guest
joined
"Welcome β this is your player ID"
host β guest
roster
"Here's who's in the room" (sent to everyone)
host β guest
state
"Here's your view of the game" β secrets filtered per player, just like online
host β guest
error
"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 β guest
host-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
2026-10-08The host's phone hands a game its list. A game
whose content is a secret holds no list of its own: whoever referees hands it one as the match begins. On
a hotspot that is the host's phone, handing over the list the app ships with. A guest's own copy of the
app carries that list too, which is one more thing to close before this mode returns
(the pause page, the checklist). Why:
decisions, 2026-10-08.
2026-10-08A move the screen made for its player says so. If the
rules turn it down, the host sends no "that didn't work" back, the same as online and on one phone. Why:
decisions, 2026-10-08.
2026-10-04The host's phone deals from a wide seed. The
referee asks the app for one fresh seed per match and the app answers with 256 bits from the phone's own
strong generator, the same form the server uses online. Why:
decisions, 2026-10-04.
2026-10-02The game picker's pictures are scenes drawn in code, so there is no
bundled poster art any more. The picker still renders with zero internet.
2026-09-17A refused move now answers in the game's own
words here too. The error frame carries the game's name for the refusal and
the numbers its sentence needs, and no longer carries the note a developer wrote beside the
throw β which the guest screen used to print at whoever was holding the phone. A
browser guest now reads the same sentence as the host and as an online player.
Why.
2026-09-14Page checked against the code. The mode's
promise now reads "every game that plays online"; the fixed file counts are gone (the packed
copy grows with the app); the build-service upload guard and the local-build hook are written
down. Nothing about how the mode works changed.
2026-08-11Paused for launch, hidden not deleted.
Two iPhone limits, both Apple's and neither fixable by us: a guest iPhone marks an
internet-less hotspot as "no internet" and routes around it over its own mobile data, so it
never reaches the host; and airplane mode switches the iPhone hotspot off entirely, which
deletes the on-a-plane story. The mode still works on shared wifi and every test still passes β
one flag in the app and one on the site simply stop offering it.
The come-back note holds the resume checklist and the open
product questions.
2026-08-03The mode stopped treating a phone like a
server. A guest now waits for the host indefinitely instead of announcing after twelve
seconds that the game had ended β a locked phone is normal, not an outage β and only the host
ending the room actually ends it. A heartbeat makes a dropped connection noticeable at all
(walking out of range does not close a socket, so nothing used to notice). Anyone can join at
any moment, including mid-game and while the winner screen is up; they watch and are dealt into
the next game. Being removed by the host, or refused, now says so instead of showing
"Joiningβ¦" forever, and a browser guest's Back button returns to the name screen rather than a
QR scanner their browser will never allow.
2026-08-03Two release traps closed. Every iOS
release build was shipping without permission to use the local network β a development tool's
cleanup step deleted it β which would have made in-app guests hang with no error. And a local
build could ship a weeks-old copy of the browser-guest page; the freshness test was checking
the copy that does not ship. Both are now verified in the built app, not the repo.
2026-08-03Nobody picks a colour and there
is no seat limit. The host phone hands each joiner the next free colour, and
a hotspot room seats however many people are actually there. Both had been copied
from online mode, where a live roster and a shared database make them mean
something; here a guest picked blind (the roster has not arrived yet, so the
pre-selected first swatch was the one the host already held) and the cap turned
friends in the same room away at eight.
2026-07-18A freshness test now
guards the packed-in browser app: it fails whenever the built export is missing a
registered game, closing the "new game invisible to browser guests" gap. Lobby
poster art is bundled into the app too, so the game picker renders with zero
internet β matching the mode's whole promise.
2026-07-17Guest-experience hardening from the
screen audit: the join screen now rejects addresses that can't be a real host
(a bare word like "banana" used to sail through); a browser guest who can't
reach the host gets an honest "Can't find the host" card after 12 seconds β
including when the connection layer gives up β instead of searching forever or
being told "the game ended"; and opening Host-a-game in a browser now explains
hosting needs the phone app instead of showing a form that could never
work.
2026-07-16M4 shipped: all four games verified over
real LAN (host phone + browser guest), including error paths and the
airplane-mode test.
2026-07-16Cloud builds run the guest export via a
postinstall hook β chosen after research confirmed it's the only hook
that runs after dependencies exist and before iOS pods are installed, on both
platforms.
2026-07-16Android packaging override added (the
underscore-folder fix) + release-build guard so this class of silent failure can't
recur.
2026-07-15iOS server library: Telegraph,
a deviation from the originally planned FlyingFox (recorded in the M4 plan). Its
helper pods need two hand-patched Podfile lines β re-applied automatically by a
config plugin.
2026-07-12Guest strategy decided in the spec:
friends without the app play in the browser, served by the host's phone β
no "everyone must install first" wall at the party.
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.