The game is beer die: 2v2 or 3v3, first to 21, win by 2, and the winners stay on. Someone at the table taps who won and what the losers finished on. Everyone else's phone shows the rankings, a game log, head-to-heads and badges. Ratings are OpenSkill, replayed from the full game history on every change.

Fig. 1 Logging a game at the table: pick the winners, tap the losing score, pick the next challengers, and the rankings update.Recorded from the real app against a local database of made-up players.

Built for bad party wifi

Every game is written to a queue in local storage first and shown immediately, then flushed to the server in order. Each game carries a client-generated id and the write path is idempotent on it, so a retry after an ambiguous failure cannot record a game twice.

The queue tells a transient failure (a database cold start, a dropped connection: keep it and retry) from a permanent one (a malformed or refused game: set it aside and say so), because retrying a permanent failure forever blocks every game behind it. The server maps errors to status codes through an explicit allowlist, so anything unexpected is retryable by default. A stuck queue can be recovered. A discarded game cannot.

Tap the result
the game gets a client-generated id
Local queue
written to local storage first; the UI updates immediately
Serialized drain
one game at a time, in order
POST the game
passcode checked, payload validated, insert idempotent on the id
Transient failure
keep it and retry
Permanent failure
set it aside and say so
Fig. 2 The offline write path. The phone never waits on the network to show a result.Drawn from lib/client/queue.ts, app/api/games and docs/OFFLINE.md in the repository.

Ratings are a pure function of history

The games table is append-only apart from a voided flag. Ratings, per-game rating changes, rank movement, streaks and badges are all derived by replaying it in order. An undo is just a void, a tuning change is just a replay, and there is no stored rating that can drift out of sync with the games it came from. The replay is cached against a fingerprint of the table (row count, highest ordinal, voided count), which only ever grows, so a stale cache is always detected and rebuilt.

Margin of victory counts, but only past a point. A win within five points is an ordinary win; beyond that the extra weight grows logarithmically, so a skunk counts for more without dominating a season. New players are provisional until ten games, and the rankings say so instead of hiding them.

Power rankings screen: rank, name, record, streak, rating, and weekly movement for each player
Fig. 3 Power rankings with rating, record, streak and weekly movement.Screenshot of the app against a local database of made-up players.

A four-digit PIN that stays safe on the open internet

The house wanted a PIN, not accounts. With ten thousand possibilities, the design limits guessing to one place and makes it slow. The PIN is checked only at the gate, which is rate limited in Postgres (serverless instances share no memory) under a transaction-scoped advisory lock, so a hundred parallel guesses cannot all read zero failures. A global cap of twenty wrong guesses a day works out to about 250 days to find the PIN on average.

The session cookie holds a signature, never the PIN, so hammering the write API with every possible cookie value earns ten thousand rejections and learns nothing. Changing the PIN or resetting the invite link signs out every device at once, because both are inside every signature.

Game log screen grouped by night, each game showing the teams, the score, and the rating change per player
Fig. 4 The game log, grouped by night, with per-game rating changes.Screenshot of the app against a local database of made-up players.

Status

Live and in use. Rankings, the game log and player pages are public; changing anything needs the house PIN or an invite link. A nightly job writes a JSON backup to blob storage. Known gaps: 2v2 and 3v3 only, and no rating decay, so a badge marks inactive players instead. 561 tests across 39 files run in CI against Postgres 17.