Games live in the Expo app at apps/app/src/games/<id>/; the engine is the workspace package
@g4g/engine (packages/engine/), which game.ts imports directly (it bundles cleanly in
Convex, Metro, and Vitest).
A game is a self-contained folder. You add the folder, two registration lines and its
catalog entry for the website — you touch nothing else. No new Convex functions, no changes to packages/engine/, src/ui/, the
mode shells, or other games. (LAN guests additionally need the guest bundle rebuilt — see
"Run it in each mode" below.) Read the repo-root CLAUDE.md and the plain-language
architecture pages first; the contract lives in
packages/engine/src/types.ts (READ-ONLY — quoted below verbatim).
Start with the scaffolder: from apps/app, bun run new-game <kebab-id> ["Display Name"]
(add --online-only for a game with no pass-and-play) generates the folder as a compiling,
test-passing turn-based skeleton: the full contract (including onPlayerRemoved and
result), a selector the Board and reducer share, one host setting, a
HowToPlay on the shared HowToSteps card, a rules.test.ts on
@g4g/engine/testing, and every word in copy.en.json (the rules file carries keys
only) — you replace the toy mechanic and the words. It touches no shared file; it prints the next
steps instead, with the exact lines: the two registration lines below, the catalog entry and its
name, the card and its link-preview still, restarting Metro with --clear, the gates, and the LAN guest rebuild.
The canonical worked example is the shipped game, apps/app/src/games/tic-tac-toe/.
The excerpts below come from that folder. Read its files alongside this doc.
apps/app/src/games/<id>/
├── game.ts PURE rules + meta. No React/browser/Convex. No Date.now/Math.random.
├── Board.tsx Mode-agnostic UI. Default export (React.lazy loads it).
├── HowToPlay.tsx The learn-in-under-a-minute card (a named export). A game with MODES skips this
│ file: its `MODES` in game.ts carry `steps` per mode and the card draws them.
├── rules.test.ts Vitest reducer tests. REQUIRED.
├── copy.en.json EVERY word this game says. One file per language.
└── index.ts Assembles the client GameModule (meta + rules + lazy Board + HowToPlay + copy).
A game with one way to play keeps HowToPlay.tsx at the folder root (TTT and Connect Four play modes,
so they have none — their MODES carry the steps); a components/ (game-private React) and
content/ (question/word/prompt data) subfolder are both fine when a game needs them. Content whose
items carry an answer is kept in two files there, content/local.ts and
content/online.server.ts, and the rules file imports neither: see "Content that is a secret".
<id> is kebab-case, unique, and doubles as the URL slug (/games/<id>). Component files
are PascalCase.tsx; everything else lowercase. Named exports (except Board.tsx, which
default-exports so React.lazy can load it).
game.ts is bundled twice: by Metro/Vite for the client (same-device) and by Convex for
the server (convex/games.ts reaches it through apps/app/src/games/pure.ts, by relative path). The Convex bundler does not
know the ~/* path alias, but it resolves workspace packages fine, so:
game.ts imports the engine as @g4g/engine — the workspace package barrel. Never
'~/engine/...' or app-internal ~/* paths in game.ts (they would break the Convex
build). Everything reachable from Convex through game.ts must be @g4g/engine, the
game's own relative files (./content/…), or nothing.Board.tsx, index.ts, and rules.test.ts are client/test-only and use the ~/*
alias freely for app-internal imports ('~/theme/colors', '~/ui/cn') plus @g4g/engine
for the contract.From packages/engine/src/types.ts:
interface GameMeta {
id: string // kebab-case, unique; the URL slug
name: string
tagline: string // one-liner for cards
description: string // paragraph for the /games/<id> SEO page
accent: AccentId // 'pink' | 'cyan' | 'yellow' | 'green' | 'purple'
icon: IconId // 'square'|'circle'|'diamond'|'triangle'|'dice'|'grid'|'card'|'star'
family: GameFamily // 'turn-based' | 'race' | 'simultaneous' | 'timed'
minPlayers: number
maxPlayers: number
approxMinutes: number
modes: { sameDevice: boolean; online: boolean }
secretTurns?: boolean // the next player's screen hides a SECRET — keeps the hand-off's no-peeking dressing
clock?: boolean // the players are racing a clock — the host may stop it (nothing else to write)
languages?: ReadonlyArray<string> // e.g. ['en'] — the pickers hide the game from anyone else
} // tagline / description are copy KEYS, not sentences — see "The words"
interface RulesCtx {
rng: () => number // seeded [0,1) PRNG from (seed, version). NOT Math.random.
now: number // ms epoch, injected. NOT Date.now().
}
interface GameAction<A> { playerId: PlayerId; payload: A } // PlayerId = string
interface GameRules<S, A> {
setup(playerIds: Array<PlayerId>, ctx: RulesCtx, settings?: unknown): S
reduce(state: S, action: GameAction<A>, ctx: RulesCtx): S // returns next state or throws GameError
onPlayerRemoved(state: S, playerId: PlayerId, ctx: RulesCtx): S // REQUIRED — what losing a seat means
canPlayerLeave?(state: S, playerId: PlayerId): boolean // may this seat be given up RIGHT NOW?
// presentation only — shells hide the control; onPlayerRemoved enforces
canRemovePlayer?(state: S, playerId: PlayerId): boolean // may the HOST remove them? defaults to canPlayerLeave
liveScores?(state: S): Record<PlayerId, number> // running scores for the Players sheet
interlude?(state: S): boolean // nobody is on the clock right now — shells hide the whose-turn card
allAtOnce?(state: S): boolean // every player is at it at the same time (hiding fleets): no turn to time
playerView?(state: S, playerId: PlayerId): unknown // strip hidden info; omit if all public
hasClock?(state: S): boolean // is THIS match on a clock? only for a game whose clock is a choice
totalClock?: TotalClock // the game's own clock is the LENGTH of a game: a tournament keeps it
carryOver?(state: S): unknown // what to REMEMBER into this room's next match (rematch only)
actorFor?(state: S): PlayerId | null // same-device only: a FIXED seat acts (the phone stays on the table)
needsHandOff?(state: S): boolean // same-device only: must the turn's seat take the phone in private right now?
onReveal?(state: S): A | null // same-device only: the action to send the moment the phone is taken
packSpec?: GamePackSpec // opt in to host-supplied content packs
awardFacts?: ReadonlyArray<AwardFact> // opt in to party awards of your own (see "Party awards")
result(state: S): GameResult | null // null in progress; rankings when over
}
type CopyLine = string | { key: string; vars?: Record<string, string | number> } // a copy key + its blanks
interface GameResult {
rankings: Array<{ playerId: PlayerId; rank: number /* 1-based, ties share */; score?: number;
badges?: Array<CopyLine> // short labels under a name on the winner screen
facts?: Record<string, number> }> // numbers about what they DID — party awards read them
teamWin?: { label: string; accent: AccentId } // team games crown a team, not rank 1
ended?: 'not-enough-players' // the match stopped short — nobody won
}
interface AwardFact { // what one of your `facts` means for a party's awards
fact: string // the key you write into a ranking entry's facts
best: 'most' | 'fewest' // which end of the numbers earns the trophy
copy: string // copy KEY for the award's names and reason line
atLeast?: number // 'most' only: the least worth a trophy (default 1)
}
interface GamePackSpec { // a game's declaration that a host may supply its content
minItems: number // fewest valid items that still make a usable pack
targetItems: number // how many to ask a generator for — above minItems, for spares
maxItems: number // hard ceiling: a pack rides in the match settings
noun: { one: string; many: string } // "question"/"questions" — every line of the door reads from it
itemDescription: string // one plain sentence naming what ONE item is, in this game's terms
itemSchema: Record<string, unknown> // JSON-Schema fragment for ONE item; keep it SHALLOW
isValidItem(value: unknown): boolean // the trust boundary — used by generator, preview AND setup()
askDifficulty?: boolean // does an item of yours HAVE an easy and a hard? omit for no row
promptRules?: ReadonlyArray<string> // your own instructions to the generator, one rule a line
topics?: ReadonlyArray<string> // ready-made topics to hand the model instead of typing one
builtins?: ReadonlyArray<BuiltinPack> // YOUR OWN packs as chips: no model, no wait, no spend
lineItem?: PackLineItem // one item is one typed line (a word, a name), not a form
playItems?: number // how many of a pack one match plays; omit and it plays them all
ordered?: boolean // does the ORDER mean anything? false skips the arrange step. Default true
reusable?: boolean // can the same pack play again without repeating? true: rematch just deals. Default false
generate?: boolean // may a model make the items? false: builtins, write-your-own and a mix only
// (the server refuses a generation too). Default true
rulesOut?(items): ReadonlyArray<PackRuleOut> // options this pack takes off your settings sheet
}
interface BoardProps<S, A> {
state: S // already filtered through playerView, when defined
players: Array<PlayerInfo> // { id, name, colorIdx, isHost, connected }
me: PlayerId
dispatch: (action: A, note?: SendNote) => void // you pass the PAYLOAD A — the shell attaches playerId = me.
// note = { automatic: true } ONLY for a send the player did not make (below)
pending: boolean // an action is in flight — disable inputs
scratchKey: string // stable per-match key for Board-local scratch state (useScratch)
}
interface GameModule<S = unknown, A = unknown> {
meta: GameMeta
rules: GameRules<S, A>
copy?: Record<string, unknown> // this game's copy.en.json, read under games.<id>.…
Board: LazyExoticComponent<ComponentType<BoardProps<S, A>>>
HowToPlay?: ComponentType // the learn-in-under-a-minute card — or `modes`, each with `steps`; one of the two, never both
modes?: ReadonlyArray<ModeSpec<string>> // a game played in modes: the card's chips and steps
settings?: GameSettingsSpec // the start sheet's fields, rendered by the shells
defaultSettings?: (mode: 'local' | 'online' | 'lan') => unknown // per-mode settings for setup()
ownsTurnStatus?: boolean // the Board draws its own whose-turn line; shells skip TurnBanner
endedRevealDelayMs?: (finalState: unknown) => number // hold the Board after the end so its last animation plays
devScenarios?: Array<{ label: string; make: (ids) => S | ReadonlyArray<S> }> // dev-only canned states
Overlay?: LazyExoticComponent<ComponentType<GameOverlayProps<S, A>>> // optional floating
// overlay the shells mount OUTSIDE their scroll (e.g. Tambola's
// number caller); receives state + dispatch, like a Board
optimisticMoves?: boolean // online: paint a player's OWN move instantly by running THIS
// reducer locally — only for a game with no playerView, no rng on
// the move path, and no first-write-wins race
optimisticView?: (view: S, action: A, playerId: PlayerId) => S | null // the general form: say
// how a player's own action should LOOK on their own screen before the
// server answers, from what this client may see — return null to paint
// nothing. Rolled back automatically when the mutation settles, so paint
// nothing terminal. Word Search fills the found capsule at release this
// way; set this OR optimisticMoves, never both
}
interface GameServerModule<S = unknown, A = unknown> { // server registry entry: no UI
meta: GameMeta
rules: GameRules<S, A>
}
Errors are thrown with the GameError class using a GameErrorCode: 'NOT_YOUR_TURN' |
'INVALID_ACTION' | 'ALREADY_SUBMITTED' | 'ALREADY_CLAIMED' | 'GAME_OVER'. All three shells
catch it and map the code to a toast (src/ui/errorMessages.ts). GameResult
is in the contract above.
new GameError(code, note, reason, vars). The note is for whoever reads
a log and never reaches a player; the reason is your game's name for this refusal, and
the app looks up games.<id>.errors.<reason> in your own
copy.en.json before falling back to the shared line for the code. vars
fills the holes in that sentence — a count the rule itself owns, a noun your game supplies
— so a number lives in the rules and not typed into a sentence a second time. Convex
forwards both to the client, and fail(code, note, { reason, vars }) is the same door
on the server side. The hotspot host forwards them too, on its error frame, so a
browser guest reads the same sentence as everybody else — your words arrive in all three
modes or in none of them.
// games/trivia-clash/game.ts
throw new GameError(
'INVALID_ACTION',
'host mode needs more seats than the room has', // the log's words
'HOST_MODE_SEATS', // this game's name for it
{ seats: MIN_HOST_LED_SEATS }, // what its sentence has a hole for
)
// games/trivia-clash/copy.en.json
"errors": { "HOST_MODE_SEATS": "Host mode needs at least {{seats}} players." }
// games/trivia-clash/rules.test.ts — the reason is the part nothing else can see
expectGameError(() => online({ runBy: 'host' }, [HOST, 'a']), 'INVALID_ACTION', 'HOST_MODE_SEATS')
Write words only where the shared line would be wrong. Most refusals guard a
move your Board renders no affordance for, and the shared line for the code is the right answer
there — a sentence nobody can reach is copy to keep true for nothing. Reach for your own
words when the shared line would lie: a control only the host has is not "it's not your
turn yet". And a reason may name a line the app already owns rather than one of yours
— 'NOT_HOST' resolves to errors.NOT_HOST and costs you no copy at
all.
Assert the reason, not just the code. A refusal that drops its reason keeps
its code, so a code-only test still passes while the player quietly reads the shared line. Pass
the reason as expectGameError's third argument wherever your game names one, and
null where a refusal deliberately leaves the shared line to answer.
Key contract facts to internalize:
reduce is pure and total on the state. Given (state, action, ctx) it returns the
next state or throws — no side effects, no I/O, no clocks. It runs server-side online and
in-browser locally with the SAME inputs, so it must be deterministic.ctx. ctx.rng() for shuffles/dice/coin-flips, ctx.now for
timestamps. Never Math.random() / Date.now() — that would desync server and replay.Board.dispatch takes the payload only. You call dispatch({ type: 'place', cell: 4 });
the shell builds GameAction<A> by attaching playerId = me. The Board never sees a
GameAction and never knows its mode.dispatch(action, { automatic: true }). The reason: such a send can cross a change of beat
while it is in the air, the rules then rightly refuse it, and a shell says every refusal to the player
("That move won't work here"). A Board hears nothing back from dispatch, so it cannot keep that
line off the screen itself. The mark tells the shell nobody made this move, and all three shells keep the
rules' refusal of it to themselves (apps/app/src/modes/automaticSend.ts). Three things stay
true. A player's own tap never carries the mark. The mark never reaches reduce, so the rules
allow and refuse exactly as before. And only the rules' own refusal goes unsaid: being out of the room, a
lost connection or a fault is said as loudly as for a tap. Still send nothing you can know will be refused,
and leave the player a way to do the thing by hand where one makes sense (Charades keeps its button).
Do NOT mark a send whose refusal its player needs to hear: Doodle Twist hands the drawing in at time up
unmarked, because "Too late for this one" is news to the person who drew it.
Worked example: Hide and Seek's useTappedCounts.playerView runs where hidden info must be hidden. Online it's applied server-side in
engine.state so guests never receive other hands over the wire; locally the shell applies
it at hand-off. Omit it entirely when the whole state is public (TTT omits it).onPlayerRemoved is required — every game explicitly decides what a mid-match seat loss
means, even when the decision is "unsupported, throw." See the removal section below.seats —
'duel' (two players, a walk-out stops the match short, the opener swaps each round) or
'open' (two to eight playing at once, a leaver just drops out) — there is no default,
because a simultaneous game that said nothing silently inherited the duel's rules.meta.modes declares where the game can be played, and it is enforced.
sameDevice = pass-and-play on one phone; online = networked play, covering the Online
room AND LAN (every player has their own screen in both). A game whose hidden per-player
info pass-and-play can't hide (Tambola's tickets, Trivia's answers) ships
sameDevice: false — scaffold it with bun run new-game <id> --online-only. The
same-device picker filters on sameDevice; the online lobby and LAN host picker filter on
online; and both engines reject start for a non-online game, so a flag is never just
advisory.meta.secretTurns says the phone hides a secret, not just a hand. Set it
true only when what the next player is about to see must stay hidden from the rest of the
table (Charades' prompt): the pass-and-play hand-off screen then keeps its
"screen shielded" chip and its no-peeking note, and every other game gets a plain hand-over.meta.clock lets the host stop the game. Set it
true when your rules run a deadline the players are racing. The play header then
offers the host Pause, and the ENGINES freeze the match's own time while it is paused — your
rules are simply handed a clock that stands still, so every deadline you wrote waits and you
write nothing at all. Every countdown on screen reads the same clock through
~/ui/matchClock, so use the shared hooks rather than Date.now().
A countdown you write yourself lists the reader (useMatchNow()) among the things that restart
it, or reads the newest one each tick: it is another function the moment the host stops or starts the
clock, and a countdown that keeps the old one is wrong by the length of every pause.
If your clock is a choice (a turn clock the host may leave Off, or one your sheet only
asks where every player holds a screen), also declare rules.hasClock(state): the shells offer
Pause only when the game has a clock AND this match does, so nobody is offered a pause with nothing to
stop. It reads only what a player's view carries. games/pauseOffered.test.ts sets your game
up in every shell with every choice on its sheet and fails if the two disagree.carryOver is the only memory a game has between matches. setup runs
fresh on every rematch, so without it a content game re-draws questions the room has
already played — the flaw a party notices fastest. Return small, bounded, JSON-serialisable
data (ids, not objects); the engines inject it into the next setup's settings under
CARRY_OVER_SETTING. See "Rematch" below. It is memory, not a second sheet: if you
remember something the start sheet also asks, the sheet wins when the host answered it
(sheetWasAnswered(carried)), and the field says where its answer lives in your state
(live) so the play-again sheet opens on what was really played. A table game gets both from
the engine's table.packSpec declares that a host may supply this game's content — and the
declaration is the entire opt-in. The platform owns the topic box, the generation, the
validation and the host UI; the game writes none of it and adds nothing to convex/.
See "Content packs" below.game.ts, never in a Board. Alongside rules,
export pure read-only helpers over your state (Ludo's legalMoves, Connect Four's canDrop)
and make the Board ask THEM for every affordance it renders — which token is tappable, which
column takes a disc, is the game over. The reducer validates with the same selector, so the
affordance a player sees and the rule the reducer enforces can never disagree, and
rules.test.ts covers both at once. Compute on read; never store derived answers in state —
a stored copy goes stale the moment one reducer branch forgets to recompute it.Rooms hold up to 30 members (the host picks the cap at creation), but a game seats only
minPlayers..maxPlayers of them. When the
host starts a game in a bigger room they pick the seats; everyone else spectates live. Your
game never sees this. The engine resolves the seats and passes the seated ids — in turn
order — straight to setup(playerIds, ctx). So setup receives exactly the players who are
playing; a spectator's id is simply never in state and never holds the turn, which makes a
turn-based Board naturally inert for them. No seat/spectator code in game.ts or Board.tsx.
setup is honestBoth shells offer a Rematch off the winner screen: same game, same seats/roster, a fresh
seed (so setup's ctx.rng() picks a new starter/shuffle). This works automatically as long
as setup derives every initial choice from ctx.rng() — no hidden module-level state, no
Math.random(). Online it's engine.rematch (recovers the previous match's exact seats);
locally it re-runs setup over the same roster with a new seed.
carryOverA fresh setup is right for a board and wrong for content: a room that plays five rounds
of a question game gets the same questions five times, which is the single most-noticed flaw
in a party game played round after round. carryOver(state) is the whole fix —
return what this game wants to remember, and every engine hands it back at the start of the
next match in the same room:
import { CARRY_OVER_SETTING } from '@g4g/engine'
const SEEN_CAP = 300 // BOUNDED — this value rides in the game-state row on every rematch
carryOver(state) {
return { seenIds: state.seenIds.slice(-SEEN_CAP) }
},
setup(playerIds, ctx, settings) {
const carried = readSeen(settings) // narrows settings[CARRY_OVER_SETTING]
// …draw items the room hasn't had yet, and store the new seen list in state…
}
setup's settings
under the reserved CARRY_OVER_SETTING key (see "Reserved settings keys" below), so its
absence reads cleanly as "this is a fresh room, remember nothing".convex/engine.ts online, packages/match-host on LAN,
apps/app/src/modes/local/session.ts same-device. A game writes one method, not three.slice(-CAP) is not a nicety.facts in your result, and awardFactsA party plays several games in a row and ends in an awards show. Every game takes part in the
awards that come from finishing places (most wins, most last places, a comeback…) by
doing nothing at all: the party reads the rankings your result() already returns.
A game can also have awards of its own, for things only it can count: snakes landed on, drawings guessed, tokens sent home. That takes two small, optional additions to the contract and no award code:
// 1. In result(): numbers about what each player DID, beside their rank.
result(state) {
return {
rankings: order.map((playerId, i) => ({
playerId,
rank: i + 1,
facts: { bites: state.bites[playerId] ?? 0, ladders: state.ladders[playerId] ?? 0 },
})),
}
},
// 2. On rules: what each number means for an award.
awardFacts: [
{ fact: 'bites', best: 'most', copy: 'games.snakes-and-ladders.awards.bites', atLeast: 2 },
{ fact: 'ladders', best: 'most', copy: 'games.snakes-and-ladders.awards.ladders' },
],
| Field | What it says |
|---|---|
fact | The key you write into facts. Short and stable. |
best | 'most' or 'fewest': which end of the numbers earns the trophy. |
copy | A copy KEY in your own copy.en.json for the award's words: a bank of names and one reason line. The reason line is filled with value (the winning number) and every other fact on the winner's entry, so "4 drawings. All 4 guessed." can use two of your numbers. |
atLeast | For 'most': the least the winning number must be to be worth a trophy. Defaults to 1. "Bitten by 1 snake" is not a story. |
game.ts only names the key.rules.test.ts: play a short match and assert the
facts on each ranking entry, the same way you assert the ranks.The award's words live under the copy key you declared, in your own
copy.en.json. The awards show reads three things there and nothing else:
"awards": {
"bites": {
"name": "Snake magnet", // two to four words, playful, never unkind
"reason": "Bitten by {{value}} snakes", // the true reason; {{value}} is the winning number,
// {{game}} your game's name, and every other fact by its own key
"quips": { "1": "They just like me.", "2": "…", "3": "…", "4": "…", "5": "…" } // optional: what the winner says
}
}
Leave quips out and the winner says a line from the shared bank; leave the whole key
out and the award is still a real little prize ("Snakes & Ladders star"), so a half-written game
never shows a blank trophy.
rules.partyA party is several games in one evening, so each game plays short. A game says what its short version is with one optional member on its rules:
// In game.ts, on the rules object.
party: { settings: { rounds: 1 }, minutes: 2 },
| Field | What it says |
|---|---|
settings | Laid over the game's normal starting settings for the mode (defaultSettings and every field's own default), so it names only what changes. |
minutes | About how long the short version takes. The party adds these up for "about 35 min". |
partyPlan.test.ts refuses a key your settings sheet does not offer, and a value it
would not accept.meta.approxMinutes.game.ts, so Convex bundles it: a change here needs a
backend deploy before the client that relies on it, like any change to a rules file.A party offers a game only while everyone in the room fits it (at least
minPlayers, at most maxPlayers): there is no seat picker between games. So
the seat range in your meta is also what decides which party nights you are in.
Turn-based, two seats, fully public state (no playerView), four modes, played as a
best-of series on the shared table. Excerpts below are the real code in
apps/app/src/games/tic-tac-toe/; open the files for the rest. Two shapes are worth
telling apart before you copy it:
| Your game… | Start from |
|---|---|
| is one match with one winner (a race, a quiz, a board game played once) | The scaffolder's skeleton: a plain rules object with setup / reduce / onPlayerRemoved / result, and a HowToPlay.tsx card |
| is a short game friends play several rounds of, maybe in modes (Tic-Tac-Toe, Connect Four, Word Search) | The table: spread tableRules(...) into rules and answer the three questions below. Rounds, the start sheet, the mode chips, NEXT ROUND and the series result come with it |
game.ts (PURE — engine via @g4g/engine)import type { GameMeta, GameRules, ModeSpec, PlayerId, TableFields, TableGame } from '@g4g/engine'
import { GameError, duelLeaving, recordRound, seriesSettings, tableRules, advanceTurn } from '@g4g/engine'
export type TttMode = 'classic' | 'vanish' | 'slide' | 'avoid'
// Every word is a KEY — the sentences live in copy.en.json next door.
export const MODES: ReadonlyArray<ModeSpec<TttMode>> = [
{
id: 'classic',
label: 'games.tic-tac-toe.modes.classic.label',
tip: 'games.tic-tac-toe.modes.classic.tip',
steps: ['games.tic-tac-toe.modes.classic.steps.0', /* … */],
},
// …vanish, slide, avoid
]
export interface TttState extends TableFields<TttMode> { // mode, series, pick, plan, winner, turn…
cells: Array<PlayerId | null>
stamps: Array<number | null> // when each square was taken — Vanish and Slide need nothing else
clock: number
winningLine: Array<number> | null
}
export const meta: GameMeta = {
id: 'tic-tac-toe',
name: 'Tic-Tac-Toe', // a proper noun — stays as it is
tagline: 'games.tic-tac-toe.tagline', // keys, not sentences
description: 'games.tic-tac-toe.description',
accent: 'cyan', icon: 'grid', family: 'turn-based',
minPlayers: 2, maxPlayers: 2, approxMinutes: 5,
modes: { sameDevice: true, online: true },
}
/** The start sheet — how many rounds, which mode or Mix. The shells render it. */
export const settings = seriesSettings(MODES)
/** What the table needs from this game: its seats, when a grid is settled, and a fresh one. */
const TABLE: TableGame<TttState, TttMode> = {
seats: 'duel', // required — 'duel' (two, a walk-out stops it) or 'open' (2–8 at once)
settled: roundSettled, // a line, or a full board
fresh: freshGrid, // an empty grid in `mode`, seats in `order`, table fields carried
}
/** After a mark lands: a line ends the round, a full board draws it, else the turn passes. */
function resolve(state: TttState, player: PlayerId): TttState {
const line = findWinningLine(state.cells, player)
if (line !== null) {
const winner = state.mode === 'avoid' ? (other(state, player) ?? player) : player
return { ...state, winningLine: [...line], winner, series: recordRound(state.series, [winner]) }
}
if (isBoardFull(state.cells)) return { ...state, series: recordRound(state.series, []) } // a draw
return { ...state, turn: advanceTurn(state.turn) }
}
export const rules: GameRules<TttState, TttAction> = {
// setup from the sheet, table decisions before moves, the pick carried into a rematch,
// rounds won as the Players sheet's score, the ranking at the end.
...tableRules<TttState, TttMode, TttAction>(TABLE, {
modes: MODES,
parsers: PARSERS, // one checked parser per move type off the wire
over: isSettled, // the grid accepts no move
move: (state, playerId, a) =>
a.type === 'place' ? place(state, playerId, a.cell) : move(state, playerId, a.from, a.to),
}),
// Leaving a duel is a right: a guest may walk out, the match stops short, nobody is crowned.
...duelLeaving(),
// playerView omitted — the whole state is public to every seat and spectator.
}
Selectors sit beside it (currentPlayer, fadingCell, mustSlide,
placeError, moveError): the Board asks them what is tappable and the reducer
refuses with the same answers. recordRound takes a list of winners —
one seat in a duel, several when an open table's round is shared, none for a draw.
Board.tsx (mode-agnostic, default export, ~ alias)It reads state, colours marks from players, and calls
dispatch({ type: 'place', cell }) when a selector says the square is open to
me. Its width comes from useBoardMax('square') — or
useBoardMax('tall', width / height) for a board taller than it is wide, which is then
sized by the window's height on every screen. A tap target is a finger, 44px: a board of many small
squares may take back some of the page's gutter on a small phone (useBoardReach(want), as a
negative side margin on the board's own card), and where some squares are never played on, a tap on one
goes to the nearest square that takes it (Checkers' nearMiss); never rely on hit slop, which
does nothing on the web or Android. The app has two layouts, a phone
below 768px and a big screen from 768px up (tablets and desktops alike): on a big screen that
number is the largest board the play column and the window's height allow, so design the board to
scale, and if it needs a different arrangement, give it exactly two (read
useBreakpoint().isBig), never a third for tablets. Any words it shows are read
by key (useTranslation()). What a Board may import: design tokens and
~/ui building blocks (Tic-Tac-Toe uses ModeStrip, RoundDialog,
WinLine, Burst). What it must
never import: shell chrome — TurnBanner, WinnerScreen,
Toast/useToast, SeatPicker, AppShell. The banner over the board
and the winner screen after it are drawn by the shell.
HowToPlay.tsx or modesA game with one way to play ships HowToPlay.tsx: a short list of steps whose lines
are keys (the scaffolder writes one). A game with modes ships no HowToPlay.tsx:
its MODES (each { id, label, tip, steps }) go on the module as
modes, and the shared card (ui/HowToPlayBody) draws the modes as one row of
chips over the picked mode's steps — the same card in the picker, over a running game (opening on
the mode in play) and on the site's game page. One of the two, never both or neither: the
registry refuses it.
index.ts (assembles the client GameModule)import { lazy } from 'react'
import type { GameModule } from '@g4g/engine'
import { tableDefaults } from '@g4g/engine'
import { MODES, meta, rules, settings } from './game'
import copy from './copy.en.json'
import type { TttAction, TttState } from './game'
export const ticTacToe: GameModule<TttState, TttAction> = {
meta,
rules,
copy, // this game's words, read under games.tic-tac-toe.…
settings, // the start sheet: rounds and mode (or Mix)
Board: lazy(() => import('./Board')), // code-splits per game
modes: MODES, // the card — or `HowToPlay` for a game with one way to play
defaultSettings: tableDefaults, // who runs the table: the host online, anyone on one phone
optimisticMoves: true, // public state, no rng on a move, turn-gated — paint own moves instantly
endedRevealDelayMs: () => /* … */ 0, // hold the finished board until its win line has shown
}
rules.test.ts (REQUIRED, ~ alias)Table-driven Vitest over the pure reducer — all 8 win lines, a draw, every GameError path,
each mode's own rule, the series, walk-outs, and seeded first-player determinism. The deterministic
ctx and the error-code assertion come from the shared @g4g/engine/testing helpers.
import { describe, expect, it } from 'vitest'
import type { RulesCtx } from '@g4g/engine'
import { ctxFixed, expectGameError as expectCode } from '@g4g/engine/testing'
import { meta, rules, type TttState } from './game'
const ctx: RulesCtx = ctxFixed() // rng() < 0.5 → order stays [a, b]
/** A fresh a-vs-b game with 'a' first — ONE round, the plain duel. */
const fresh = (): TttState => rules.setup(['a', 'b'], ctx, { rounds: 1 })
function play(cells: ReadonlyArray<number>): TttState {
let s = fresh()
for (const cell of cells) {
const who = s.turn.order[s.turn.turnIndex]!
s = rules.reduce(s, { playerId: who, payload: { type: 'place', cell } }, ctx)
}
return s
}
// …see the file for the win-line matrix, each mode, the series and the GameError-code assertions.
Add your game to both registries — client and server — keyed by meta.id. Match the
existing TTT pattern in each file.
apps/app/src/games/registry.ts (client) — import the assembled module from your index.ts
and add it to MODULES. The array is typed ReadonlyArray<GameModule>, and each concrete
GameModule<S, A> is stored as the generic GameModule the registry traffics in — the
Board's contravariant dispatch makes that an explicit cast, not a plain assignment:
import { ticTacToe } from './tic-tac-toe'
const MODULES: ReadonlyArray<GameModule> = [ticTacToe as GameModule]apps/app/src/games/pure.ts (everything else) — import your PURE
game.ts as a namespace (never index.ts, which pulls in React) and add one entry:
import * as ticTacToe from './tic-tac-toe/game'
export const PURE_GAMES: ReadonlyArray<GameServerModule> = [
{ meta: ticTacToe.meta, rules: ticTacToe.rules },
]
Do NOT edit convex/games.ts. The server registry is now DERIVED from this
list, so a game added here lights up online for free. It used to be a second hand-written
list, and forgetting a line meant the host picked the game in front of their friends and got
"Unknown game" — with every gate green, because nothing compared the two.That's it for the product — no Convex functions, no schema, no route, no shell edits.
A third line, only for a game whose content is a secret: its server list, in
apps/app/src/games/online.server.ts, with the words of an item a phone is shown and whether they are
the item's own ("Content that is a secret", below). games/decks.test.ts fails for a game that has a server list and
no phone list, or the reverse.
Its page on the website (not optional): the game's page at
/games/<id>, the sitemap and llms.txt are built from
apps/app/src/games/catalog.ts. Add an entry (or flip a coming one's status to
'available'), import your copy.en.json into its OWN_WORDS list so the page
reads your own tagline and paragraph.
catalog.test.ts reads the real game modules and fails when a registered game has no
available entry, when the entry contradicts the game's own player counts, length or
same-device flag, or when its link-preview still is missing (below). Word Search was playable for weeks with no page on
the site because the old check compared the catalog against a hand-typed list that had forgotten
it too.
Its card (the game's picture, everywhere it is listed): a small scene of mascots playing your
game, drawn in code, in apps/app/src/ui/gamecards/scenes/. Two files: <id>.ts
holds the data (the pastel ground for each look, the loop's length, and every motion as keyframes) and
<Name>Scene.tsx is the drawing, built from the kit's pieces (Layer, Art,
Ink, Words, Mini, Bubble). Register the data in
scenes/data.ts and the drawing in scenes/index.ts. The compiler refuses one without the
other, and gamecards.test.ts checks every motion. The guide is the header of
gamecards/kit.tsx, and Tic-Tac-Toe's two files are the worked example. Design it as a still first: the
card RESTS on its most iconic frame and moves only while it is the one being looked at, in a loop of four to seven
seconds that tells one tiny story. Any word the scene shows goes in your copy.en.json under
card. Look at it at the dev-only /gamecards page, at three sizes, resting and playing,
light and dark. The card is the game's only picture inside the app, with no image behind it, so
gamecards.test.ts fails for a registered game that has no scene. (It is the one shared place a new
game adds to.)
Its link-preview still (what a shared link to its page shows): a chat app or a search engine
needs a picture file, so the card is photographed at rest, at the size of a link card, into
apps/app/public/posters/<id>.png. With the dev server running, from the repo root:
bun run card:stills <id> (no id retakes every game's; run it again whenever a scene is redrawn).
catalog.test.ts fails when a game's link picture is missing or is not the size the page promises, and
the site build checks the same thing again in the folder that ships.
A game that had a coming-soon poster drops it when it ships, with the link card cut from it
(public/posters/<id>.jpg and <id>.card.jpg): a coming game shows its poster, a
shipped game its own card. The same test fails for a picture the line-up no longer asks for. A new coming-soon
game needs both files: draw the upright poster, then bun run card:stills <id> cuts its link card
(no dev server needed for that one).
Its how-to-play video (optional): a short clip, a voice explaining while the game is played.
The game's how-to card then plays it in the card: behind "Read rules · Watch video" tabs on a phone, beside the
steps on a big screen (the rules sheet and the game page alike). It is made in the marketing repo
(~/Desktop/g4g-marketing/sources, brief in promo/HOWTO-BRIEF.md), put up with its picture by
tools/publish-how-to.sh <id> in that repo (releasing)
at https://media.gamesforgroup.com/how-to-play/v1/en/<id>.mp4 and
<id>.jpg, and listed in apps/app/src/games/howToVideos.ts.
A game with modes lists one file for each mode. The mode the game opens on keeps the
game's plain file (<id>); every other mode has a file of its own, named
<id>--<mode> (tic-tac-toe--vanish), made with the brief's "variation"
section and put up under that name. In the list each mode says 'plain', 'own', or
null while it has no video yet, and the list is checked against the game's own mode names, so a
renamed or a new mode stops the build until it is answered for. The card offers the video of the mode being
read about; a game or a mode without one simply shows its written steps. Why it is built this way:
decisions, 2026-10-14 and 2026-10-13 (and where the files live:
the entry under it).
The three modes pick your game up differently — two are automatic, one is a build step:
bun run copy:doc regenerates
docs/copy.html, where Karim reads every word the product says — a new game's copy is
invisible there until you do.--clear. Metro's file watcher does not reliably pick up a
brand-new src/games/<id>/ directory mid-session — neither Fast Refresh nor an app
relaunch will surface the game until Metro restarts.bunx convex dev pushes them as you work.)bun run export:lan-guest (rebuilds modules/expo-lan-server/guest-dist), then
pod install in apps/app/ios — that is what mirrors the export into the copy
iOS actually ships — and then rebuild the app (bunx expo run:ios /
run:android; Android re-mirrors itself as part of the build).
guestBundle.test.ts checks BOTH halves and fails loudly for either, so a stale LAN guest
list can't ship silently — expect it to go red the moment you re-export and stay red until the
pod install. App-joining LAN guests (not browsers) run their own app build
and need nothing special.Reducers call these helpers (they are not base classes). Reach for the one matching your
meta.family:
family | Util | Use it for |
|---|---|---|
turn-based | engine/turns.ts — requireTurn, advanceTurn, currentPlayer, withoutPlayer | enforce/rotate whose turn it is (TTT uses requireTurn + advanceTurn) |
simultaneous | engine/barrier.ts — submit, hasSubmitted, allSubmitted, reset | everyone submits, then reveal (throw ALREADY_SUBMITTED on a repeat); show who is in as the table's heads with tick eyes (see Simultaneous below) |
race | engine/race.ts — first-claim-wins helpers | buzzers, bingo claims — reducer checks "already claimed?" and throws ALREADY_CLAIMED for the loser (Convex serializability makes the first write win, no locks needed) |
| any with scoring | engine/scoring.ts — rankings/points | build the GameResult.rankings and running scores |
| any with randomness | engine/rng.ts via ctx.rng() — deriveRng, shuffle, rollDie, pick | shuffles, deals, dice, coin-flips — always through ctx, never Math.random() |
| any with content packs | engine/packs.ts — PackItem, pickUnique, pickOne | deterministically draw non-repeating items from a content/ pack (see Content packs below) |
| drawing games | engine/strokes.ts — Stroke, strokeIsValid, quantizeAndSimplify | validate/compact pen strokes; pair with ~/ui's DrawingCanvas (see Drawing below) |
timed | engine/timers.ts — SYSTEM_PLAYER, isTimeout, timeoutAction, nextTimerAt, readDeadline, isTimerLive | carry deadlineAt: number | null in state; the platform fires { type: '@timeout' } into reduce at the deadline (see Timers below) |
Reach for these from @g4g/engine in game.ts — the same package as
above. The client shell and tests can use '@g4g/engine' (the barrel re-exports everything).
state.turnA game whose meta.modes.sameDevice is true must embed a TurnState (engine/turns.ts)
at the conventional top-level slot state.turn — order the seated ids, turnIndex the
seat currently holding the device. The pass-and-play shell resolves the active seat as exactly
state.turn.order[state.turn.turnIndex] (apps/app/src/modes/local/session.ts, activeSeat): that seat
is who a dispatched payload is attributed to and, for hidden-info games, whose filtered view the
hand-off gate unlocks. Without it activeSeat returns null and every local dispatch throws
GAME_OVER. TTT embeds it as its whole turn model; a rotation game keeps its rotation IN
state.turn rather than beside it (Quick Draw's drawer is state.turn.order[state.turn.turnIndex],
advanced with the engine's advanceTurn / withoutPlayer), so there is never a second copy to keep in sync. Online-only games (sameDevice: false) may omit it.
The platform ships generic infra so the next games plug in with zero new Convex functions and
zero shell edits — the same rule as the rest of the platform. Everything below is opt-in:
ignore a section and your game behaves exactly as TTT does. Engine helpers (timers.ts,
packs.ts, strokes.ts) are pure and reach game.ts through @g4g/engine; UI atoms
(TimerBar, DrawingCanvas, PlayerHead) live in ~/ui and are composed by a Board.
useScratch (a platform rule)Before you reach for a new action, decide whether the state you're storing is actually shared. The platform rule (phase 8):
Server game state holds ONLY what other players or the rules must see. Private, per-player interaction state ("scratch") lives on the Board — instant, free, offline-tolerant — and reaches the server only inside the action that needs validating (a claim, a submit). The server validates scratch data at that checkpoint against its own authoritative state; trust never depends on the scratch having been stored server-side.
Why it matters: every mutation fans out to every subscriber. A write that only mutates one
player's private state is N players × M subscribers of pure churn for data playerView hides
anyway. Keeping it off the wire is the biggest headroom win for large rooms. Examples of scratch:
a Tambola daub (nobody else sees your marks; a claim re-sends the marked numbers for the server to
validate against the called set), or a Story Builder word still being typed.
What the platform gives you. Every Board receives a scratchKey: string in BoardProps —
a match-stable key the two shells derive for you (online from the room + gameStateId, local from the
seed). A rematch mints a new key, so scratch from the previous match never leaks in. Use it with
the useScratch hook (~/ui):
import { useScratch } from '~/ui'
// [value, setValue, clear] — like useState, but debounced-persisted under scratchKey.
const [draft, setDraft, clearDraft] = useScratch(scratchKey, 'draft', '')
// …edit locally (instant, no `pending`, survives an accidental refresh on the same device)…
<textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
// …fold it into the server action that validates it, then clear the scratch:
function submit() {
dispatch({ type: 'line', text: draft })
clearDraft()
}
subKey namespaces multiple scratches in one match: marks:<ticketIdx>, draft:<slot>.
Scoping a sub-key to a turn/slot number means a turn advance starts the next writer on a clean
value for free (a new sub-key = a new, empty scratch).localStorage, debounced ~300ms, SSR-guarded, restored on mount. Same-device
refresh keeps the scratch; a cross-device rejoin does not (like a paper ticket — acceptable and
worth documenting in your HowToPlay if it matters). Every storage op is best-effort — a blocked or
full store degrades to in-memory only, never throws. Entries older than 7 days are GC'd on write.seed is deliberately NOT exposed to
online clients (it would let a client predict RNG-driven outcomes); the online key uses the opaque
gameStateId instead.deadlineAt + the @timeout system actionA timed game (countdown rounds, a race clock, Tambola's auto-caller) carries an optional
top-level deadlineAt: number | null in its state. That single field is the whole contract:
deadlineAt at the top level of S. (2) Set it to a future
ms-epoch — always ctx.now + N, never Date.now() — when a countdown should run; set it to
null to stop. (3) Handle the timeout in reduce via isTimeout(action), decide what a
timeout means (reveal, force-skip, draw the next number…), and set the next deadlineAt
to arm the following tick, or null to end the chain. Repeating timers (an auto-caller) are
just a reducer that re-arms; one-shots null the field.convex/engine.ts), a setTimeout mirror
locally (apps/app/src/modes/local/session.ts). At the deadline it dispatches the @timeout action
through the same reduce → version+1 → result pipeline a player action uses. Stale timers
(a deadline that moved or cleared, or a game that ended) silently no-op — idempotence is a
comparison, not cancellation bookkeeping. You write none of this.assertSystemTimeout(action.playerId, state.deadlineAt, ctx.now): it refuses a tick that is
not the platform's, one with nothing armed, and one that comes before the deadline. A game built
on the table (tableRules) gets this for free. Every dispatcher holds a tick that wakes early
and arms it again, so a refusal never leaves a clock dead.rules.allAtOnce(state)
and give the stretch a deadlineAt of its own that your reducer answers. In a tournament no
player's clock runs through it, so the stretch must end itself; the first turn then starts on the clocks
as they stood. tournamentReady.test.ts fails a game that names such a stretch and arms no
limit, or a limit longer than the platform's backstop.The exports you touch (from packages/engine/src/timers.ts, all pure):
export const SYSTEM_PLAYER: PlayerId = '@system'
export interface TimeoutPayload { type: '@timeout'; firedAt: number }
export function isTimeout(action: GameAction<unknown>): action is GameAction<TimeoutPayload>
@timeout actions are attributed to the reserved SYSTEM_PLAYER ('@system') — never a real
seat — so isTimeout cleanly separates a system tick from a player move inside reduce:
import { isTimeout } from '@g4g/engine'
interface QuickDrawState { deadlineAt: number | null; /* … */ }
reduce(state, action, ctx) {
if (isTimeout(action)) {
// ctx.now === action.payload.firedAt. Reveal, then arm the next round (or null to stop).
return { ...revealRound(state), deadlineAt: ctx.now + 15_000 }
}
// …ordinary player actions set deadlineAt when they open a timed window…
}
TimerBar (~/ui) renders the countdown from deadlineAt — a client-clock display
only; the server (or local session) stays authoritative for when the deadline actually fires.
A Board composes it (it is a presentational atom, not shell chrome):
import { TimerBar } from '~/ui'
<TimerBar deadlineAt={state.deadlineAt} /> // optional totalMs to fix the full span
Cyan by default, turns pink and pulses under 5s (reduced-motion safe), and announces the time left to screen readers at 10-second granularity.
A clock per player is the same one field. A game where each player has their own
time (a chess clock) needs no second timer: deadlineAt is the moment the player on the move
runs out, and the state keeps what each player had left when their clock last stopped. A move stops the
mover's clock on what was left (deadlineAt - ctx.now) and sets
deadlineAt to ctx.now plus the other player's time; the @timeout
is then that player's flag falling. A move that arrives once ctx.now has reached
deadlineAt is not played: the rules answer it with the same time-up the timer would have, so a
late move never beats the platform's timer and never earns time. Because the rules are handed the match's own
time, the host's Pause stops such a clock with no code, and on one shared phone the shell stops it whenever
the match is not on screen (modes/local/away.ts). Chess is the worked example (games/chess/game.ts,
clocked), with a clock that reads either state beside the mascots
(components/SideStrip.tsx).
Is your clock a limit on one move, or the length of the whole game? The two are
different things, and a tournament treats them differently. A turn clock (thirty seconds a move)
only keeps a put-down phone from stalling a room. Every tournament match already has one of those from the
platform, so yours is dropped there: nothing to write. A total clock (five minutes each, nothing
given back) is what ends the game, and a tournament needs it more than any room does. Say so with
rules.totalClock: the lengths a tournament may play, as minutes each by the answer of your own
clock question, the one a tournament opens on, and a reader for what each player has left.
// game.ts: the sheet's answers '3' | '5' | '10' | 'off'
totalClock: {
minutes: { '3': 3, '5': 5, '10': 10 }, // 'off' is left out: a round must end
default: '3', // what a TOURNAMENT opens on; your ordinary sheet has its own default
left: (state) => state.clocks, // what each player has left, as last stopped
},
hasClock: (state) => state.clockMs > 0,
The tournament's start sheet then asks your own clock question with only those answers, every match
starts with the host's pick (or your default, whatever a phone sent), and no player's platform clock runs
while yours does, so one clock times a move and never two. The people watching a match are shown both
clocks from left and your deadline, with no code of yours.
Nobody's time runs before they have touched the board. Leave deadlineAt
null until each player has made their first move of a game, in every mode, and again at the start of each
game of a series. That is all a game does about it. In a tournament the platform times any turn your clock
is not timing: the player has one minute (FIRST_MOVE_MS, whole every time a first move is
handed to them, however their last one ended), both players and anyone watching are told how long is left, and running out gives the MATCH to the other player, as when somebody leaves. In
an ordinary room, on one phone and in a party there is no such minute, and the first move simply waits. So
the only turns you may leave untimed are first moves: any other turn with no deadline would be handed a
minute it should not have.
What you owe: answer rules.hasClock, arm deadlineAt once both have moved and
keep it armed for the whole of every turn after that, end the game when it runs out and say who ran out
(the platform's safety net sends the same @timeout if your own wake-up ever misses), and give
the party a length (rules.party). tournamentReady.test.ts holds every game that
declares a total clock to that, and holds every game that does not to the clock it has always had.
A stretch everyone must see is the same one field. When the rules show something for a
time (two cards that differ, in Pairs) they write deadlineAt for it, refuse moves while it
stands, and end it on the @timeout. Every phone then shows it for the same stretch, and what was
shown stops being sent when it ends. Two things to settle: if the stretch ends a turn, pass the turn in the
player's OWN move and not on the tick, because a tournament's clock gives time back only for that
(games/memory/game.ts); and a stretch that waits on a timer needs a way out if the timer never
comes, so the rules treat it as over once its time is (tableAt: the next card played turns the
two back first).
A tap counts only on a screen that has kept up. A board plays one move at a time and
queues the rest, while the rules are a move ahead the instant a tap is accepted. If the board asks only the
rules whether a tap counts, a quick player turns cards they have not yet seen, and every tap puts the screen
further behind. So a board with moves that take time asks two things: would the rules take it, and has this
screen shown what the player is acting on. In Pairs that is keptUp and takesTap
(games/memory/moves.ts): the screen shows the rules' own table, or is one move short of it and
that move cannot change what the player does next (a card just turned, two cards turning back). A tap any
earlier is ignored, with no toast. Keep each move short so nobody feels it, let pure decoration outlive its
move (a found pair's pop), show a timed stretch for exactly as long as the rules hold it, and add the furthest
the screen can be behind to the hold on a finished table (endedRevealDelayMs), so the last move
is seen before the winner screen. Check it by playing a whole game as fast as you can tap
(learnings, 2026-10-04).
onPlayerRemovedThe host can remove a seated player mid-match, and a seated non-host can leave
(engine.removePlayer online, the match host on LAN, and on one shared phone the Players
sheet's Remove, which the local session applies). The engines
route it to your rules' required onPlayerRemoved(state, playerId, ctx) through the same
step → version+1 → result → re-arm pipeline a player move uses. Because the method is required,
TypeScript makes every game decide what losing a seat means — there is no way to forget:
withoutPlayer), and let result() end the match if
too few players remain. Reject an unknown/already-removed id with
GameError('INVALID_ACTION') — that guard catches stale duplicate removals.
onPlayerRemoved(state, playerId, ctx) {
if (state.over) throw new GameError('GAME_OVER')
if (state.pos[playerId] === undefined) {
throw new GameError('INVALID_ACTION', 'unknown player removal')
}
return removeSeat(state, playerId)
}GameError('INVALID_ACTION') with a log note (never a player sentence — see "The words").
The engine catches it, reports it as a toast (nothing is written — the step threw before any state
change), and the host's tool there is End game instead. Say so in advance with
canPlayerLeave / canRemovePlayer returning false, so the shells grey
the control rather than offering a button that fails....duelLeaving()
from @g4g/engine: a guest may walk out at any time, the match stops short with nobody
crowned (result().ended), and the host's Remove stays greyed because taking out the
only other seat ends the game for everyone.A game built on the shared table (tableRules — rounds, modes and a
tally) must say which of these it is — there is no default: seats: 'duel' is two
players where a walk-out stops the match short, and seats: 'open' is two to eight
playing at once, where whoever leaves simply drops out of the running and the rest finish the
series (the game's own onPlayerRemoved still drops their seat from the state).
On a clean step the engine deletes the removed player's row: they become a non-member and their
client falls back to the join / "already playing" screen. A game writes nothing beyond this
one method — no backend, no shell edits. If result() becomes non-null in response (only one
player left standing), the match ends and the room flips to the winner screen exactly as any
natural finish.
Note removal is not an action: a { type: '@player-removed' } payload arriving through
reduce is just junk off the wire and must be rejected like any other unknown action — a client
can never fake a removal. (@timeout remains a reduce-dispatched system action, because timeout
mechanics are game-specific; removal semantics are universal enough to earn a contract method.)
An action's name is a stranger's string. A reducer that finds its handler on a plain
table (HANDLERS[type]) also finds toString and constructor, which
every object answers to, and then calls them: the answer becomes the stored match. Parse with the
engine's parseAction, or ask the table for its OWN entry
(Object.hasOwn(HANDLERS, type)). games/strangerActions.test.ts sends every shipped
game those names, from a seat and from someone watching, as the match begins and one move in. It finds
your game's first move by trying a pool of ordinary ones (games/probeMoves.ts); a game none of
them fits fails there, and the fix is one ordinary move of yours added to the pool. The same pool plays
every hidden-information game a few moves in for the server's watcher-secrecy check.
playerView + the local hand-off gateHidden-info games (a bluff hand, a charades prompt only the actor sees) define
playerView(state, playerId) — a pure function returning a copy with everyone else's secrets
stripped. That is all a game writes; both shells handle the reveal for you:
engine.state, so a guest never receives another
player's hidden data over the wire.PlayingScreen applies playerView and, because one device is
shared, inserts a full-screen hand-off gate (HandoffScreen, "Pass the device to <Name>")
before revealing a new current player's filtered view. The gate re-appears on every turn
change, fresh mount, and resume-from-persistence, so a hand never flashes to the wrong eyes.playerView(state, playerId) {
return {
...state,
hands: { [playerId]: state.hands[playerId] }, // only my hand
// other secrets replaced with counts/placeholders…
}
}
A game writes nothing beyond playerView — no hand-off code, no if (local). Fully public
games (TTT) omit playerView entirely and skip the gate.
A secret has more than one spelling, and the view must drop all of them. The
answer itself is the obvious one. The others are an id or index that looks it up in
content bundled with the app, a list it can be found in (a host's own pack), and a
memory whose last entry is whatever is in play now (the ids a room has already used). Keep
those on the state for the reducer and carryOver, and cut them from every view, the
last screen included. If the Board needs something that was derived from one, give it a plain
public value of its own. Test it by reading the whole view: walk a match, build every seat's
playerView at every beat, and search every value for the secret (Undercover's
rules.test.ts is the worked example, including a test that plants a leak to prove the
search can fail).
What you do not have to write: a deal nobody can work out. A player is shown some
of what your game draws (their own hand, every card turned over), and everything is drawn from the
match's seed. That seed is 256 bits from a strong generator and its stream gives nothing away about
what it has not shown, so a secret dealt with ctx.rng() cannot be recomputed from the
draws a player has seen. Your part is still the view: a secret your playerView leaves in
is sent, and nothing protects it then.
When the secret is a position, compare instead of searching. A hidden ship is a
square, and a square is only a number: there is no value to search a view for. Sea Battle's
rules.test.ts asks the other question: it moves everything a reader may not see
somewhere else (every ship with no hit on it), and asserts the reader is sent exactly the same view,
for the other player and for someone watching, at every beat of whole matches. Build the view field
by field (an allow-list) so a field added later is withheld until someone puts it there on purpose,
and let the Board draw only what the view holds: then the same Board is right for a player, a
watcher and a shared phone, and nothing about how a square is drawn can say what is under it.
When the secret is the same for everybody. In Pairs a card's picture is the whole
table's or nobody's: the view strips the picture under every face-down card for players and watchers alike,
and every reader is sent the same thing. Its rules.test.ts swaps the pictures under the
face-down cards (and replaces them with pictures no table has) and asserts every view is the same byte for
byte, at every beat of whole games. Two things follow. Nothing else in the view may be worked out from a
hidden picture: what a move did is a plain record of which cards turned, and a mascot's line reads only
that. And on one shared phone there is nothing to hand over in private, so the game names the player on turn
as its actor (actorFor, the same seat the shell would pick itself), which is what tells the
shell to skip its shielded hand-off.
A hidden-info game usually has moves that belong to exactly one player — read your own card,
cast your own vote, make the one guess. Write each of those twice: once for a room,
where that player is holding their own phone, and once for a shared phone, where the shell sends
every tap as ONE fixed seat and that seat is usually somebody else entirely. On a shared phone the
answer is nearly always "whoever is holding it does it for them", and the reducer and the
Board must read it from the SAME exported selector, or the screen offers a button the rules refuse.
A game says which world it is in with its own setting (defaultSettings('local')), never by
asking which shell it is in. Undercover shipped this bug on its first play-through
(learnings, 2026-09-18).
actorFor and the hand-off gate are mutually exclusive. Naming a
fixed actor tells the shell the device does not travel, so it never shows the gate at all
(needsHandoff in src/modes/local/match.ts). A game that needs BOTH — a
private deal first, then a phone that sits on the table — leaves actorFor undefined and
walks state.turn.turnIndex through the seats itself, leaving the pointer where it stops
once the deal is done. That resting seat is then the holder for the rest of the match. Such a game
also says which beat is the private one with rules.needsHandOff(state):
true while a seat must take the phone alone, false once the table plays round it. The shell asks before
it shows the gate, so nobody is asked for the phone in the open beat — not when the turn moves, and not
after a reload, when the shell has forgotten who took the phone last and would otherwise ask for whoever
the pointer rests on. A game that says nothing is private at every turn. Undercover is the worked example
(private only in the deal); Sea Battle is the same shape for
a duel (each player hides a fleet behind the gate, then the phone rests and whoever taps acts for the
player whose turn it is). Tell the rules the phone is shared with the same-device shell's reserved
hostRun setting rather than a key of your own: the server strips it from whatever a
client sends, so nobody can switch that on in a room.
content/<pack>.ts + deterministic selectionA content pack is the data a game plays from (prompts, questions, words, story cards). When its items
carry an answer a player must not be able to look up, it is kept as two lists and handed to
setup: read "Content that is a secret" below first. Otherwise it has
two possible sources: the game's own built-in pack, and one the host supplies for a
single room (see packSpec below). The built-in pack is always present and is the fallback
whenever a supplied one is missing or unusable. A game's own content can also be OFFERED
through the host's door: list it as packSpec.builtins and each one becomes a chip the
host taps, which delivers those items as their pack with no model call and nothing spent —
one road into the game whichever way the words were chosen (Word Search offers its own topics this
way, and says generate: false so no model ever writes its words). Convention for the built-in one:
apps/app/src/games/<id>/content/<pack>.ts.PackItem — a stable string id that never changes once shipped — plus
the game's own fields. Voice is family-friendly (a platform content decision).ids they have used (usedIds, in game state) so a match never repeats an
item — and hand that list to carryOver so the room doesn't repeat one either, round
after round.Draw items deterministically with pickUnique / pickOne (from packages/engine/src/packs.ts), always
through ctx.rng so server and local replay pick identically:
import { pickUnique, pickOne, type PackItem } from '@g4g/engine'
interface Prompt extends PackItem { text: string } // id + game fields
const PROMPTS: ReadonlyArray<Prompt> = [/* …family-friendly items with stable ids… */]
// signatures:
// pickUnique<T extends PackItem>(rng, pool, count, usedIds): Array<T>
// pickOne<T extends PackItem>(rng, pool, usedIds): T
const round = pickUnique(ctx.rng, PROMPTS, 3, state.usedIds) // 3 fresh items
const next = pickOne(ctx.rng, PROMPTS, state.usedIds) // 1 fresh item
Both throw GameError('INVALID_ACTION') when the pack is exhausted (fewer unused items than
requested) — and pickUnique also for a non-integer/negative count. Selection never mutates
the pool and returns items in shuffled order. If your game carries room memory, check the
remaining pool before drawing and cycle rather than letting this throw at kick-off.
ctx.deckSome content is the game: a quiz question with its answer, a scrambled card with the phrase behind it. A
list like that is worth nothing once it sits inside the app, because anybody can read the app. A phone is
sent the question so it can draw it, and a script looks the answer up in the list. Hiding fields in
playerView cannot fix that. The list has to be somewhere a phone cannot reach.
So a game whose items carry an answer has two lists that share no item, and its rules file
holds neither. Whoever runs the rules hands setup the list as ctx.deck:
| File, in the game's folder | Who deals from it | Where it is |
|---|---|---|
content/online.server.ts, exporting ONLINE_DECK | A room. The server hands it to setup at every start: the lobby's, a rematch, a party's game, a tournament's match. | On the server only. The app cannot import a file named *.server.ts: its bundler refuses, and the build stops. |
content/local.ts, exporting LOCAL_DECK | A phone that runs the match itself: one shared phone, and the hotspot host. | In the app. On one shared phone that is safe: everybody looks at one screen, so there is nothing to look up. On a hotspot it is not: every guest holds a phone of their own, and their copy of the app carries this list. That is open, and closes before the hotspot mode returns (the pause page). |
What you write (Trivia Clash is the simplest worked example; Gibberish, Undercover and Quick Draw play the same way):
content/types.ts, or content/rows.ts when a row is written as a line of fields and
built into an item). An id is used once across both lists and never changes. The server's list is wrapped in
sealed('<id>', [ … ]) (games/seal.server.ts): the seal is one string that goes
wherever the list goes, and it is what the site build looks for.deck: LOCAL_DECK on your client module (index.ts). The one-phone shell and the
hotspot host hand it to setup.apps/app/src/games/online.server.ts: your ONLINE_DECK, a
function that says which words of an item a phone is shown while its answer is still a secret (a question's
wording, the word a player is holding), and whether those words are the item's own
('distinctive': a question, a scrambled card) or everyday ones ('everyday': "cat",
"coffee"). The checks hold the two lists apart by those words. The site build searches for distinctive
words item by item, and leaves everyday ones to the seal, because finding "cat" in an app proves nothing.readDeck in setup, and nowhere else. ctx.deck is given to
setup only, so the match keeps what it was dealt, as whole items, in its own state.
reduce and playerView then read the state and never look anything up:
import { readDeck, pickUnique } from '@g4g/engine'
import type { Question } from './content/types' // a type: erased, so no content comes with it
setup(playerIds, ctx, settings) {
// Refuses the match when no deck was handed over. There is no list to fall back on.
const pool = hostPack(settings) ?? readDeck(ctx, isQuestion)
const questions = pickUnique(ctx.rng, pool, 10, readSeen(settings))
return { questions, /* … */ }
}
Only setup's context has a deck (SetupCtx). The one reduce and
playerView take (RulesCtx) has no place for one, so this is the compiler's rule.setup. Nothing later can reach the list, so a game that
needs content turn by turn takes all of it now and keeps it in a field no phone is sent:
| Game | What the match keeps | What a phone is sent of it |
|---|---|---|
| Trivia Clash | Its ten questions | A question once it is asked; its answer at the reveal |
| Gibberish | The cards it will play, whole | The card's scrambled line; the hint when it is out; the answer when it is shown |
| Undercover | A hand: one pair for each game. And spare pairs, never played, for Mr White's decoys | Your own word. Neither the hand nor the spares |
| Quick Draw | A bag: three words for every turn the match can have, taken off the front turn by turn | The drawer: their three while choosing, then the word. A guesser: its length and hint letters. Nobody: the bag |
Rules that follow from it:
game.ts imports no content. Not the phone's list as a fallback either. A game that
quietly dealt from a list of its own when a start forgot the deck would deal a room from the one list every
phone holds. readDeck throws GameError('INVALID_ACTION') instead, and the host reads the
shared line.carryOver) stays ids, and setup uses it to filter
whichever list it was handed.rules.test.ts: a match handed no deck refuses, every item of
every match comes from the list handed over, a host pack still wins, the memory filters the handed list. Pass
the deck in the test's ctx. The shared checks (games/decks.test.ts,
convex/decks.test.ts) pick your game up from the registrations with no edit.An item never changes sides. The first split of a list is made by one command at the repo root,
bun run split:decks: shelf by shelf (a quiz's categories, a list's own headings), by a hash of each
id, two thirds to rooms, and it only moves rows. After that a row stays where it is and the lists only grow,
each on its own side: a new item is written straight into the file it belongs in. Moving a row later is not
free in either direction. From rooms into the app publishes an answer that rooms are still being dealt, so the
backend that has stopped dealing it deploys BEFORE the site and the app builds that ship it. From the app into
rooms hands rooms a row every installed app already holds. The script says so when a run would move a row.
What the site build checks, and what it does not. scripts/build-site.mjs searches
every file of the web export for every server list's seal and for the words of every distinctive item, and it
requires the same search to FIND every one-phone list there, so a search of the wrong place cannot pass. A
phone build is not searched: there the bundler's refusal to import a *.server.ts file is what
stands.
What this does and does not promise. A phone never holds an answer before its moment: not the value, and not a key that finds it in the app. That is true of a list only once its room rows have never been in a build of the app. A list whose room rows were moved out of the app is still known to every older copy (decisions, 2026-10-08, says which lists those are). And it does not make a finite list unknowable: somebody who plays through a whole list has seen it, as a person with a good memory has. A longer server list is the answer to that, and it needs a backend deploy, never an app release.
Content with no answer in it (story openers everybody reads, the outline everybody draws on) is not a secret and follows the plain convention above.
packSpec (the whole opt-in)A game may let the host bring the content for their room: generated from a topic they
type, or written out by hand. Declaring packSpec on your rules is the entire opt-in
— the platform then owns the topic box, the generation, the validation, the storage and the
host UI. No new Convex function, no shell edit, no generation code in your game, and the
second game to declare it adds nothing at all to convex/:
// game.ts, on `rules` — five plain facts about ONE item
packSpec: {
minItems: QUESTIONS_PER_MATCH, // below this, a supplied pack is ignored entirely
targetItems: 15, // ask for spares so the host can swap after reading
itemDescription:
'a multiple-choice trivia question with exactly four answer options, ' +
'one of which is correct', // reads like a brief — it goes into the instructions
itemSchema: { // SHALLOW JSON Schema for one item; providers reject deep nesting
type: 'object',
properties: {
prompt: { type: 'string' },
options: { type: 'array', items: { type: 'string' } },
answerIdx: { type: 'integer' },
},
required: ['prompt', 'options', 'answerIdx'],
},
noun: { one: 'question', many: 'questions' }, // the door speaks your game's language
askDifficulty: true, // a quiz has easy and hard ones; a word list does not
isValidItem: isTriviaQuestion, // the SAME predicate setup() uses — see below
},
If ONE of your items is a single typed line rather than a little form — a word, a name — add
lineItem (a placeholder, a hint, a pure read that turns a typed line into an item or
says in plain words what is wrong with it, and a show for the other direction). The
write-your-own step then becomes a list of boxes and the preview one line a row. If the ORDER of
your items means nothing to the game — a grid scrambles its words and hides a handful a
round — say ordered: false and the door drops the preview step entirely: the host
goes from the made items straight to your options, because there is nothing there to arrange and
nothing worth reading. And if your own options argue with a host's content — a board too small to hide their longest word — answer
rulesOut and the sheet greys those choices, prints your reason, and moves the host to one
that fits.
isValidItem is the trust boundary, and it must be ONE function. The generator, the
host's preview and your own setup all judge a pack with it, so a pack can never be valid in
one place and invalid in another. Export the predicate and reference it from both
packSpec and setup — never write the check twice.settings.pack. Online, engine.start reads the room's
pack server-side and merges it in; a client never hands one over (it holds the answers). Once
merged it rides in the persisted settings, so a rematch replays the same set with a fresh seed
for free.setup keeps only the items that pass
isValidItem, and if fewer than minItems survive it plays the game's built-in content
instead. A malformed pack must degrade the party, not end it:
function pool(settings: unknown): ReadonlyArray<TriviaQuestion> {
const pack = (settings as { pack?: unknown }).pack
if (!Array.isArray(pack)) return QUESTIONS // no pack — built-in content
const valid = pack.filter(isTriviaQuestion)
return valid.length >= QUESTIONS_PER_MATCH ? valid : QUESTIONS
}packSpec obeys the purity rule — it lives in game.ts and is read by
Convex, so no React, no browser APIs, no clocks, no dice.modes on a fieldA host setting that only means something where there is a clock, or a screen in every hand,
carries modes: ['online', 'lan'] on its field, and the same-device set-up screen simply
never asks it. Trivia Clash is the worked example: who runs it, the answer clock, the gap and
the wrong-answer cost are all online/LAN questions; on one shared phone the host reads out and
there is nothing to configure. The shells read the spec through
settingsFor(spec, mode), which also answers undefined when nothing is left to
ask, so the sheet has no empty "Game options" block. A select option may also carry
minPlayers and a hint: below that seated count the chip is shown but
disabled with the hint under it, and the field sends its default (Trivia's Host mode needs
three).
defaultSettingsA module may export defaultSettings?: (mode: 'local' | 'online' | 'lan') => unknown; the
platform threads its return value into setup(playerIds, ctx, settings) — from
session.ts ('local') same-device, from the lobby into engine.start's
settings online, and from the LAN host screen ('lan'). All three modes are named, so
answer for each on its own terms rather than treating LAN as a flavour of online. Omit it when
the game plays identically everywhere.
This is also how a game plays a genuinely different shape in one place without
a second copy of itself: Word Search is a simultaneous hunt over a series of rounds online, and a
one-round hot seat on a shared phone, and defaultSettings('local') returning
{ flow: 'hotseat' } is the whole switch. setup reads the key once and
builds the state that flow needs; every rules member that differs is then chosen from the STATE,
never re-derived from the settings. Pair it with modes on the settings fields above,
so each place is asked only its own questions.
A shared-phone flow whose turn is timed starts its clock at the hand-off, not
on a tap of its own: the same-device shell already shows a full-screen pass-the-phone gate for any
game with a playerView, and when that gate closes it asks
rules.onReveal?(state) for an action and dispatches it as the seat taking the phone.
Return null when there is nothing to start — the gate re-appears on a fresh mount and
on a resumed match, so guard on the state you are protecting (Word Search answers only while its
board is still ready). A Board can never do this itself: it is mode-agnostic and never
sees the shell's hand-off screen.
// index.ts, on the GameModule (Quick Draw: one shared screen ⇒ shared easel;
// online and LAN both put a screen in every hand ⇒ typed guesses)
export const quickDraw: GameModule<QuickDrawState, QuickDrawAction> = {
meta, rules, Board: lazy(() => import('./Board')), HowToPlay,
defaultSettings: (mode) => ({ easel: mode === 'local' }),
}
// game.ts reads it (settings is `unknown` — narrow it):
setup(playerIds, ctx, settings) {
const easel = (settings as { easel?: boolean } | undefined)?.easel ?? false
// …
}
setupsettings is not only the host's picks. It is also the one channel the platform uses
to tell setup things that aren't game rules — which is why new capabilities keep arriving
without setup's signature ever changing. Five keys are reserved; all five are injected by
the engines, never by a client, and a game that ignores one is unaffected:
| Key | Present when | Carries |
|---|---|---|
HOST_PLAYER_SETTING ('__hostPlayerId') | Always | Who the host is — online their playerId, locally the first seat. Lets a reducer gate host-only actions (pause, speed, call the next number) with no contract change. Written by the server from the room's own host on every start and every rematch, whatever a phone sent, because the host is room-level truth. |
CARRY_OVER_SETTING ('__carryOver') | Rematch only | Whatever your own carryOver returned last match. Absent on a first start, so its absence reads as "fresh room". |
OPENER_SETTING ('__opener') | The match is part of a tournament | Which of the two seats opens. A tournament decides this itself. A duel on the shared table honours it instead of flipping its own coin, so a table game reads nothing. |
FIXED_TABLE_SETTING ('__fixedTable') | The match is part of a tournament | Nobody runs this table: it is one game in a mode fixed for the whole tournament. The shared table refuses every table action then, so a table game reads nothing. |
pack | The room has a host-supplied pack for THIS game | The pack items, merged in server-side at start. Validate and fall back — never assume it is well-formed. |
Import the constants from @g4g/engine rather than typing the strings, and treat
every one of these as untrusted shape at the boundary: settings is unknown, so narrow it
the same way you would any payload. A key only the server may set goes on the engine's one list beside
the keys (SERVER_ONLY_SETTINGS): the server deletes that list from anything a phone sends, at
every door that takes settings, and a test fails for a settings key that nobody has placed.
The list a secret-content game deals from is not one of these. It arrives as ctx.deck,
beside ctx.rng, so there is no key for a phone to send and nothing saved with the match.
strokes.ts model + DrawingCanvasDrawing games share an isomorphic stroke model (packages/engine/src/strokes.ts) because two runtimes
touch the same math: the browser compacts a stroke before dispatch, and the reducer validates
every incoming stroke before appending it.
export interface Stroke {
colorIdx: number // index into the 8-color player palette
size: 's' | 'm' | 'l'
points: Array<number> // flat x0,y0,x1,y1… ints in the 0..1000 logical square
}
DrawingCanvas (~/ui) is a mode-agnostic square canvas over that 0..1000 space,
devicePixelRatio-aware. It replays committed strokes, renders the in-progress stroke, and
emits a finished stroke on pointer-up already quantizeAndSimplify-ed (int-quantized,
min-distance simplified, hard-capped at STROKE_MAX_POINTS = 240). It owns render + capture
only — palette, size, and clear controls belong to the game composing it:
import { DrawingCanvas } from '~/ui'
<DrawingCanvas
strokes={state.strokes}
drawable={isDrawer && !pending}
colorIdx={myColorIdx}
size="m"
onStroke={(stroke) => dispatch({ type: 'stroke', stroke })}
label="Drawing area"
/>
Strokes travel as ordinary game actions ({ type: 'stroke', stroke }) — no new backend
surface; int-quantized points keep a full board well under Convex's document-size limit. The
reducer structurally validates each one with strokeIsValid:
import { strokeIsValid, type Stroke } from '@g4g/engine'
// strokeIsValid(s: unknown, opts?: { maxPoints?: number }): s is Stroke
const PALETTE_SIZE = 8 // game-local: game.ts can't import the ~/theme palette (purity rule)
const MAX_STROKES = 400
reduce(state, action, _ctx) {
const { stroke } = action.payload as { stroke: unknown }
if (!strokeIsValid(stroke)) throw new GameError('INVALID_ACTION')
if (stroke.colorIdx >= PALETTE_SIZE) throw new GameError('INVALID_ACTION')
if (state.strokes.length >= MAX_STROKES) throw new GameError('INVALID_ACTION')
return { ...state, strokes: [...state.strokes, stroke] }
}
strokeIsValid checks shape only — colorIdx a non-negative int, size one of s|m|l,
points a non-empty even-length list of in-bounds ints within the point cap. The two
game-specific bounds — the palette-size cap on colorIdx and the strokes-per-round cap
— are deliberately the game's job (the engine stays decoupled from the UI palette), so enforce
them in the reducer as above.
live + useLiveChannelFor things other screens should see as they happen but nobody needs kept — a line
while it is still being drawn, a pointer — a Board may use the room's live channel. It arrives as an
optional live prop on BoardProps: the online shell provides it (a
relay socket for the room), same-device leaves it out (one screen), and paused LAN leaves it out
(no internet).
import { createBatcher, useLiveChannel } from '~/live'
// Open the socket ONLY while the board needs it — the relay bills every awake second.
const pen = useLiveChannel<PenBatch>(live, 'pen', state.phase === 'drawing')
pen.publish(batch) // to everyone else in the room; dropped when disconnected
useEffect(() => pen.subscribe((msg) => {
if (msg.from !== currentDrawer(state)) return // msg.from is stamped by the relay, trust it
const batch = readPenBatch(msg.data) // msg.data is NOT: check its shape
…
}), [pen.subscribe])
state alone. Quick Draw without it shows finished strokes only.dispatch. The channel stores nothing, and a late joiner hears nothing that came before.createBatcher (about ten messages a second, not
one per frame) and smooth them on the receiving side. Limits: 8 KB a message, 40 a second a socket.ui/livePen.ts is the
worked example).useReactions + ReactionBarTo let players cheer with emojis that float up everyone's screen, use the shared kit rather
than the channel directly. Pick the moment worth reacting to as active, put the overlay over
the area people cheer at, and place the bar where a thumb reaches it:
import { FloatingReactions, ReactionBar, useReactions } from '~/ui'
const reactions = useReactions(live, state.phase === 'drawing' || state.phase === 'reveal')
<View className="relative">
{/* the canvas */}
<FloatingReactions reactions={reactions.reactions} bottom={64} />
<View className="absolute bottom-3 right-3"><ReactionBar reactions={reactions} /></View>
</View>
ReactionRow (always showing) instead of the corner
button.live (same device) the bar renders nothing and
nothing floats.useReactionMenu + ReactionMenuRow: the same fold-away and
pause, only the trigger differs. Quick Draw's phone opens it from the drawer's mascot
(games/quick-draw/ui/CornerDrawer.tsx). That is for a screen the row would cramp; a screen
with room keeps the row out (Charades' acting screen, Undercover's unmasking).A Board plays a sound through useFeedback() from ~/ui (play(name),
with the phone's buzz beside it), and the name says what happened, never how it should
sound. How a name sounds belongs to the app's one palette of game sounds
(how sound works), so a game ships no sound files.
| Name | The moment it is for |
|---|---|
place | A piece, a disc or a mark goes down on the board: an ordinary move |
take | A piece is captured or taken off the board |
burst | A chain is set off: a cell bursts and what was in it flies out |
tap | A pick that is not yet a move: a piece lifted, a card chosen |
correct · wrong | A right answer or a good find; a refused or wrong one |
claim | Something won inside the game: a box, a crown, a prize |
daub · tick | A number marked off; one tick of a clock that is running out |
slide · climb | A ride down a snake (about 1.15 s) and up a ladder (about 1 s): each is as long as the ride it goes with |
flip · land | Something turning over on its way to an answer (about 1.4 s), and something coming down: a card being revealed, a shot that lands in the water |
swoosh | Something drawn or swept across the screen: a winning line, a shot |
out | A player is knocked out |
rattle | The dice, which the dice kit plays itself |
take, not place and then take. A board whose pieces move says it
through useMoveNoise(moveKey, sound) (from ~/ui): it plays once per move, keyed on
the presenter's move key, with the buzz that goes with it. Chess and Checkers are the worked examples: each
keeps a pure soundOf(move) beside its lines, and a test that holds what it asks for.place, not slide: slide is the ride down a snake, and is free to
become a second-long fall. Sea Battle's ship set down is place; Chain Reaction's chain going off is burst. If no
name fits your moment, that is a new name (~/ui/audio/names.ts, and the palette then gives it a
sound), which is a change to shared code: raise it.greet, for somebody arriving, and a Board has no use for it.playerView no mascot makes a sound, so a voice can never tell the
table who knows the answer. To let a moment be heard, wrap it in PublicMoment (from
~/ui): the claim is that EVERY screen is shown it at the same instant. Mark moments, never the
whole board, and give the mark a voiced.test.ts that plays real matches and puts each seat's
own view through the decisions the moment is drawn from: wherever two seats would differ, it stays silent
(on={…} takes the judgement when it moves with the game). Pairs, Sea Battle and Trivia Clash
are the worked examples.Simultaneous-family games (everyone submits, then reveal) use barrier.ts in the reducer and
show who has submitted as the table's mascot heads (PlayerHead from
~/ui/PlayerHead, initials past twelve via headLetters): a head rests until its
player is in, then shows face="tick". Nothing reflows as people finish, and nothing on a head
says WHAT they submitted. Feed it the barrier's keys (Object.keys(state.submissions), which
playerView keeps public) plus this phone's own tap the instant it lands. Worked examples:
Trivia Clash's crowd under the question (games/trivia-clash/components/Crowd.tsx, whose
stragglers go wide-eyed in the last five seconds) and Undercover's table, where a seated mascot's eyes
become a tick once its vote card is in the middle (games/undercover/components/scene.ts).
Any board that ANIMATES what just happened — a token walking, an explosion crossing the
grid — draws itself from usePresentation(state, SPEC, leadMs) (from
~/ui; the PresenterSpec type is in ~/ui/presenter). Your
SPEC says which state change is a move and how long showing it takes; the presenter
holds one snapshot plus that one move, queues whatever arrives meanwhile, and adopts the move whole
when it lands. Everything on the screen comes from that moment, not just the
pieces: the players' counts AND their in-or-out, the turn line, the buttons.
Because the shells' whose-turn card reads the settled rules, a game like this draws its own turn
line and sets ownsTurnStatus. Chain Reaction
(moves.ts, components/useMoment.ts) is the worked example without dice:
its players' feelings on the rim (sayings.ts) are built from the same beats the
board replays, so nothing on the rim is ever ahead of the board.
A two-player board that plays its moves out keeps the look of the other duels: it sets
ownsTurnStatus and mounts the same stage the shells draw for them, MascotStage
(~/ui/MascotStage), under its board, fed from the presented moment. The stage's
turn.say lets the mover say one line of the game's own for a beat (what just happened) before the
other player stands up, and cheer stands a seat up to celebrate. Checkers is the worked example
(moves.ts, sayings.ts, components/useMoment.ts): a hop is one move, a
line earns a beat on the settled board, and a piece's resting square is a shared value set in the same
layout effect that starts its travel, so the piece is never a frame ahead of its animation.
A move that changes many pieces at once is still one move. Reversi is the worked example
(games/reversi/moves.ts, components/DiscAt.tsx, components/useMoment.ts):
one disc goes down and every disc it trapped turns over, in rings outward from it. Each disc already rests as
what it is becoming from the moment the move starts; the turning version is a separate view mounted for that
one move (keyed on the presenter's move key, born showing the old colour) and the plain resting disc takes its
place when its turn is over. The two counts on the rim are read off the same moment and tick as each ring
shows its new colour, so the score is never ahead of the board.
A piece that slides from square to square does not write that mechanism again: it is
~/ui/board/pieceMotion (usePieceTravel, useDeclaredStart,
usePieceGone, usePieceLift, usePieceShake), which keeps where a piece
rests in ordinary layout, carries only its distance from there, and keys everything on the presenter's
move key. The piece's box is one square, and the game brings only the drawing and what is its own: a
checker's leap, crown and "pick me" bounce (checkers/components/Piece.tsx), a pawn's promotion
and a mated king tipping over. Two things that shake a piece each keep their own count
(usePieceShake twice), never one number added up. Chess is the worked example
(components/ChessPiece.tsx), and also for a line that does NOT hold the
board: with a clock running the other player must never wait for a sentence, so its presenter counts only
the slide, and the mover's line goes up when the move has landed and gives way to the next move.
Two smaller pieces a grid board does not write again. Where in a square a finger came
down is ~/ui/board/useTapPoint: it reads the point as the finger goes down (the press that
follows carries no position on the web), so a tap that just misses can be handed to the square it was meant
for. Which square that is stays the game's own call (Chess aimedSquare, Checkers
nearMiss). And the players on the stage under a two-player board come from
useStageSeats (~/ui/MascotStage): the seated players in the room's own order, so
nobody changes places when who goes first swaps.
Roll-then-move games do not build their own die. Place RollStrip from
~/ui (its pieces live in ~/ui/dice) under the board: it draws the 3D
die, the turn pill and your roll line, and already knows the first-roll hint, shake-to-roll,
and who sees the die online and on one phone. It is the section above with a die in it: draw the
board from usePresentation(state, SPEC, TOKEN_LEAD_MS), where the LEAD is the die's —
pieces set off only after it lands and has been read, and a second move never cuts the first short.
~/ui/board/MascotRim puts the players' mascots over the board's top edge and
useFollowCamera zooms in on a big moment.
moveSeq, bumped by every roll) and
let the spec recognise a move by that number. Two rolls can say exactly the same thing, so a spec
that compares what the roll says calls the second one already played and the token stands still.useSharedDevice(): on one phone me is whoever holds it.moves.ts) and Ludo (moves.ts,
useBoardMotion.ts) are the worked examples.tableRules): the tournament starts an ordinary match with the
opener it drew, as one game, on a table nobody runs. It reads back who
won. The platform also gives each player a clock, read from state.turn, and that is the only
clock of a turn in a tournament match: a game's own clock is asked with the settings key clock (the
engine's OWN_CLOCK_SETTING), the tournament's start sheet leaves that question out, and the
server never passes that answer to a tournament match. The one exception is a game whose own clock is the
length of the game (rules.totalClock, Chess): it keeps that clock, the sheet asks it without
"no clock", and the platform's stands down while it counts, so there is still one clock and never two. The
platform times only what that game leaves untimed: each player's first move, for one minute. A match that
ends level is settled by a coin there and then, so a game needs no rule of its own for a draw. A match is
one game, so it never waits between two: a game that hands its settings to its table gets that with
nothing of its own. A player who walks out ends the match for whoever stayed: a game gets that from
walkOut.
Nobody switches the mode of a tournament match, and nobody starts or ends a round on it.
src/games/tournamentReady.test.ts checks all of that for every two-seat game in the shipped
list, with the settings the server really builds, so a new one that keeps its turn somewhere else, flips its
own coin, names its turn clock anything but clock, or builds its series without the settings
it was started with fails there. The same file plays one game of every such game to its end and has either
player leave, so a new game with no way to be played to the end of a game in a test (its own clock, or its
moves written out there) fails too.rules.test.ts — table-driven Vitest over the reducer: valid
transitions, every GameError path (assert the .code, not just that it threw),
onPlayerRemoved, the exported selectors, and result() producing correct rankings. Run
with bun run test (Vitest, node env — no DOM; rules are pure).@g4g/engine/testing — the shared, framework-free
helpers every game's tests use instead of hand-rolling their own:
ctxFixed(value?, now?) — rng always returns value (default 0, which keeps the
seating order through setup's coin flip).ctxRolling(dice, { now?, sides? }) — rng yields exactly the fractions that make
rollDie produce the given faces in order (dice games assert exact rolls this way).expectGameError(fn, code) — asserts a GameError with exactly that code.reduce until result() is non-null (see TTT's play()
helper).carryOver or packSpec, test them like rules — they are rules.
Feed carryOver's output straight back into setup under CARRY_OVER_SETTING and assert
the second match draws nothing the first one used, that the memory stays capped, and what
happens once the pool runs dry. For a pack, assert that a junk pack falls back to built-in
content instead of throwing, and that packSpec.isValidItem is the same predicate
setup filters with (call both in one test — two copies that agree today are the failure
mode this catches).Your game’s sentences go in copy.en.json in your folder, and your
game.ts carries only their names. This is not a style preference:
game.ts is bundled by Convex too, and a server has no idea what language the person
at the other end is reading, so it cannot be the thing holding a sentence.
Your file is yours alone — nobody else edits it — and it is loaded under
games.<id>, so a name is your id, then the path inside your file.
// copy.en.json
{
"tagline": "Three in a row takes it.",
"modes": {
"classic": {
"label": "Classic",
"tip": "Three in a row takes it.",
"steps": ["Take turns tapping an empty square…", "…", "…"]
}
},
"errors": { "NOT_A_WORD": "Not one of the words." }
}
// game.ts — names, never sentences
export const MODES = [
{
id: 'classic',
label: 'games.tic-tac-toe.modes.classic.label',
tip: 'games.tic-tac-toe.modes.classic.tip',
steps: [
'games.tic-tac-toe.modes.classic.steps.0',
'games.tic-tac-toe.modes.classic.steps.1',
'games.tic-tac-toe.modes.classic.steps.2',
],
},
]
export const meta: GameMeta = {
id: 'tic-tac-toe',
name: 'Tic-Tac-Toe', // a proper noun — stays as it is
tagline: 'games.tic-tac-toe.tagline', // a sentence — a name
description: 'games.tic-tac-toe.description',
// …
}
| What | How your game names it |
|---|---|
| Its one-liner and its paragraph | meta.tagline / meta.description are names. meta.name stays as it is — it is a proper noun. (The game’s id is what names its picture and its page address.) |
| Its modes | ModeSpec.label, .tip and every entry of .steps are names. |
| Its start-sheet options | Every label, note and hint in your settings is a name. So is a pack’s noun, its topics, and the reason a board size is ruled out. |
| A refused move | throw new GameError('INVALID_ACTION', 'a note for the log', 'NOT_A_WORD'). The third argument is your reason; the app looks up errors.NOT_A_WORD in your file, and falls back to the shared line for the code when you have none. The note is for whoever reads a log and never reaches a player. |
| The line it toasts the room, a badge on the winner screen | A CopyLine: { key, vars }. The shell looks up the key (and any value that is itself a key) and fills the blanks. |
The website’s copy for your game — the one-liner and paragraph on
/games/<id> — is your own tagline and description, read from your
copy.en.json through the catalog (see "The two registration lines"). Only games still on the
roadmap keep their words in apps/app/src/i18n/en/catalog.json, because they have no folder
yet.
If your game only works in some languages, say so. A word hunt deals a grid of A–Z letters, so it is an English game however well the buttons around it are translated:
export const meta: GameMeta = {
// …
languages: ['en'], // omit and your game is offered everywhere
}
The pickers ask playableGames(), so a game nobody at this table could win is
simply not offered.
Adding a language to your game is adding copy.<lang>.json
beside your English one. bun run i18n:check tells you if a line is missing, empty, or
fills a different blank; bun run copy:doc rebuilds
docs/copy.html, where Karim reads every word the product says.
rooms.ts / engine.ts / packs.ts are fully generic. If
you think you need a server function for a game, you've put logic in the wrong place — it
belongs in reduce. Host-supplied content is the worked example of the rule, not an
exception to it: opting in is a packSpec declaration on your own rules, and the backend
it lights up is code that was already there.if (online), no
store, no Convex, and no imports of TurnBanner / WinnerScreen / Toast / SeatPicker /
AppShell in Board.tsx. Pass-and-play chrome belongs to the local shell; presence /
connection chrome to the online shell.game.ts. Content whose items carry an answer is
handed to setup as ctx.deck and is never imported by the rules, and a
*.server.ts file is never imported by anything in the app.~/* imports in game.ts. It's Convex-bundled — the only non-local import it may use is @g4g/engine.copy.<lang>.json. Not in game.ts, not typed into Board.tsx. A Board reads copy with useTranslation(); bun run lint:copy is what stops you forgetting.packages/engine/, src/ui/,
src/theme/, src/modes/, other apps/app/src/games/*). Your folder, the two registry lines, your catalog entry, and your card's scene with its still.
Never copy another game's private component either — if a piece in
src/games/<other>/components/ is what your game needs, PROMOTE it: move the file
into src/ui/, export it from ui/index.ts, give it a /catalog
section, and repoint the game it came from. Two copies of a clock drift, and no gate can see it.
The ring clock (ui/clock: RingClock, useSweep,
useSecondsLeft, useClockTick) and useKeyboardTop reached
src/ui exactly this way.Date.now() / Math.random() and no React/browser/Convex imports in game.ts or
anything under packages/engine/.packages/engine/src/types.ts, and don't touch
package.json.