Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
20 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; for someone coming back it comes first, and finished games wait further down), your reports to the keeper, and about this game, which opens the about panel: a dialog of tabs (Origins, Games so far, More games, Send word; a game may rename the first two in its own world, as Waving Hands' Duels so far, or add its own, as Dip's Playing here),TabbedDialog.svelte, with each game's words in its hall. The address names the dialog (#about,#about/tally), so a plain link opens it; a game may put its guide and rules in dialogs the same way, as Dip does. Its strip of tabs isTabs.svelte. -
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 line said while a game is running carries its round (
turn). A seat may also speak privately, naming seats (room.say(text, to)): the line is shown to its sender and those seats alone, never to the gallery, and a game whose talk is its heart puts the choice of whom in its talk box. -
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.
-
The keeper can bring a seat back: a player whose browser lost the seat (Safari's seven days, an app's own browser, cleared data) writes to the keeper, and
<slug>-reseat.sh ROOM SEATon the host gives the seat a new token and prints a link carrying it in the fragment. The old token opens nothing after. The script answers through the game server's/keeperdoor, which Caddy does not proxy. A spectator at a game in play is told both ways back, and the hall asks before forgetting a game in play. -
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.jsonand its heading links to the family's landing page, gamehall.kestrelsnest.social. A new game is added to the kit'sfamily.jsonand given a card on that page (gamehall/in the kit); both are hand-kept. -
A move names the round it was made for. The room counts rounds played (
resolvedin its view), and a page sends that count with each move; the server refuses a move whose count is behind, so a slow connection, a second click or a stale tab can never be taken as the move for the next round. A page without the count is told to reload. -
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 chipping in unlocks nothing and nothing is held back 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.preferencesand 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.tallyandGameSpec.tallyBegun. -
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, andcreatereceives them. A game with one way to play declares none. An option that changes how many can sit (a smaller map) says so throughGameSpec.seatsFor. The server, the tally and the ledger replay all read a room line throughcleanOptionsand size a table withtableSize, both inspec.ts, so each creates the same game from the same ledger.
The engine
- Pure and deterministic:
create(names, seed, rules, options)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. A few such rounds are played at once; past that (a table every person is out of), the bots play on a round every two seconds so the table can be watched and the server answers everyone else between rounds. The view says so (playingOn), and a seat may ask for the rest at once (room.hurry()). - 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.
Optional hooks
A game implements only the hooks it needs; without one, the kit behaves as if it were never there.
validate(state, seat, input): a reason to refuse a move, shown to the player.seatsFor(options): fewer seats for some table options.settled(input): whether an input is final. A game with drafts answers false until the player says it is done; a draft may be replaced (and a settled move taken back as a draft) until the round resolves, and the round waits for every seat's settled input. A seat's view carries its own input in waiting (yourInput), so a page reopened shows the draft, and its input in the last round it played (yourLastInput), so the page can tell it what became of its own choice.rankable(options): the choices a table asks each seat to rank before the deal (sides, roles, colours). The lobby shows a seat its own ranking and how many have ranked, nothing more; the start line records every ranking andcreatereceives them. A solo table asking for one waits in the lobby for the player's.botPress(state, seat, press, people, trigger): bots that talk. A bot speaks when a round opens and when a person writes to it (or says a first word to the table); its lines go privately to the people it names, as ledger lines, with anotefor its own memory that no view ever carries.botInputreceives the same press, so a bot can keep what was agreed.parting(press, viewer)gives the bots' last words once the game is over. WithoutbotPress, private talk to a bot is refused.tally(state): the game's own lines for the tally, from a finished game;tallyBegun(options): lines from every game begun, finished or not, by its table's options (games begun on each variant).preferences,options: the preferences panel's settings and the host's table choices (above).
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), and a looser one
on a seat's own actions, each a ledger line; a table of friends never nears
them. An address holds at most twenty sockets, a socket that stops
answering pings is closed, and a request has seconds, not minutes, to
arrive. 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>-reseat.sh ROOM [SEAT]: who sits at a table and when each last acted; with a seat, a new token for a player locked out, as a link.<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. On the shared host the hall'shall-nightly.shruns both for every game and checks in with Sentry Crons once for all, 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.
The voice
Every game speaks in its own world. Wherever the page tells a player what happened, it gives an image as well as the fact: not "Corwin takes 5 from a fireball" but "A fireball bursts over Corwin, for 5". The fact stays exact and whole (the number, the duration, who is affected, the turn a thing will happen), and the image carries it.
- The chronicle is where the voice matters most. Each move, blow, enchantment, arrival and death gets its own picture. Something that unfolds over turns (an illness, a siege, a fuse) is told a turn at a time, names the turn it ends on when it begins, and warns on the last turn anything can be done about it.
- The rest of the surround speaks the same way: the lobby, the guide, the status lines, the verdict. Plain words for controls and buttons; the images belong to what happens, not to what you press.
- Our own words. An older implementation's prose is a guide to the tone, never a source to copy.
- Plain chrome. Headings, tabs, captions and notes say what is there in ordinary words. The images live in what happens, not in the labels around it. The patterns readers take as generated text are listed under "Writing for players" below.
- Changing text is not changing rules. Chronicle wording is not part of the ledger, so it ships without a rules revision; the deploy gate compares outcomes, not sentences. A test that pins a phrase should pin the fact inside it.
Writing for players
Players have learned the habits of generated text, and once they spot one they trust the rest of the page less. Every game built from this kit has needed an editing pass to remove them. Write without them from the start, and read each new page against this list before it ships.
- One semicolon is fine. A semicolon in every paragraph is a tell. Use two sentences, or join with "and" or "so". Lists of rules may keep their semicolons.
- No statement, colon, explanation. "The table is yours: seat a bot, invite a friend" becomes "You can seat a bot or invite a friend". The same goes for a label and a colon at the start of a line ("Shieldwall: two or more pieces..."), and for a heading that ends in a colon.
- No headings made of "The" and an abstract noun ("The game", "The tally"). Name what is there ("Origins", "Games so far").
- A button may name its action and its cost ("Absorb: take 2 instead"). The colon rule is for sentences, not for controls.
- No clipped fragments in threes as a habit. "Four towers. One crown. No second chances." Write one sentence instead. A game may keep one deliberate triplet as its tagline when the energy is the point, as Wiz-War's "Steal treasure. Blast through walls. Outwit your friends." does. Once is a choice, and a pattern is a tell.
- No closing aphorisms. A paragraph ends on its last fact, not on a line meant to sound wise ("This is the whole art", "Nothing is unlocked by it and nothing is withheld without it").
- No "X, not Y" flourishes ("drawn, not borrowed"). Say what it is.
- No page that talks about itself. Avoid "This page walks the room", "the page keeps watch" and "a walk through the page". Describe the game and what the player does.
- No spaced em dashes in the interface, and few anywhere.
- Nothing copied from another game that is untrue here. Text carried over from the kit or a sibling game is the most common source of errors. Look for hidden information in a game with none, points in a game without them, a bot's name from another game, and "both" at a table that seats more than two. Read every inherited line as if it were new.
- Every claim checked. A date, a count, "only" or "never" is checked against the source and the engine. "A thousand years before chess" was wrong, and so was "a shield is the only defence".
- Words for any table size. Use "every player" for what each does on a turn, and plurals for opponents ("your rivals"), unless the game seats exactly two.
- Some words are load-bearing. Before rewording, grep for the string. An end-of-game reason written to a ledger line is matched later by exact text, and rewording one needs a migration. The client may match a server error by its words. Replay screenshots kept as goldens include captions, and stored shares carry event text. A bot's lines may be told apart by their shape, and the examples it gives players must still parse.
- Bots should not share scaffolding. When each personality recites the same clause ("I keep out of X, you keep out of Y, through 1903"), they stop sounding like people. Give each its own way of saying the terms, and avoid assistant phrasing ("Certainly", "Happy to help", "I hope you understand").
The game's own voice (above) is not on this list. The chronicle's images, the in-world names, the keeper and a game's whimsy are what the editing passes protect. The template's plain lines are a default. A game may say the same thing in its own voice ("The gallery listens. The host has not opened its mouth.") as long as the fact survives.
The move
- Every history reads top to bottom, oldest first, and scrolls to the newest line: the ledger, the chronicle, table talk. That is the order people already know from chat.
- The move and its button stay together and in view. Whatever the player has chosen this turn, what it will do, any warning, and the button that commits it sit in one row that does not scroll away.
- Warn before, not after. A move that is legal but wasted is pointed out while it can still be changed, and left legal.
- A move can be typed when the game's moves have short names, with Enter to commit and Backspace to take one back.
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.