Ported from Wiz-War, where each earned its keep. All on the ledger: galleryTalk, challenge and rematch lines, gallery chat signed with a name, held seats on a rematch table's room line. Twenty-two protocol checks against a scratch server and a replay after restart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
8.4 KiB
House conventions for a game in this kit
Every game built from the kit shares one shape, so a player who has been at one table knows the next, and the keeper runs them all the same way. This is the shape. When a game strays from it, the reason should be in the game's README.
The vocabulary
- The hall is the landing page (
/). Name, Play now against the bot, Open a table, or join one with a four-letter code, then your games (the ledger of tables this browser holds a seat at, with whose move it is and unread talk), your reports to the keeper, and about this game — a labor of love. - A table is a room. Its code is four letters or digits from an alphabet
with no look-alikes, and its link is
/join/CODE. The masthead button reads invite friends · room CODE and copies the link. Whoever opened the table is the host; they may Seat a bot and they Begin when the company suits them, at any size of table; there is no automatic start. - The Peanut Gallery is where a link takes anyone without a seat: a read-only view showing only what every seat could see, counted for the table (3 in the gallery). While the table is laid, a watcher is offered a seat first; the gallery is the afterthought.
- Table talk is the chat panel, seated players only unless the host lets the Peanut Gallery talk; then a watcher signs a name no seat holds and their lines read Name (gallery). Each line is a ledger line, so it replays with the game, and the hall counts lines said while you were away.
- A rematch: a finished table may call for one. A new table of the same size opens with the same bots, seats held for the same people, and the caller as host; a second call finds it already open. Held seats refuse strangers until their players are back.
- The keeper's bell: while a table is laid, a seated player may Challenge the keeper. A seat is held under the keeper's name, the call goes on the ledger, and the keeper's phone rings through Sentry. The note beside the button says what hour it is where the keeper lives, so a caller knows whether to wait ten minutes or come back tomorrow.
- A seat travels by phrase: Transfer seat in the masthead mints four words good for ten minutes and one use; the hall's bring a seat from another device claims them. The browser that claims holds the seat too.
- Held seats are doubted before they are dropped: the hall forgets a seat only on the second refusal in a row from the server. A restart, a stale answer or the wrong backend answer with ignorance, and ignorance is not deletion.
- The ledger is the append-only JSONL file that is the game: room, seats, start (seed and rules revision), each seat's submitted input, the turn that resolves them, talk, over. A room is its ledger replayed through a deterministic engine; nothing else is saved. A half-written round survives a restart because every input is written as it arrives. Ledgers survive deploys and restarts and are backed up nightly, so they hold nothing secret: a seat line carries the SHA-256 of its token, never the token, which goes once to the browser that earned it.
- Report in the masthead opens the slip: what happened, what you expected, a screenshot if you have one. It is pinned to the room and the round. The keeper replies; the reply appears under the report in the player's hall, and the player may answer. Every report and answer rings the keeper's phone through Sentry; the desk's own replies ring nothing.
- The rules page (
/rules) is the game's text, structured for reading mid-game, with the original source verbatim in collapsible sections. The engine follows that text and nothing else; a divergence is a bug. The page ends with send word. - How to play (
/guide) walks the page in the game's own voice, one screenshot per section, from the hall to a first game. - Send word is the contact paragraph: mail, Bluesky and Mastodon, and where the keeper roosts. It lives in the about panel; the colophons, the rules page and the guide each carry a one-line mail link.
The engine
- Pure and deterministic:
create(names, seed, rules)andresolve(state, inputs)are functions of their arguments with a seeded generator inside the state. Bots are functions of the state too. This is what lets a ledger be the whole record. - Simultaneous by default: every seat that
needsInputsubmits, then the round resolves at once, bots included. A seat that owes an extra turn alone (a time stop, a bonus move) is just a state where only it needs input; the server resolves rounds owed only to bots straight away. - Hidden information is the engine's business:
view(state, viewer)returns what one seat may see, and the SPECTATOR view is the intersection of everyone's. The server sends nothing else. - The rules revision is stamped on every game at its start line. Bump
it only when the deploy gate shows a fix changes how an already-played
round resolves; keep the old path behind
state.rules < Nand pin both with tests. A fix that diverges from no ledger ships without a bump.
The server
One Node process per game, on its own port, run from source with tsx as
a sandboxed systemd service, behind Caddy handle blocks that route /api
and /ws before the static files. Rooms in memory are evicted after a week
idle and come back from disk on the next visit. Rate limits sit on the doors
a stranger can knock on (rooms, seats, talk, the desk); a table of friends
never nears them. A seat token is minted once, sent once, and compared by
hash in constant time ever after; a shared link carries none. Room codes
are checked against the disk as well as memory, so an evicted room's code
is never reissued over its ledger. A game may refuse a move with a reason
(validate), and the player sees the reason.
Operations
Every game gets the same tools, installed by deploy.sh on every deploy:
deploy/deploy.sh <ip>: checks, tests, build, the determinism gate (every production ledger replayed with the engine about to ship and compared with the server; a DIFFERS or REFUSED stops the deploy), rsync, restart. Hashed assets from the previous week stay so an open page can still fetch what it was built against.<slug>-visitors.sh(skill<slug>-visitors): who is here now, who came today, every room opened and how far it got. Names seated for the first time are marked NEW.<slug>-pulse.sh(skill<slug>-pulse): service health, journal errors, the rollup's last week, the backup's last word, the box's vitals.<slug>-rollup.shnightly at 00:12 UTC: one JSON line a day of counts, no addresses.<slug>-backup.shnightly at 07:23 UTC: rclone to the shared Space, mirror plus dated snapshots pruned after 90 days. Both check in with Sentry Crons so a missed night is noticed.- The reports desk (skill
<slug>-reports):pull-reports.shmirrors the desk to the Desktop with a digest;report-reply.sh <ip> <id> <status> "text"answers withresolved,by-designoropen. A fix goes live before its reply goes out. - Sentry: one project per game;
sentry-slack-alert.shroutes every issue to#<slug>-notifications, run once by the keeper with their own token. KEEPERandKEEPER_TZin the service unit name the keeper a table may call and where they sleep.
The look
Each game chooses its own palette and typeface in src/app.css; the
components name only the tokens. Keep the page quiet: one accent, no
animation that does not answer an action, the game itself as the hero. No
emoji in the interface; the words do the work. Phone width first: a sticky
bar for the move if the board runs long, lists that shorten, gutters of
16px, no horizontal scroll.
Things learned the hard way
- Caddy's
try_filesrewrites/apibeforereverse_proxyunless the proxy sits in its ownhandleblock first. npm install --omit=optionalon the droplet breaks Vite; the server carries its own smallpackage.jsonand installs only that.- A dynamic
/join/[code]route must setprerender = false. - Two tabs on one origin share a seat; test a second player from
127.0.0.1againstlocalhost(Vite needs--host). - Svelte proxies do not
structuredClone: hand the engine$state.snapshot. - Old saved states lack fields added later: backfill on load, and bump the save version when the shape changes.
- A token typed into a session lands in the transcript; run token-bearing scripts yourself and revoke afterwards.