Appearance
Architecture
gammonchain is one stateful Node process. That single fact decides where it can run, and it is also why the process is cheap to run.
browser (no build step, ES modules)
├── board.js SVG board, portrait rotation, tap/drag
├── identity.js your key, your reveal chain
├── app.js WS protocol, in-page verification, puzzle, account
└── imports /src/core/* ← the SAME rules engine, dice derivation and verifier
the server runs; there is only one implementation
│ WebSocket
server/
├── index.js transport only: HTTP routes, sockets, scheduler
├── actor.js one live match: clocks, reconnects, forfeits, the signed record
├── lobby.js who is online, what is on offer, who plays whom
├── db.js players, ratings, puzzles, subscriptions (SQLite)
├── records.js signed records (a directory today, a bucket tomorrow)
├── rating.js Glicko-2, weighted by match length
├── bot.js the built-in evaluator
├── puzzles.js the daily puzzle
├── mail.js provider-agnostic email over fetch
└── identity.js the server's own persistent signing keyThe pieces
One actor per live match. MatchActor (src/server/actor.js) owns everything mutable about one match: the state machine, the server seed, clocks, grace timers, the latest hash and the retained signatures. It shares nothing with any other actor. Work on an actor is serialised, so async steps never interleave.
The lobby (src/server/lobby.js) holds the connected sessions, the open seeks, and a map from match id to actor. When a player reconnects, the lobby finds their seat by public key and hands them back to the same actor.
Transport (src/server/index.js) is the only file that knows about HTTP and WebSockets. The game logic can therefore be tested without a socket and moved to a different runtime.
Durable state is deliberately small:
| what | where | notes |
|---|---|---|
| players, ratings, match index, puzzles, subscriptions, accounts | one SQLite file, gammonchain.db | node:sqlite, so no native module and no database service. WAL mode. Migrates itself on boot |
| signed match records | records/<id>.json.gz | write once, never updated |
| the server's signing key | server-key.json | must never change |
| analyses | annotations/ | computed once per match |
| position cache | analysis-cache/ |
There is no database in the hot path. Moves, rolls and signatures touch only actor memory. SQLite is written when a match finishes, when someone signs up, and for puzzles and mail.
The only runtime dependency is ws. Mail, error reporting and S3 backups are all plain fetch or node:crypto, with no SDKs.
Why serverless does not fit
Both players must reach the same process for the whole match, and a 9-point match can run 20–40 minutes. The match lives in that process's memory.
DEPLOY.md (platform facts checked in August 2026) walks through Vercel as the example:
- Connections die at the function's maximum duration. Vercel has supported WebSockets on Fluid compute since a public beta on 22 June 2026. But Hobby caps a function at 300 s, Pro defaults to 800 s, and Pro can opt into 1800 s (beta). A 9-point match outlasts all three, so players would be disconnected mid-match, repeatedly.
- No instance affinity across reconnects. A connection stays on one instance for its lifetime, but a later connection is not guaranteed to reach the same instance. Vercel's own docs recommend external Redis for shared state. After any reconnect, the in-memory match would be on the wrong machine.
The same reasoning rules out API Gateway WebSockets + Lambda. That model turns the actor into per-message invocations and loses the in-memory match entirely.
A split does work: static client plus read-only HTTP API on a static host, WebSocket server elsewhere. The client is plain ES modules with no build step. By default it connects to location.host. public/app.js accepts an override from a ?server= query parameter or a global GAMMONCHAIN_WS variable. For a first public instance, one container running everything is simpler and cheaper.
Platforms that sleep drop live matches
Anything that spins down when idle (Render's free tier sleeps after 15 minutes) ends every match in progress when it sleeps. Anything without a persistent disk loses records and, worse, server-key.json. See Requirements.
The exception: Cloudflare Durable Objects
A Durable Object is a single-threaded stateful actor with its own embedded SQLite storage, which is exactly what MatchActor is. The class was shaped for a 1:1 mapping: one match, one Durable Object. WebSocket Hibernation lets the object sleep while keeping the connection open, so a player thinking for two minutes costs nothing, and a turn-based game spends most of its time like that.
It is still a port, not a config change (DEPLOY.md):
| moves unchanged | gets rewritten |
|---|---|
src/core/* (rules, dice protocol, hash chain) | src/server/index.js becomes a Workers fetch handler |
src/verify/* | actor.js becomes the Durable Object class |
| the analysis code | db.js moves from node:sqlite to DO SQLite or D1 |
| the whole client | records.js writes to R2 instead of disk |
backup.js is no longer needed, because Cloudflare handles durability |
DEPLOY.md estimates the port at a couple of days. For free-tier sizing, see Requirements.
Scaling out
Actors share nothing, so matches are the shard key, and scaling out is embarrassingly parallel: route by match id. The two options in DEPLOY.md are Durable Objects, or Kubernetes/ECS behind a consistent-hash router. The second has more moving parts for this workload. Before that, the planned steps are to grow one container vertically, then move the static client and read-only API to a CDN and records to S3/R2. Records are immutable append-only blobs, which makes that the easiest possible migration.
The rate limiter is per process. With N instances behind a round-robin proxy, a caller can get up to N times the limit. See Rate limiting.