← All docs Games for Group · Adding a game

Adding a game

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.

Folder layout

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).

Import convention — this bites people

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:

The contract, field by field

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.

A REFUSAL IN YOUR OWN WORDS

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:

Seats & spectators — a game does nothing special

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.

Rematch — free, if setup is honest

Both 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.

Room memory across rematches — carryOver

A 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…
}

Party awards — optional facts in your result, and awardFacts

A 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' },
],
FieldWhat it says
factThe key you write into facts. Short and stable.
best'most' or 'fewest': which end of the numbers earns the trophy.
copyA 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.
atLeastFor 'most': the least the winning number must be to be worth a trophy. Defaults to 1. "Bitten by 1 snake" is not a story.

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.

The party's short version — rules.party

A 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 },
FieldWhat it says
settingsLaid over the game's normal starting settings for the mode (defaultSettings and every field's own default), so it names only what changes.
minutesAbout how long the short version takes. The party adds these up for "about 35 min".

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.

Worked example — Tic-Tac-Toe (the shipped reference)

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.

The card — HowToPlay.tsx or modes

A 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.

The two registration lines

Add your game to both registries — client and server — keyed by meta.id. Match the existing TTT pattern in each file.

  1. 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]
  2. 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).

Run it in each mode

The three modes pick your game up differently — two are automatic, one is a build step:

  1. Rebuild the words page. 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.
  2. Restart Metro with --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.
  3. Same-device and online — automatic. Both render from the live registry; the two registration lines are all they need. (Online also needs the dev backend to know the server rules: bunx convex dev pushes them as you work.)
  4. LAN guests — rebuild the guest bundle. The browser guest a host phone serves renders from the frozen static export embedded in the native app, not from Metro. Run 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.

Which engine util per mechanic family

Reducers call these helpers (they are not base classes). Reach for the one matching your meta.family:

familyUtilUse it for
turn-basedengine/turns.ts — requireTurn, advanceTurn, currentPlayer, withoutPlayerenforce/rotate whose turn it is (TTT uses requireTurn + advanceTurn)
simultaneousengine/barrier.ts — submit, hasSubmitted, allSubmitted, reseteveryone submits, then reveal (throw ALREADY_SUBMITTED on a repeat); show who is in as the table's heads with tick eyes (see Simultaneous below)
raceengine/race.ts — first-claim-wins helpersbuzzers, 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 scoringengine/scoring.ts — rankings/pointsbuild the GameResult.rankings and running scores
any with randomnessengine/rng.ts via ctx.rng() — deriveRng, shuffle, rollDie, pickshuffles, deals, dice, coin-flips — always through ctx, never Math.random()
any with content packsengine/packs.ts — PackItem, pickUnique, pickOnedeterministically draw non-repeating items from a content/ pack (see Content packs below)
drawing gamesengine/strokes.ts — Stroke, strokeIsValid, quantizeAndSimplifyvalidate/compact pen strokes; pair with ~/ui's DrawingCanvas (see Drawing below)
timedengine/timers.ts — SYSTEM_PLAYER, isTimeout, timeoutAction, nextTimerAt, readDeadline, isTimerLivecarry 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).

Same-device turn resolution — state.turn

A 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.

Shared infrastructure (timers, hidden info, packs, memory, settings, drawing)

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.

Client-local scratch state — 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()
}

Timers — deadlineAt + the @timeout system action

A 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:

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).

Mid-game player removal — the REQUIRED onPlayerRemoved

The 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:

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.

Hidden info — playerView + the local hand-off gate

Hidden-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:

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 rule written as "only this seat" needs its one-phone answer

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 packs — content/<pack>.ts + deterministic selection

A 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:

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.

Content that is a secret — two lists and ctx.deck

Some 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 folderWho deals from itWhere it is
content/online.server.ts, exporting ONLINE_DECKA 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_DECKA 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):

Rules that follow from it:

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.

Host-supplied packs — 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.

Per-mode settings fields — modes on a field

A 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).

Per-mode default settings — defaultSettings

A 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
  // …
}

Reserved settings keys — how the platform talks to setup

settings 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:

KeyPresent whenCarries
HOST_PLAYER_SETTING ('__hostPlayerId')AlwaysWho 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 onlyWhatever 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 tournamentWhich 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 tournamentNobody 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.
packThe room has a host-supplied pack for THIS gameThe 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.

Drawing — strokes.ts model + DrawingCanvas

Drawing 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 channel — live + useLiveChannel

For 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])

Reactions — useReactions + ReactionBar

To 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>

Sound — say what happened, by name

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.

NameThe moment it is for
placeA piece, a disc or a mark goes down on the board: an ordinary move
takeA piece is captured or taken off the board
burstA chain is set off: a cell bursts and what was in it flies out
tapA pick that is not yet a move: a piece lifted, a card chosen
correct · wrongA right answer or a good find; a refused or wrong one
claimSomething won inside the game: a box, a crown, a prize
daub · tickA number marked off; one tick of a clock that is running out
slide · climbA 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 · landSomething 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
swooshSomething drawn or swept across the screen: a winning line, a shot
outA player is knocked out
rattleThe dice, which the dice kit plays itself

Simultaneous games — who is in, on their faces

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).

A board that plays its moves out

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.

A dice game

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.

Testing conventions

🏆 A two-player game is a tournament game A game that seats exactly two can be played as a tournament online with no code of its own, as long as it is built on the engine's table kit (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.

The words your game says

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',
  // …
}
WhatHow your game names it
Its one-liner and its paragraphmeta.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 modesModeSpec.label, .tip and every entry of .steps are names.
Its start-sheet optionsEvery 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 movethrow 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 screenA 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.

What NOT to do

Games for Group · Adding a game — the self-contained folder + two registration lines.