Files
game-kit/CONVENTIONS.md
T

10 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 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 <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.sh nightly at 00:12 UTC: one JSON line a day of counts, no addresses. <slug>-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 <slug>-reports): pull-reports.sh mirrors the desk to the Desktop with a digest; report-reply.sh <ip> <id> <status> "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 #<slug>-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.