# 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 family list** in the about panel, *More games in Kestrel's Hall*, comes from `family.json` and its heading links to the family's landing page, gamehall.kestrelsnest.social. A new game is added to the kit's `family.json` and given a card on that page in the blog repo; both are hand-kept. - **The hosting line** follows the family list in the about panel: the games are free and will stay free, the servers cost a few dollars a month, and ko-fi.com/kestrelsnest is where anyone may chip in. It says nothing is unlocked by it and nothing withheld without it, and it never names a game's rights holder: a tip is for the hosting, not the game. - **Preferences** are what a browser remembers about its player: a panel opened from *preferences* in the hall and the masthead, kept in localStorage and nowhere else. The kit's own settings are motion (full or reduced, and the system's reduced-motion request counts too) and confirming a move before it is sent; a game declares more in `GameSpec.preferences` and the panel renders them. The hall also remembers the host's last table options as the next table's defaults. - **The tally** sits in the about panel: what the ledgers add up to (tables, games begun and finished, names seated, turns, time at the table, games with a bot, talk), counted on demand from the files and cached a minute. A game adds its own lines through `GameSpec.tally`. - **Table options** are the host's choices when opening a table (a variant, a side, a length): the game declares them in `GameSpec.options`, the hall renders them as selects, the room line records them, and `create` receives them. A game with one way to play declares none. ## The engine - Pure and deterministic: `create(names, seed, rules)` and `resolve(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 `needsInput` submits, 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 < N` and 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 `: 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. - `-visitors.sh` (skill `-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. - `-pulse.sh` (skill `-pulse`): service health, journal errors, the rollup's last week, the backup's last word, the box's vitals. - `-rollup.sh` nightly at 00:12 UTC: one JSON line a day of counts, no addresses. `-backup.sh` nightly 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 `-reports`): `pull-reports.sh` mirrors the desk to the Desktop with a digest; `report-reply.sh "text"` answers with `resolved`, `by-design` or `open`. A fix goes live before its reply goes out. - Sentry: one project per game; `sentry-slack-alert.sh` routes every issue to `#-notifications`, run once by the keeper with their own token. - `KEEPER` and `KEEPER_TZ` in 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_files` rewrites `/api` before `reverse_proxy` unless the proxy sits in its own `handle` block first. - `npm install --omit=optional` on the droplet breaks Vite; the server carries its own small `package.json` and installs only that. - A dynamic `/join/[code]` route must set `prerender = false`. - Two tabs on one origin share a seat; test a second player from `127.0.0.1` against `localhost` (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.