- HTML 44.2%
- Kotlin 26%
- JavaScript 18.5%
- CSS 7.8%
- Just 1.9%
- Other 1.6%
A pack now mirrors its canonical bit's layout: index.html and manifest.json at the root, everything the client imports under lib/. The point is that a pack's imports become byte-identical to the online client's — index.html's './lib/rules.js' stays './lib/rules.js', and lib/tilt.js importing './rules.js' still finds its sibling. The only import the bundler still rewrites is the transport, which is the one thing that genuinely differs offline. The old flat layout forced the opposite: rules.js sat at the pack root, so every module that imported it had to be flattened alongside it, and the bundler carried a flatten map, a reserved-names collision list, and a path-rewriting pass to keep that consistent. All of it is deleted on the webbits side. Nesting is the simpler design and it scales to a pack with any module tree. The host needed no behaviour change: `PackManager.readFile` already took any pack-relative path, `assetReader` already passed the full remainder through, and `ServerRoutingTest` was already asserting that `/cribbage/lib/rules.js` serves. Only the manifests move, pointing `entry.rules`/`entry.core` at `lib/`. The parse defaults stay flat, since a manifest with no `entry` block predates the convention. Verified on a Pixel 8a: 14/14 instrumented tests pass, including the three that read the migrated paths — a scripted backgammon game played to a winner off `packs/backgammon/lib/rules.js`, cribbage per-seat redaction, and reconnect resuming seat and state. The running host serves all seven of Snake's modules from `lib/` and 404s the old flat paths. Unit tests, lint, and the webbits drift check are clean for all four packs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| .scratch | ||
| app | ||
| docs | ||
| gradle | ||
| just | ||
| .gitignore | ||
| AGENTS.md | ||
| app.just | ||
| build.gradle.kts | ||
| build.just | ||
| CLAUDE.md | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| Justfile | ||
| README.md | ||
| settings.gradle.kts | ||
| test.just | ||
Offline Game Host
An Android app that hosts browser-based multiplayer games over a local Wi-Fi hotspot, with no Internet required. One phone becomes the game server; everyone else joins by scanning a QR code and playing in their browser — no app to install, no login, no account.
How it works
One Android device acts as the host by:
- creating a local Wi-Fi hotspot,
- serving the game's HTML/JS/CSS/assets over an embedded HTTP server,
- acting as the authoritative WebSocket game server, and
- displaying a QR code that players scan to join.
Android Host
+---------------------------+
| Native Android App |
| ├─ HTTP server |
| ├─ WebSocket server |
| ├─ Game engine |
| └─ QR generator |
+-------------+-------------+
|
Wi-Fi Hotspot
|
+-----------------+-----------------+
| |
+-------------+ +-------------+
| Browser | | Browser |
| Player 2 | | Player 3 |
+-------------+ +-------------+
The Android device is the single authoritative server; every browser talks only to the host. The QR code encodes a plain HTTP URL (e.g. http://192.168.43.1:1999/join/7KQ4) — no custom URI schemes, no native client.
The host app presents two numbered steps that map directly onto two sub-flows: (1) Connect Devices (get everyone onto one Wi-Fi network) and (2) Start Game Server (bring up the server and load the game). Each step shows its own QR code — the first joins the Wi-Fi, the second opens the game.
Host flow
Sub-flow 1 — hotspot (Connect Devices)
The host chooses how the shared Wi-Fi network gets created:
- Auto (
LocalOnlyHotspot) — the app creates the network itself. Tap Start Wi-Fi Hotspot; Android hands back a system-chosen SSID and passphrase, which the app renders as a "Scan to join Wi-Fi" QR. The hotspot has its own lifecycle, separate from the server — Stop Wi-Fi Hotspot tears it down independently. - Manual — the host runs their own hotspot from Android Settings. The app can't set or read that network, so it guides the host to name it
GameOn/ set the known passphrase, best-effort detects whether a hotspot is up (✓ / ⚠), and offers Open hotspot settings. Because the network name is known up front, the "Scan to join Wi-Fi" QR appears immediately — guests can join before the server even starts.
Either way, guests end this sub-flow connected to the host's Wi-Fi.
Sub-flow 2 — load game (Start Game Server)
Once devices are on the network, tap Start Server. The app starts the embedded HTTP/WebSocket server (Auto requires the hotspot to be up first; Manual can start right away against the Settings hotspot), determines the local IP, and creates a session. It then shows:
- a "Scan to open the game" QR encoding the join URL (
http://<host-ip>:1999/join/<room>). The host device can have more than onewlanaddress, so the best-guess AP address is shown first with a "Not working? Show other addresses" fallback (see ADR-0001). - a game picker (choose which bundled game to serve),
- a live player roster — each seat shows presence (● present / ○ disconnected / — empty) and can be given a host-local label, and
- Open game in browser (host plays too) and Stop Server.
Guest flow
Sub-flow 1 — hotspot
Scan the host's "Scan to join Wi-Fi" QR (or pick the GameOn network manually) → the device joins the host's Wi-Fi. No app, no login.
Sub-flow 2 — load game
Scan the host's "Scan to open the game" QR → the browser opens the join URL → game assets download over HTTP → the WebSocket connects → the player claims a seat and lands in the lobby → the game begins. A refresh, brief Wi-Fi drop, or screen sleep reconnects automatically.
Goals & non-goals
Goals
- Zero Internet connectivity required during gameplay.
- Only the host installs an application; players use only a modern browser.
- Existing browser-based games require minimal modification.
- Reuse the existing PartyKit networking model where practical.
- Simple enough for solo development.
Non-goals (initial release)
- Internet play
- NAT traversal
- Peer-to-peer
- Automatic service discovery (mDNS/Bonjour)
- Multiple simultaneous sessions
- Persistent accounts
- Spectators
- Voice/chat
- Cross-session persistence
Design
Networking
- HTTP serves HTML, JS, CSS, images, and sounds.
- WebSocket carries gameplay, lobby, and reconnect traffic.
- No external networking of any kind.
HTTP API
| Route | Purpose |
|---|---|
GET / |
Landing page |
GET /join/:session |
Returns the game HTML for a session |
GET /assets/* |
Serves bundled assets |
GET /health |
Returns { "status": "ok" } — useful during development |
WebSocket
ws://192.168.43.1:1999/ws
Client sends JoinSession; server replies SessionAccepted; normal gameplay messages follow.
Session model
Each hosted game creates a Session ID (e.g. 7KQ4), a join token, and a random secret. The join URL embeds the session ID; the server validates the session before allowing gameplay.
Game transport
Games depend on an abstract transport rather than PartyKit directly, so the same game logic runs online or offline with only a transport swap:
interface GameTransport {
send(message: unknown): void;
close(): void;
onMessage(fn): void;
onClose(fn): void;
}
Implementations: PartyKitTransport (online) and LocalTransport (offline host). The game engine never knows which transport it is using.
Hosting game assets
A game ships as an installable pack (ADR-0004): a directory holding a manifest.json, the browser client at its root, and everything the client imports — including the rules that run in QuickJS — under lib/, mirroring the canonical game's own layout so the bundler rewrites no import paths. Packs bundled in the APK live under assets/packs/<id>/ and are copied to filesDir/packs/<id>/ on startup; arcade packs are fetched as zips into the same place. The heavy vendored dependencies (assets/web/vendor/ — Shoelace, Tailwind, Alpine) and shared chrome (assets/web/lib/ — theme, fonts) stay in the APK and are shared across every pack rather than duplicated per game.
assets/
packs/
backgammon/{ manifest.json, index.html, local-transport.js, rules.js, game-core.js }
cribbage/{ manifest.json, index.html, local-transport.js, rules.js, game-core.js }
consensus/{ manifest.json, index.html, local-transport.js, rules.js, game-core.js }
web/
vendor/ # shared: shoelace, tailwind, alpine
lib/ # shared: theme.css, fonts
The server composes those two roots: a request under an installed pack's id is served from the pack, anything else falls back to the bundled shared asset. rules.js is byte-identical to what the client imports — one file, run by QuickJS on the host and by the browser locally.
Adding a game is installing a pack, not writing Kotlin. HostService builds a runtime for every pack the registry scanned, taking its seat encoding straight from the manifest's seatValues/seatLabels, so the lobby, session, and engine machinery never learn a particular game. See ADR-0003 for the game registry, the per-recipient redaction seam (hidden-information games like cribbage), and game-defined seats; ADR-0004 for the pack format and why webbits is the canonical source.
Seat count is whatever the manifest declares. The board games take two; consensus takes eight. Players fill seats in join order, and the state envelope's playersPresent is keyed by the manifest's own seatLabels.
Every message a client sends is a game intent forwarded verbatim to the pack's rules, with one exception: {"type":"host:name","name":"Alice"} is consumed by the host itself to label that seat on the facilitator's screen. It never reaches the engine and never touches game state. The host: prefix is reserved for exactly this reason — game intents are bare verbs. Sending it is optional; a pack that never does looks the same as it always has. The facilitator's own label always outranks a self-reported name.
Authoritative server
The host always owns game state: player move → server validates → server mutates state → server broadcasts update. Clients never directly modify state.
Reconnection
Browsers reconnect automatically after a refresh, a temporary Wi-Fi interruption, or screen sleep. The server maintains the session until it is explicitly ended.
Security
The threat model is intentionally minimal: prevent accidental joins, avoid URL guessing, avoid stale sessions. Encryption, authentication, and malicious hotspot users are out of scope. Future versions may add per-session random join tokens.
Browser requirements
Must support WebSockets, ES Modules, Fetch, and Canvas. Target browsers: Chrome, Safari, Firefox.
Android responsibilities
Hotspot lifecycle · embedded HTTP server · embedded WebSocket server · QR generation · session management · battery wake lock (if required) · determining the local IP · hosting UI. The browser is responsible only for gameplay.
Failure handling
| Condition | Behavior |
|---|---|
| Browser refresh | Reconnect automatically |
| Wi-Fi disconnect | Show "Connection lost — attempting to reconnect…" |
| Host exits | Show "Game ended." |
| Invalid session | Show "This game is no longer available." |
Future features
Multiple games— done: backgammon, cribbage, and consensus ship today, and the host is game-agnostic (see ADR-0003)- An "arcade": let the user choose which games are installed to keep the APK small — the pack format and
installFromArcadeexist (see ADR-0004); the browse-and-install UI does not - Real-time games: the host has no clock, so tick-driven packs like snake-battle cannot run yet (see ADR-0005)
- Tournament mode
- Saved games
- AI players
- Local player profiles
- Bluetooth transport
- Wi-Fi Direct
- LAN discovery (mDNS)
- Multiple concurrent sessions
- A plugin game architecture
Repository layout
.
├── Justfile # task runner entry point (`just help`)
├── build.just # gradle build recipes (build::)
├── test.just # gradle test recipes (test::)
├── app.just # adb / device recipes (app::)
├── just/common.just # shared JAVA_HOME, app identity, scaffolding guard
├── app/ # Android :app module (Kotlin, Compose, Ktor, QuickJS)
├── docs/
│ ├── adr/ # architecture decision records
│ └── agents/ # conventions for AI agents working in this repo
└── .scratch/ # local issue tracker + PRDs, one dir per effort
App identity: net.tfks.gameon (release), net.tfks.gameon.debug (debug — carries an applicationIdSuffix so it installs alongside an Obtainium-managed release build).
Getting started
Prerequisites: just, a JDK 17 (Homebrew openjdk@17 is the default; override with JAVA_HOME), and the Android SDK platform-tools (adb). Deno and qrencode are used by the prototype helpers under .scratch/.
just setup # check prerequisites and report what's missing
just help # list every recipe (doubles as a gradle/adb cheat sheet)
just map # show the issue-tracker frontier for the active effort
Everyday recipes:
just build::apk # debug APK
just test::units # fast JVM unit tests
just app::run # install + launch on a connected device
just app::logs # logcat filtered to the app's pid
just app::wireless switches adb over to Wi-Fi so the USB cable can't drop the link — handy since the host phone is also running a hotspot.
Releasing (Obtainium)
Releases are published to Forgejo Releases so friends can auto-update via Obtainium using its Codeberg/Forgejo/Gitea source type.
The git tag is the single source of truth: versionName is the tag minus its leading v, and versionCode is derived (major*10000 + minor*100 + patch), so the integer Android compares is never hand-maintained.
just build::release v0.2.0
This builds a signed release APK and publishes it with fj (forgejo-cli). Prerequisites:
-
fjinstalled and authenticated (fj auth login). -
keystore.propertiespresent at the repo root (gitignored) — the recipe refuses to publish an unsigned build. It names your keystore and credentials:storeFile=release.keystore storePassword=… keyAlias=… keyPassword=…app/build.gradle.ktsreads this file: when it's present, release builds are signed with your persistent key so friends can update in place; when it's absent (a fresh clone or CI without the secret), everything still builds but the release APK comes out unsigned. -
Push your release commit first:
fjcreates the tag server-side on the default branch.
To create the signing key once (keep release.keystore and its passwords safe — losing them means Obtainium users must uninstall/reinstall to take a new key):
keytool -genkeypair -v -keystore release.keystore -alias gameon \
-keyalg RSA -keysize 2048 -validity 10000
Working with agents
This repo follows the mattpocock/skills conventions for AI-agent-driven development. If you're an agent (or configuring one), start with AGENTS.md, then read the per-skill guides under docs/agents/:
- Issue tracker (
docs/agents/issue-tracker.md) — issues and PRDs live as local markdown under.scratch/<feature>/; there is no remote tracker. - Triage labels (
docs/agents/triage-labels.md) — maps the canonicalmattpocock/skillstriage roles to this repo'sStatus:strings. - Domain docs (
docs/agents/domain.md) — single-context layout: oneCONTEXT.mdplusdocs/adr/at the repo root, created lazily as terms and decisions get resolved.
CLAUDE.md is a symlink to AGENTS.md so both Claude Code and other agents read the same instructions.