Files
game-kit/CONVENTIONS.md
T

7.2 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. Each line is a ledger line, so it replays with the game, and the hall counts lines said while you were away.
  • 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) 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.

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.