Two games, two ladders. Beer die is 2v2 or 3v3, first to 21; spikeball is 2v2 to 25, 15 or 11, picked game by game. Both are 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, and a pill in the top bar switches sport, each with its own colours and its own live night. Ratings are OpenSkill, replayed per sport from that sport's game history on every change. On the roster, tapping a name opens that player's profile, which is also where a signed-in phone edits the name and nicknames.
Since the 29 September restyle there is a desktop layout, with a rail down the left and two-column pages, and the phone's tab bar is a glass capsule along the bottom.

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.
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, one ladder per sport. 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. Each sport's replay is cached against a fingerprint of its own games (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, and the point scales with the target: five for a game to 21, six to 25, four to 15, three to 11. Inside that margin a win is an ordinary win; beyond it 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.


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.


Status
Live and in use. Rankings, the game log and player pages are public; changing anything needs the house PIN or an invite link. It runs on Cloudflare Workers with Postgres on Neon. Deploys are run by hand with npm run deploy, which applies the schema migration first and then ships to Workers; merging to main does not deploy. October's fixes to stay under the Workers CPU limit, which production ran before they reached GitHub, were pushed to main on 7 November. Every night at 09:00 UTC a Cloudflare Cron Trigger writes a JSON backup to Workers KV. Known gaps: beer die (2v2, 3v3) and spikeball (2v2) only, each rated separately, and no rating decay, so a badge marks inactive players instead. 835 tests across 60 files passed in CI against Postgres 17, including component tests in jsdom, on f0ea6cd (run 42418592709).