tabletop
with friends
THE BUILDER’S HANDBOOK
ReferenceSDK 0.1.0

SDK authoring guide

The complete API guide, examples, and reusable patterns for this SDK release.

Read Markdown ↗

Game projects need one dependency: @tabletopwithfriends/sdk.

Inside the BoardEngine monorepo, scaffold the smallest complete game with:

pnpm --silent game create examples/my-game
pnpm install
cd examples/my-game
pnpm dev
pnpm inspect
pnpm simulate
pnpm test

The generated game source imports only this SDK. The package uses the separate CLI as a development tool and remains ordinary editable TypeScript—there is no BoardEngine-specific project file to keep in sync. pnpm dev imports that same module into a generic local playtest, rendering projected scenes through Three.js and scene-free games through their projected legal actions.

import { defineGame, playtest } from '@tabletopwithfriends/sdk'

export const counter = defineGame({
  id: 'counter',
  state: { count: 0 },
  end: ({ state }) => state.count >= 5,
  actions: {
    increment(draft) {
      draft.count += 1
    },
  },
})

const test = playtest(counter)
test.act('increment')
test.state.count // 1

This smallest form is for games whose complete state is public and whose actions only edit state without command inputs or transition effects. Direct action methods receive the correctly typed draft and, when needed, the usual action context as their second argument. BoardEngine normalizes them into the same inputless actions used everywhere else. When both view and project are omitted, the SDK validates the state schema as every player and spectator’s view; an omitted catalog is an empty catalog. A game with hidden or audience-specific information can provide project alone: its return type becomes ViewOf<Game> and every projection is validated as JSON. Projected JSON is structurally read-only, so a projector can return safe state branches directly (board: state.board) without copying arrays or objects. Add an explicit view schema when the network boundary needs stricter shape or value validation independent of the projector. Here the plain numeric field compiles to a widened number schema whose default is 0, so setup is unnecessary. A boolean end condition marks completion without requiring a dummy result payload.

For state only, a plain string, finite number, or boolean field is both its initial value and its inferred broad type. Plain objects recurse, so common structured state stays ordinary TypeScript:

state: {
  scores: { red: 0, blue: 0 },
  settings: { label: 'Round', enabled: true },
}

BoardEngine widens those leaves to number, string, and boolean, supplies fresh nested defaults for every setup, and still exposes one ordinary Zod schema to the runtime. Use an explicit schema such as z.number().int().min(0).max(5).default(0) when a field needs constraints or transforms. Arrays and nullable values also stay explicit because [] and null cannot describe the types authors will place there later.

Every object schema boundary also accepts explicit field shapes. BoardEngine turns events: { moved: { spaces: z.number().int() } } into the corresponding z.object(...), and does the same for result, view, state, and action input. Pass a complete Zod schema when the boundary itself is an array, union, transform, or another non-object contract. Plain values are defaults only in game state; they are not silently accepted as event or command schemas.

Schema-bearing SDK definitions can be used as a complete schema boundary or placed directly in an object field shape. BoardEngine unwraps their normalized .schema while retaining the definition’s exact output type:

const seats = defineSeats('north', 'south')
const turns = seats.turns

state: { turn: turns.default(), active: seats },
events: {
  moved: { seat: seats, spaces: z.number().int() },
  seated: seats,
},
result: seats,

actions: (action) => ({
  chooseSeat: action(seats, (draft, { input: seat }) => {
    draft.active = seat
  }),
}),

This works for state, view, result, event-payload, and action-input boundaries, as well as inside raw shapes and helpers such as seats.record(track). Reach for .schema explicitly only when applying a Zod modifier such as seats.schema.nullable(). An object whose fields are all schema fields remains an object shape, so a real field named schema is unaffected.

examples/minimal-counter is a complete version of this shape in fewer than forty lines. It has no catalog, scene, renderer code, or custom browser UI. Its inputless action is still discoverable by tests, simulation, room clients, and the generic browser action fallback. Its display title is derived from minimal-counter; only its deployed example version is overridden.

examples/dice-race carries the same inference into a hosted multiplayer game: its audience-specific projector defines ViewOf<typeof diceRace> without a duplicate view schema, while a plain component list and scene object normalize to the catalog and renderer boundary. It attaches the scene with defineGame({...}).withScene((view, action) => ...). This resolves state, projection, and action types first, so the scene receives the exact inferred view and action names even when the state uses a direct field shape. scene inside defineGame remains supported for schema-first definitions; withScene can also replace an existing scene.

examples/phased-race keeps roll and move as separate steps of one active player’s turn. It composes typed turn and phase guards around ordinary inputless actions, then verifies that the same boundaries drive headless choices, replay, and complete simulation.

Flat metadata is shorthand for manifest. Only id is required: the SDK turns dice-race into the display title Dice Race and defaults the initial version to 0.1.0. Set title or version explicitly whenever either differs. The nested manifest form remains fully explicit, and every resulting game exposes a normalized manifest with player counts. A static initial state can be supplied as an object and is validated once, then cloned for every session. It can also be omitted when the state schema can materialize an initial value from Zod defaults; BoardEngine validates that value once and clones it through the same normalized setup function. A schema without enough defaults fails immediately when the game module loads. Use a setup callback when initialization needs players or seeded randomness. That callback returns state-schema input, not already-materialized output, so fields with defaults may still be omitted:

state: {
  deck: z.array(z.string()),
  round: z.number().int().positive().default(1),
},
setup: ({ random }) => ({
  deck: random.shuffle(['one', 'two', 'three']),
}),

Definitions with a natural empty state can own that default too: chat.default() starts a fresh message history and zones.default() starts a fresh empty array for every declared physical zone:

state: { messages: chat.default(), zones: gameZones.default() }

Every callback result is parsed immediately into complete validated state; the normalized game.setup(...) seen by runtimes always returns schema output. Likewise, components: [board, pawn] is shorthand for catalog: defineCatalog(board, pawn); hosts always receive a normalized catalog. Component lists and defineCatalog also accept authored collections such as a complete card-deck set, so authors do not unpack internal arrays.

When an action needs an input, authored description, legal choices, guard, event, or completion effect, use the action-builder callback. Its callable action(...) form edits a fresh state draft and may return transition effects. The draft comes first, so an action that does not need command context has no unused parameter. When a direct-method game gains an advanced action, action.group(...) lets the existing methods remain unchanged:

actions: (action) => action.group({
  increment(draft) {
    draft.count += 1
  },
  add: action(
    'Add an amount',
    { amount: z.number().int().positive() },
    (draft, { input }) => {
      draft.count += input.amount
    },
  ),
})

Use a direct method map while every action is inputless and state-only. Use the builder callback for advanced actions, and wrap a mixed map once with action.group(...); each plain method becomes the same normalized inputless action as the top-level shorthand, while builder-created entries retain their exact inputs, choices, and guards. In builder forms, descriptions and input schemas are optional, and both complete Zod schemas and raw object shapes are accepted for input. When a description is omitted, the normalized game derives one from the action name: playCard becomes Play Card, so generic controls and CLI inspection remain readable. Provide a description when the rulebook wording should differ. The second updater parameter is the usual typed action context, including the actor, players, seeded randomness, logical time, and command ID.

When replacing the complete state is genuinely clearer, use action.reduce(...); its callback receives the deterministic context and returns the replacement:

reset: action.reduce('Reset everything', () => ({ count: 0 })),
add: action.reduce(
  { amount: z.number().int() },
  ({ state, input }) => ({ count: state.count + input.amount }),
),

The object form adds advanced metadata and accepts update, reduce, or the full apply transition.

For an ordered-turn action, let the conventional state.turn field supply the standard active-seat guard:

roll: action.onTurn(
  'Roll the die',
  (draft, { actor, random }) => {
    const roll = random.roll(die).value
    draft.positions[actor.seat] += roll
    draft.turn = turns.advance(draft.turn)
  },
)

onTurn accepts either a scalar seat in state.turn or an ordered-turn object with state.turn.activeSeat. It preserves the authored action’s exact input and inputless marker and returns the usual “It is …‘s turn” reason to generic clients. It deliberately does not advance automatically: the update keeps turns.advance(...) at the point required by the rulebook. To guard an existing finite or otherwise advanced action, wrap it:

play: action.onTurn(action.choose(legalMoves, updateMove))

When every action in a group belongs to the active player, wrap the map once:

actions: ({ action }) => action.onTurn({
  roll: action('Roll', roll),
  move: action.choose(legalMoves, move),
  pass: action.when(canPass, action('Pass', pass)),
})

The returned map preserves each action’s exact input, choices, description, and existing availability check. The active-turn check still runs first for every entry, through the same normalized action definitions as individual wrapping.

When the game needs only an active seat—not turn and round counters—the seat set itself is the lightweight turn contract:

state: { turn: seats.default() },

play: action.onTurn(
  action.choose(legalSquares, (draft) => {
    draft.turn = seats.next(draft.turn)
  }),
)

seats.default() initializes the first declared seat; pass another typed seat to select it explicitly. seats.schema stays strict for payload fields that must always be present. Pass an explicit contract as action.onTurn(customTurns, action) only when state.turn uses a custom shape; its available(state.turn, actor) function then owns the comparison.

For any other condition, use action.when(...) instead of opening the full object form solely for available. onTurn also composes an existing availability check, evaluating active-seat ownership first.

The inputless form retains the strict discoverable {} choice. A guarded open-ended input adds either a raw shape or complete Zod schema before the availability callback:

add: action.when(
  'Add without passing ten',
  { amount: z.number().int().positive() },
  ({ state, input }) =>
    state.count + input.amount <= 10 || 'Count cannot exceed ten',
  (draft, { input }) => {
    draft.count += input.amount
  },
)

The input is inferred in both callbacks, and a rejected boolean or string availability result follows the same runtime path as the object form. This input-bearing form remains open-ended: concrete scene bindings are filtered, but generic clients and simulation do not invent possible values. Use action.choose(...) when the rulebook decision has a finite enumerable set. To add a guard to an already-authored finite or otherwise advanced action, wrap it instead of moving back to the object form:

playCard: action.when(
  ({ state, actor }) =>
    played.isEmpty(state.zones, actor.seat) || 'You already played this round',
  action.choose(cardChoices, playCard),
)

The wrapper preserves the action’s exact input, choices, description, and inputless marker. Its new guard runs first; when it accepts, any availability check already on the wrapped action runs next. This composes with onTurn and inPhase, so each rulebook restriction can remain at the layer that owns it. When several actions share that restriction, guard their map once:

actions: (action) => action.when(
  ({ state }) => state.energy > 0 || 'No energy remains',
  {
    draw: action('Draw', drawCard),
    pass: action('Pass', passTurn),
  },
)

Every entry retains its exact input, choices, description, and existing guard; the shared condition runs first. Return the guarded map directly when it is the whole action set, or spread it into a larger action object. In practice, the three short forms cover most game actions: action(...) for unconditional edits, action.choose(...) for finite decisions, and action.when(...) for guarded actions.

The actions callback receives one value: a callable action builder that is also a named toolbox. Use actions: (action) => ... when the builder is all the game needs. Destructure named tools when an action emits events or ends the game:

actions: ({ action, event, finish }) => ({
  concede: action('Concede', (_draft, { actor }) =>
    finish(
      { winner: actor.seat },
      event('game.conceded', { seat: actor.seat }),
    ),
  ),
})

Rule callbacks follow the same model: use the callable rule value directly, or destructure ({ rule, event, finish }) when more tools are needed.

For the common case—editing a few fields and perhaps emitting events or host instructions—use update. Every action and rule updater receives a fresh writable draft first and the deeply read-only deterministic context second:

actions: ({ action, event }) => ({
  play: action({
    choices: ({ state }) => state.hand.map((card) => ({ cardId: card.id })),
    update(draft, { input }) {
      const index = draft.hand.findIndex((card) => card.id === input.cardId)
      draft.hand.splice(index, 1)
      return event('card.played', { cardId: input.cardId })
    },
  }),
})

The authoritative context.state remains deeply read-only. update clones it, checks that the edited draft remains JSON-serializable, and combines the draft with any returned effects. Use apply as the explicit escape hatch when constructing the full { state, events, instructions } transition is clearer. All three forms normalize to the same runtime action, so replay, validation, and simulation follow one execution path.

The standalone updateState(state, change) helper provides the same typed-copy behavior for reusable state helpers or explicit apply transitions.

For tests and simulations, playtest supplies players, a deterministic seed, command IDs, and current revisions automatically:

const test = playtest(counter)

test.act('add', { amount: 2 })
test.state.count // 2
test.act('reset')
test.state.count // 0
test.view() // the only player's validated state and legal actions
test.choices() // choices declared by currently available actions

For multiplayer tests, bind a validated player once instead of repeating its host ID across every operation:

const test = playtest(game)
const north = test.player('north')

north.choices()
north.act('playCard', 'card:1')
north.view()

The bound driver’s action names and inputs remain inferred from the game. Inputless actions accept command options directly, for example north.act('roll', { id: 'opening-roll' }), without an authored or serialized dummy input. The ordinary test.act(playerId, ...), view(playerId), and choices(playerId) forms remain useful for one-off calls.

Start an edge-case test from a checked position without constructing a complete runtime snapshot:

const endgame = playtest(ticTacToe, {
  state: {
    board: ['x', 'x', null, 'o', 'o', null, null, null, null],
    turn: 'x',
  },
})

endgame.act('x', 'play', 2)
endgame.result // 'x'

The state shape is inferred from the game schema and validated through the same restoration boundary as a persisted session. It starts at revision zero, keeps the chosen seed and the random cursor produced by normal setup, and is mutually exclusive with the lower-level snapshot option. simulate and explore accept the same starting-state shorthand, so agents can exercise or search a focused rulebook position without forging runtime metadata.

Schemas prove that a position has the right shape. Named invariants express rulebook truths that must also hold in every stable position:

invariants: {
  marksAlternate({ state }) {
    const x = state.board.filter((mark) => mark === 'x').length
    const o = state.board.filter((mark) => mark === 'o').length
    return x === o || x === o + 1 || 'X and O marks must alternate'
  },
},

An invariant returns true or a useful failure reason. BoardEngine checks it after setup, when restoring snapshots or scenario state, and after automatic rules have fully settled each action. A failure aborts the transition with the named INVARIANT_FAILED runtime error, so normal play, CLI inspection, simulation, replay, and exhaustive exploration all enforce the same facts.

act('reset') and act('add', { amount: 2 }) infer the only player. view() and choices() do the same. These shorthands throw when a playtest has more than one player. Multiplayer actions keep the explicit player ID. String-valued action inputs also use act('solo', 'name', input) because a two-string call is otherwise indistinguishable from act(actorId, actionName).

For a complete headless run, simulate repeatedly gathers every player’s legal choices and applies a policy until the game emits a finish instruction:

const result = simulate(game, {
  policy: ({ choices, random, view }) => {
    // Policies can inspect private projections without touching game state.
    void view(choices[0]!.actor.id)
    return random.pick(choices)
  },
})

result.status // 'finished' | 'stuck' | 'stopped' | 'limit'
result.playtest.session.records // exact replayable command/rule trace

To systematically playtest a finite game instead of following one policy path, use explore. It breadth-first visits every reachable unique runtime state up to a bound:

const report = explore(game, { maxStates: 10_000 })

report.status // 'complete' | 'limit'
report.exploredStates
report.finishedStates
report.stuckStates
report.outcomes[0]?.shortestPath

const reached = replayPath(game, report.outcomes[0]!.shortestPath)
reached.state
reached.result
reached.session.records

Every transition still goes through the authoritative session. At every visited state, exploration validates every player projection, the spectator projection, and their generated scenes before enumerating legal choices. Results group identical typed game outcomes and retain a reproducible shortest action path; dead ends retain their shortest path too. State deduplication includes game state, deterministic randomness, logical time, and completion, but excludes the irrelevant revision counter.

Exploration steps retain their original command IDs as well as actor, seat, action, and input. replayPath therefore reproduces command-derived object and message identities, not only the visible sequence of moves. It returns the ordinary Playtest, so an agent can inspect state, private views, result, and the exact command/rule trace or continue acting from that point. When exploring with explicit players or a custom seed, pass the same options to replayPath.

Exploration only invents inputs for exhaustive finite choices. An open-ended action such as chat is never guessed; a state offering only open-ended actions is reported as stuck. ExplorationError.path identifies the shortest exactly replayable branch that exposed an invalid transition, projection, or scene. Tic-Tac-Toe’s tests use explore(ticTacToe) to validate its complete reachable game tree and all three outcomes.

The same workflow is available without writing a harness. The separate @tabletopwithfriends/cli adapter loads an ordinary trusted TypeScript module while the game itself still imports only the SDK:

pnpm --silent game inspect examples/tic-tac-toe/src/game.ts
pnpm --silent game inspect examples/tic-tac-toe/src/game.ts --state-file examples/tic-tac-toe/scenarios/endgame.json
pnpm --silent game simulate examples/dice-race/src/game.ts --seed playtest-1
pnpm --silent game explore examples/tic-tac-toe/src/game.ts --max-states 10000
pnpm --silent game manufacture examples/tic-tac-toe/src/game.ts

Successful commands emit machine-readable JSON to stdout. Structured failures go to stderr with a nonzero exit code, retaining runtime error codes, nested causes, and replayable exploration paths. See the @tabletopwithfriends/cli guide for module selection and explicit player options. Its --state-file flag exposes the same checked scenario-state workflow as playtest({ state }) to inspect, simulate, and explore, using normal JSON files that coding agents can create and revise.

Omit players from a flat definition for a one-player game. BoardEngine infers one typed seat named player, so game.players.createPlayers(), action actors, and zero-config playtests still work. For fixed named multiplayer seats, pass a literal list such as players: ['north', 'south']. It is normalized to the full defineSeats('north', 'south') helper, so game.players.schema, next, isActive, available, record, and createPlayers remain available and the seat names flow into action types. Define the helper separately only when the game body itself needs those operations. A nested manifest retains its explicit player-count contract and does not opt into the default seat.

Games using the inferred solo seat, a named seat list, or defineSeats can omit players in playtest and simulate. The SDK creates one local player per seat with the seat name as its ID. Supply players when a test needs custom identities; games with a custom player contract must always supply them. Authoritative createSession calls continue to require host-provided player identities. Local calls may also omit seed—or the entire options object—and receive the stable ${gameId}@${gameVersion} seed. Pass explicit game and policy seeds when a test needs a different run.

Omit policy for the deterministic first-legal-choice policy, or pass the exported randomChoicePolicy. Policy randomness is seeded separately and never advances authoritative game randomness. A policy must select one of the choice values it receives, which keeps every simulated command on the same validation path as a human command.

An action can declare its exhaustive finite set of legal inputs with a static choice array or choices(context) callback. For this common board-game case, omit input: TypeScript infers the action input from the choice values, while the runtime requires JSON and exact membership in the current list. The runtime also filters choices through any additional available check and returns them through actionsForPlayer/playtest.choices and every player projection’s actions array. A direct command must match one of the declared choices, so authors do not repeat an input schema, ownership, turn, or occupied-space checks merely to protect the command path. Supply both input and choices when choices need schema transforms or additional schema-level constraints. Omit choices for open-ended inputs such as chat messages; those actions rely on their explicit input schema and available check and are not enumerated in the projection. This gives tests, clients, and AI agents the same legality boundary without scraping UI state. Treat the projected actions array as the answer to “what can this player do now”; do not mirror it with view fields such as canRoll or canPlayCard. That prevents a projector and the authoritative action guard from disagreeing.

action.choose(...) is the concise form for that decision-first pattern. Plain string choices automatically receive readable labels derived from their identifiers, so the smallest fixed decision stays small:

chooseStance: action.choose(
  ['attack', 'defend'],
  (draft, { input }) => {
    draft.stance = input
  },
)

Generic controls and agent traces show Attack and Defend, while input remains exactly 'attack' | 'defend'. Camel case and separators are expanded, so drawCard becomes Draw Card. Map each command value to a label when the rulebook calls for custom wording:

chooseStance: action.choose(
  'Choose a stance',
  { attack: 'Attack', defend: 'Defend' },
  (draft, { input }) => {
    draft.stance = input
  },
)

Explicit labels accompany those unchanged strings in generic controls and agent traces and take precedence over derived labels. Non-string values, including structured JSON choices, remain unlabeled unless wrapped with choice(input, label) or paired with a choiceLabel callback.

A choice is any serializable JSON value. Prefer the value itself when the decision selects one square, card ID, stance, or other atomic value; use an object when a command genuinely carries multiple named fields. The input keeps that exact inferred type through scenes, projections, playtests, replay, and the room protocol.

Static arrays and labeled maps are cloned when the game is defined, so later mutations cannot change authoritative behavior. Pass a callback when legality depends on the current position. Its draft-first updater receives the exact inferred input:

play: action.choose(
  'Place your mark',
  ({ state }) => grid.emptyCells(state.board),
  (draft, { actor, input: square }) => {
    draft.board[square] = actor.seat
  },
)

Wrap that choice in action.onTurn(...) when state.turn stores the active seat. Turn ownership then gates projection, dispatch, simulation, and scene interactions without being repeated in the choice callback.

The description is optional for both forms. Use the full action({ ... }) object when the same action also needs available, choiceLabel, or other advanced metadata.

Use choice(input, label) when individual inputs need meaningful names instead of raw JSON in a generic client or agent trace. The label stays beside the authored legal input and does not become part of the command:

playCard: action.choose(
  'Play one card face down',
  ({ state, actor }) =>
    hands.get(state.zones, actor.seat).map((card) =>
      choice(
        card.id,
        `Play ${resolveCard(deck, card).name}`,
      ),
    ),
  (draft, { actor, input: instanceId }) => {
    zones.draft.move(draft.zones, instanceId, played.of(actor.seat))
  },
)

The helper requires a non-empty string, preserves the exact inferred input union, and lets authors reuse data already present while enumerating a choice. The runtime includes the label beside the unchanged typed input in local playtests, simulations, exploration paths, room projections, and generic web controls. Labels never participate in command identity or legality; the input remains the stable machine-readable decision. The separate choiceLabel callback remains available when one label function is more convenient; an inline label takes precedence when both forms are present.

A choice can also declare which catalog components it is made of, as a multiset (repeat an id to mean several of that component):

choice(
  { routeId: route.id, paymentColor: 'red', locomotives: 1 },
  `${route.cityA}–${route.cityB} (3): 2 red + 1 locomotive`,
  { components: ['train-red', 'train-red', 'train-locomotive'] },
)

Hosts that understand the metadata — the generic local table and the web playground both do, through @boardengine/table-ui — present the whole action type as one tangible selection instead of a button list: the player assembles the exact set of cards or pieces, options that no longer fit the selection dim, and the action whose components equal the selection is the one committed. Hosts that don’t understand it still fall back to the labels. Component ids are validated against the game’s catalog while enumerating; like labels, they never affect command identity or legality.

Use action.prompt(...) when several actions should deliberately become a host-owned overlay. It is useful for abstract questions that do not belong to a particular place on the table. The shared prompt(...) value can carry a catalog subject and compact facts; it is projected beside every legal answer without becoming command input:

const offer = prompt({
  id: 'market-offer',
  title: `Buy ${card.name}?`,
  message: card.rules,
  // The table presents this existing rendered object; the prompt does not
  // manufacture a separate image preview.
  subject: { component: card, objectId: 'current-offer' },
  details: [
    { label: 'Cost', value: 5 },
    { label: 'Income', value: 2 },
  ],
})

actions: ({ action }) => action.prompt(offer, {
  buy: action('Buy', (draft) => { /* ... */ }),
  pass: action('Pass', (draft) => { /* ... */ }),
})

The source may also be a callback, so its subject and facts can follow current state. When objectId is present, 3D hosts lift that existing scene object in front of the camera using its real component renderer; the prompt itself only draws the copy, facts, and answer buttons. Hosts can also infer the object when exactly one scene object uses the subject component. Unknown prompt components fail during projection.

For a tangible table decision—buying a displayed card, refilling a market row, or clearing a discard—prefer ordinary scene composition instead. A physical button is just a catalog component carrying a normal typed scene action; the engine does not know or care whether it means Buy, Pass, Refill, or Detonate.

Solo playtests accept primitive input directly, including strings:

const test = playtest(game)
test.act('chooseStance', 'attack')

For multiplayer, keep the actor first: test.act('north', 'chooseStance', 'attack').

For an action with no input, omit both input and choices; the SDK supplies a strict empty-object schema and exposes {} as its single candidate choice. Scene callbacks can use action('reset'), and playtests can use playtest.act('solo', 'reset'); normalized scene actions and serialized commands still carry {} on the wire.

defineGame infers action names, inputs, state, and views from their Zod schemas. State is deeply read-only inside actions; an action returns its next state plus optional domain events and effects. Transition callbacks also receive commandId, a stable replay-safe identity for messages, spawned objects, and other data created by that command. Availability and choice checks remain independent of a not-yet-created command.

Domain events are declared alongside the game with a payload schema or payload-less marker per event name. Destructure the named event tool from the action or rule builder to create them, so event names and payloads are inferred without casts. The runtime parses every emitted event before committing a transition; invalid events cannot advance the game. Games that do not emit events can omit events and use the callable builder without destructuring.

Use true when an event name carries no payload. It remains part of the same typed vocabulary and is emitted without a meaningless empty object:

events: {
  'turn.ended': true,
  'piece.moved': { pieceId: z.string(), spaces: z.number().int() },
},

actions: ({ action, event }) => ({
  endTurn: action((draft) => {
    draft.turn += 1
    return event('turn.ended')
  }),
})

The normalized definition still contains a Zod schema for every event, so runtime validation, persistence, and replay do not special-case this shorthand.

A draft update may return one event, an event array, or an explicit { events, instructions } effects object. The common case stays direct:

update(draft, { actor }) {
  draft.score += 1
  return event('point.scored', { seat: actor.seat })
}

When later branches may append more events, start a correctly typed mutable batch with event.list(...) instead of spelling the event union yourself:

const events = event.list(event('card.played', { seat: actor.seat }))
if (roundFinished) {
  events.push(event('round.finished', { winner }))
}
return events

The list contains only events from that game’s declared vocabulary. Each event is still checked when created, and the runtime parses the completed batch before committing the transition.

Automatic rules accept the same forms. This shorthand changes only authoring; the normalized transition still carries an event array and passes through the same schema validation, replay, and projection pipeline.

rules describes automatic transitions that settle after each accepted player action. The callable rule(...) form is condition-first and draft-first:

result: z.object({ count: z.number().int() }),
rules: ({ rule, event, finish }) => ({
  finish: rule(
    'Finish at ten',
    ({ state }) => state.count >= 10,
    (draft, { state }) => {
      draft.count = 0
      return finish(
        { count: state.count },
        event('counter.finished', { count: state.count }),
      )
    },
  ),
})

The description is optional and derives from the rule-map key when omitted; resolveRound becomes Resolve Round in inspection output. Use rule({ when, update }) when keeping all rule metadata in one object reads better, or apply when constructing the complete next state and effects explicitly. Like the action helper, rule(condition, existingRule) can wrap an already- authored rule. It preserves metadata such as oncePerCommand, evaluates the new condition first, and then evaluates the wrapped rule’s existing condition. This keeps independent automatic-rule restrictions composable instead of combining them into one callback. For lifecycle-driven settlement, rule.inPhase(phase, update) supplies the typed phase condition; wrapping an existing rule composes its condition after the phase check.

All three rule wrappers also accept a map. This keeps a shared lifecycle rule around the group that owns it:

rules: (rule) => rule.inPhase(
  'scoring',
  rule.once({
    scoreRows: rule(hasCompletedRows, scoreRows),
    scoreColumns: rule(hasCompletedColumns, scoreColumns),
  }),
)

rule(condition, { ... }) works the same way. Each returned entry keeps its name, description, condition, transition, and once-per-command metadata. The shared condition runs first, and rule.once({ ... }) marks every entry without merging them into one opaque transition.

When a reaction should fire once after a command even though its condition remains true, use rule.once(...):

resolveRound: rule.once(
  ({ state }) => played.allOccupied(state.zones),
  (draft) => {
    resolvePlayedCards(draft)
    // The revealed cards may intentionally remain visible until the next move.
  },
),

This means once per command, not once per game. A later command can enable the rule again. Ordinary rules should still update state until their condition is false when repeated settlement is part of the rulebook.

The first enabled rule runs, then evaluation restarts until no rule applies. The runtime commits the player action and all resulting rules atomically, records the applied rule names for debugging and AI inspection, and rejects a rule set that cannot settle. Replay verifies events, instructions, applied rules, randomness, and state—not only the final state hash.

When completion follows entirely from stable state and the game has no outcome data beyond being finished, declare a pure boolean end condition:

end: ({ state }) => state.completed,

Without a result schema, end must return exactly true or false. true ends the game without inventing a dummy result; false continues play.

When a finished game has typed outcome data, pair its result schema with an end condition that returns that result. A result does not need an object envelope. For the common winner-only case, use the seat contract itself as the schema and return the winning seat directly:

result: seats,
end: ({ state }) => {
  if (track.isFinish(state.positions.red)) return 'red'
  if (track.isFinish(state.positions.blue)) return 'blue'
  return undefined
},

In this form, returning undefined continues play; returning a value ends the game with the schema-validated result. BoardEngine evaluates both forms of end after setup and after each action’s automatic rules have settled. Terminal setup or scenario state is therefore represented as completion at revision zero, while failed or invalid end conditions abort without committing a command. Explicit finish effects take precedence and do not run the automatic condition again.

For a game with draws, result: seats.schema.nullable() makes a seat a win, null a finished draw, and undefined the distinct “keep playing” signal. Use an object-shaped result only when the outcome genuinely has multiple fields, such as a winner plus final scores.

Prefer end whenever the outcome is fully derivable from stable state. The action can then describe only the player’s move and its domain events; it does not need a final-move branch, a duplicate terminal flag, or a call to finish(...). Tic-Tac-Toe follows this pattern: play places one mark and advances the active seat, while its pure outcome function returns the typed win or draw result through end.

For transition-specific completion such as conceding, or when the final move must emit a domain event, destructure the named finish tool. Return finish({ winner: 'north' }) directly, or pass one event, an event array, or { events } as its second argument when the final transition also emits domain events. Games without a result schema call finish(). The helper returns ordinary transition effects; the host-instruction representation stays below the authoring boundary. The runtime canonicalizes the result, stores it in the snapshot, exposes it as session.result/playtest.result, and rejects every new command after completion. Retrying the command that finished the game remains idempotent. Finished sessions advertise no legal actions and never invoke action choices or available callbacks, so individual actions do not need to repeat winner, draw, or terminal-phase guards solely for post-game safety.

Projection callbacks receive that same authoritative completion without copying it into game state:

project: ({ state, finished, result }) => ({
  board: state.board,
  finished,
  winner: result ?? null,
})

result is inferred from the game’s result schema and remains inside the trusted game projector. The runtime projection envelope exposes only completion: { revision }; it never sends the raw result around the projection boundary. Authors explicitly select public outcome fields in project, so a result may safely contain information that should remain hidden from some or all clients. Games whose state alone is their complete public view can still omit project entirely.

For a rulebook that allows a range without meaningful fixed seats, declare it directly:

const playerCount = definePlayers(2, 4)

export const game = defineGame({
  players: playerCount,
  // setup and actions receive players whose seat is a string
})

playtest(game) // creates the minimum two local players
playerCount.createPlayers(4) // explicit four-player test or simulation

Hosts may supply any unique player IDs and seat IDs within the declared range; the generated player-1, player-2, and so on are deterministic local defaults. Use defineSeats instead when seat names carry rules meaning or when seat-keyed schemas and helpers are useful.

Dynamic tables can still use deterministic ordered turns:

const turns = definePlayerTurnOrder()

const state = z.object({ turn: turns.schema })
const setup = ({ players }) => ({ turn: turns.create(players) })

const takeTurn = action.onTurn(
  (draft, { players }) => {
    draft.turn = turns.advance(draft.turn, players)
  },
)

The host’s validated player array is the turn order. create checks the starting seat, and advance checks that the active seat remains present before updating the serializable turn number, round, and active seat.

defineSeats('north', 'south') provides a typed Zod schema, player occupancy validation, a seat type guard, and cyclic next/previous helpers. Pass it as the game’s players contract and defineGame derives the manifest count while setup/actions receive players whose seat is the inferred union. The runtime validates exact occupancy before setup, so game code does not repeat counts or call assertPlayers itself. The normalized game retains this contract as game.players; createPlayers() produces frozen seat-ordered players for local play, tests, and simulations, while a callback can supply custom IDs, names, and optional host-defined teams. Production hosts still provide authenticated identities.

Seat-indexed game data does not need to repeat every seat name:

const scoresSchema = seats.record(0)
const asymmetricSchema = seats.record(
  z.number().int().nonnegative(),
  (_seat, index) => index,
)
const you = seats.fromAudience(audience) // inferred seat union or null

const game = defineGame({
  state: { scores: scoresSchema },
  // setup may omit scores; the schema creates { north: 0, south: 0 }
})

The record schema requires every configured seat and rejects extra keys. A lone plain scalar is the widened value schema and the initial value for every seat, so seats.record(0) produces a typed { north: number, south: number } scoreboard initialized to zero. Use the explicit schema-plus-default form when values need constraints. Its optional second argument supplies that validated schema default for every seat, either as one shared value or a seat-aware factory. Each parse constructs a fresh record, so nested arrays and objects are never shared between games. createRecord calls its factory in seat order and freezes the resulting complete record when a standalone value is useful instead. fromAudience returns null for spectators, system tools, and unknown seats.

For games with fixed teams, compose a team contract from the seats and pass it as players:

const seats = defineSeats('north', 'east', 'south', 'west')
const teams = seats.teams({
  north: 'sun',
  east: 'moon',
  south: 'sun',
  west: 'moon',
})

export const game = defineGame({
  players: teams,
  // actor.team is inferred as 'sun' | 'moon'
})

Hosts still supply only identity and seat; the runtime derives authoritative team membership and rejects an explicitly conflicting team. createPlayers() does the same for local play and simulation. The contract also provides teamFor, seatsFor, a team schema, team-keyed record/createRecord, and fromAudience helpers. Team records accept the same optional initial value or factory as seat records. defineTeams(seats, assignments) remains the explicit standalone form of the same contract.

For the lightest sequential game, store a seat directly in state.turn and use action.onTurn(action). The wrapper supplies the standard active-seat guard, seats.next(state.turn) advances cyclically, and seats.default() owns the first active seat without a setup block. Pass a seat to choose another default. When the rulebook also uses numbered turns or rounds, seats.turns expands that same model into serializable active-seat, turn-number, and round-number state with immutable advancement. defineTurnOrder(seats) remains available when an explicit standalone derivation reads better. Use turns.default() as the state field when the first declared seat starts, or turns.default('south') to author another starting seat. The strict turns.schema remains available for event, result, and input fields where omission should be rejected. turns.available(state.turn, actor) remains available as a standard true | string result for custom composition. The common action.onTurn(action) form reads the conventional typed state.turn field without advancing it. Games with simultaneous or free-form turns do not need to use it.

definePhases('setup', 'playing', 'finished') creates a typed linear lifecycle that can initialize state directly as state: { phase: phases.default() }: the first phase is initial and each phase transitions to the next. A repeating sequence is just definePhases.cycle('roll', 'move'), which adds the final move → roll edge. Use the explicit definePhases({ initial, transitions }) graph only for branching or irregular lifecycles. All forms provide the same strict .schema, state-oriented .default(), and checked transition; invalid phase names are rejected by TypeScript, while illegal edges and untyped input are rejected at runtime. action.inPhase(...) supplies the matching standard availability guard while preserving finite choices and any existing guard:

actions: (action) => ({
  start: action.inPhase('setup', 'Start playing', (draft) => {
    draft.phase = phases.transition(draft.phase, 'playing')
  }),
  ...action.inPhase(['playing', 'scoring'], {
    score: action.choose([1, 2] as const, (draft, { input }) => {
      draft.score += input
    }),
    pass: action('Pass', pass),
  }),
}),

rules: (rule) => rule.inPhase('scoring', {
  resolveScoring: rule(() => true, (draft) => {
    // Resolve the round here.
    draft.phase = phases.transition(draft.phase, 'playing')
  }),
}),

Like action.onTurn, this reads a conventional typed state field but does not change it automatically. It can wrap one authored action or a whole map; the map form retains every entry’s exact contract and is useful for phases with several decisions. rule.inPhase(...) follows the same single-or-map contract for automatic transitions and composes existing rule conditions. Phase transitions remain visible beside the action or rule that causes them; an automatic rule must leave or otherwise disable its phase to settle.

Logical board topology is separate from the physical board component. defineGrid owns a row-major cell range, exact state-array schema and default, coordinates, rows, columns, neighbors, occupancy, and contiguous line matches:

const grid = defineGrid(3)
const seats = defineSeats('x', 'o')

const board = grid.state(seats, null)
const initialBoard = board.parse(undefined)
const state = { board }

grid.cells // [0, 1, ..., 8]
grid.parseCell(4) // 4; a grid used as a schema field validates one cell reference
grid.neighbors(4) // [1, 3, 5, 7]
grid.emptyCells(initialBoard) // [0, 1, ..., 8]
grid.matchingLine(['x', 'x', 'x', null, null, null, null, null, null], 3)
// { cells: [0, 1, 2], value: 'x' }
grid.isFull(initialBoard) // false
grid.lines(3) // lower-level rows, columns, and both diagonal directions

All state-query helpers require exactly grid.size cells. Empty cells are represented by JSON-safe null; matchingLine skips them and returns the first contiguous equal primitive with its cell indexes. defineGrid(3) is the square shorthand; use defineGrid(2, 3) or the explicit defineGrid({ rows: 2, columns: 3 }) form for a rectangle. Grid state accepts any SDK schema provider directly. Passing null as the initializer adds the nullable empty-cell state, so authors do not unwrap and rebuild a seat or other typed contract merely to describe an empty board. Other initializers may be one cell value or (cell, coordinates) => value; they are validated and rebuilt for each game, so nested values cannot share references. Omit the initializer for a strict schema, or use create when a standalone initialized array is useful. The grid itself is a schema provider for one valid cell, so an event or action input can use { square: grid } without repeating its numeric range. grid.schema is the explicit equivalent. Scene code passes the same grid to layout.grid, avoiding a second count/column declaration while keeping the topology renderer-neutral. Tic-Tac-Toe derives its state, legal squares, winner, draw, and layout dimensions from one grid definition.

Ordered paths use the same boundary. A numeric track supplies bounded positions and clamped movement; a named track preserves its literal space union. Set loop: true for circular boards:

const race = defineTrack(13)
const positions = seats.record(race)

race.start // 0
race.advance(10, 6) // 12, clamped at race.finish
race.isFinish(12) // true
layout.track(6, race, {
  start: [-2.25, 0.2, 0],
  finish: [2.25, 0.2, 0],
}) // [0, 0.2, 0]

const route = defineTrack(['dock', 'market', 'castle'] as const, { loop: true })
route.advance('castle') // 'dock'

Tracks expose move for signed offsets, advance, retreat, remaining, and validated index/space conversion. Like grids, they contain no rendering data. Scene code passes that same numeric or named contract to layout.track, which validates the logical space and interpolates between authored tabletop endpoints without duplicating index or coordinate math.

A game’s optional scene callback receives an action helper tied to that game’s action names and input schemas, so clickable objects stay type-safe. Author it as scene(view, action) in the definition or attach it after inference with .withScene((view, action) => ...). Renderer-neutral layout helpers remove repeated centering math while still producing ordinary serializable positions. Static scene objects can reference their component definition directly instead of repeating its ID:

objects: [
  { component: board },
  { id: 'active-pawn', component: pawn, position: [1, 0.2, 0] },
  { instance: cardFromState, position: [0, 0.08, 1.25] },
]

The object ID defaults to component.id, position defaults to the table origin, and an omitted face displays front when the component has one. Specify face: 'back' or a custom face only when that distinction matters. defineScene and the game scene boundary normalize references to plain componentId strings, explicit positions, and a game rejects scene objects that reference components outside its catalog. For instance, both the scene-object ID and component ID come from the stable physical instance unless its object ID is explicitly overridden. Raw id plus componentId remains available when a view carries only those serialized fields.

Table controls and play-space markings use those same neutral pieces. Define a button once, place it as an ordinary action-bound scene object, and add regions when the table needs a printed slot, discard outline, market row, or other visible section:

const refill = component.button({
  id: 'refill-market',
  color: '#d1a43a',
  label: 'REFILL',
})

components: [marketDeck, refill]

scene: (view, action) => ({
  regions: [{
    id: 'market-row',
    label: 'MARKET',
    position: [0, 0.01, 0],
    size: { width: 3.4, height: 1.2 },
    style: 'mat',
    color: '#d1a43a',
  }],
  objects: [
    ...view.market.map((card, index) => ({
      instance: card,
      position: layout.row(index, view.market.length, {
        spacing: 0.72,
        center: [0, 0.08, 0],
      }),
      physical: { movable: true },
    })),
    {
      component: refill,
      position: [0, 0.05, 0.46],
      action: action('refillMarket'),
    },
  ],
})

outline regions mark a destination without visually filling it; mat regions also tint the interior. Regions organize the playspace but do not own behavior. Put actions on buttons, cards, piles, tokens, or any other physical component. A movable offered card remains the actual authored component, so players can pick it up and hold the inspect control to see the same renderer, not a separate preview image. examples/market-offer and examples/san-francisco-monopoly both compose purchases this way.

A scene object can declare ghost: true to mean a placeholder rather than a physically present piece — the printed outline of a route space, an empty slot a card could land on. Renderers draw ghosts washed out (translucent, casting no shadow) so the real pieces on the table stand apart from the spaces awaiting them; a ghost keeps its action, so an empty space stays clickable. Swapping a ghost for a real component when the space fills composes with deal for a physical entrance.

A scene can also seat its players. Each seats entry places a player mat at the table edge with the player’s name and accent color, a giant avatar looming behind it, and — given handCount and a handComponent — that many face-down cards fanned at the avatar’s chest, Tabletop-Simulator style:

seats: view.playerOrder.map((seat, index) => ({
  seat,
  position: seatSpots[index]!.position,
  rotation: seatSpots[index]!.rotation,
  name: view.players[seat]?.name,
  color: playerColors[index],
  handCount: view.players[seat]?.handCount ?? 0,
  handComponent: trainDeck.back.id,
}))

The scene only states who sits where; the live social layer is host data. Hosts stream each player’s ephemeral presence (table cursor, whether their hand has focus) through the room protocol’s presence message, and renderers answer with a colored table pointer per player and avatar heads and eyes that track their owner’s cursor — dropping to study the floating hand when its player hovers their cards. Profile photos land on the mat through the renderer’s host API, not the scene.

Game components that live on a mat, in a public tableau, or in a deck/discard should use sceneZone(...). It consumes the visibility-safe { count, items } already produced by defineZones.project, so scene code cannot accidentally reveal a private hand. The renderer supplies stack, row, fan, and grid layout; zones may anchor to the table or to a named seat:

zones: [
  sceneZone(hands.get(view.zones, seat), {
    id: `${seat}-hand`,
    label: `${seat} hand`,
    anchor: { kind: 'seat', seat },
    layout: { kind: 'fan', spacing: 0.34 },
    back: deck.back,
    browse: view.you === seat ? 'all' : 'none',
    itemAction: view.you === seat
      ? (card) => action('playCard', card.id)
      : undefined,
  }),
  sceneZone(view.zones.discard, {
    id: 'discard',
    label: 'Discard',
    anchor: { kind: 'table', position: [1.5, 0.08, 0] },
    layout: { kind: 'grid', columns: 3 },
    back: deck.back,
    browse: 'all',
  }),
]

browse: 'none' | 'top' | 'all' states which visible objects may be enlarged for inspection. It is presentation permission, not information security: privacy belongs in defineZones.project. A hidden zone has items: null and renders only projected backs/counts; a public discard carries real instances and can be spread so every card is inspectable. See examples/hidden-card-duel for private hands, hidden simultaneous plays, a face-down draw stack, and a public browseable discard in one small game.

A deck or stack declares pile: { count, shuffled? } instead of pretending to be one card. Renderers stack count real copies of the component with a little physical jitter, deals citing deal: { from: <the pile> } lift off the top of the stack, and an exhausted pile (count 0) leaves a ghost marking the empty spot. shuffled is a serial the game bumps whenever it shuffles the pile — reshuffling a discard into a fresh deck, say — and renderers answer a change by physically tossing and settling the stacked cards:

{
  id: 'train-draw-pile',
  component: trainDeck.back,
  pile: { count: view.trainDeckCount, shuffled: view.trainDeckShuffles },
  action: action('drawTrainCard', { source: 'deck' }),
}

Scene code may attach every potential interaction without repeating turn, ownership, occupancy, or completion conditions. For each projection, the runtime keeps a scene action only when it is currently legal for that player; spectator piece bindings contain no actions. Resolution controls remain visible but disabled. Finite scene actions are matched against the same authoritative choices array returned in the projection. A concrete open-ended scene action is schema-checked and evaluated through available. Unknown actions and malformed scene inputs fail projection validation, so an agent discovers a broken interaction during tests or exhaustive exploration.

Layout remains renderer-neutral:

const handPosition = layout.row(index, cards.length, {
  spacing: 0.82,
  center: [0, 0.08, 1.25],
})

const boardPosition = layout.grid(square, grid, {
  spacing: 0.76,
})

Rows can run along the x, y, or z axis. Grids occupy the tabletop’s x/z plane and center partial final rows automatically. Invalid indexes, counts, columns, and spacing fail immediately.

Related physical pieces can share the same typed keys as the rules:

const pawns = seats.components((seat) => component.piece({
  id: `${seat}-pawn`,
  size: { width: 24, height: 32, depth: 16 },
  color: colors[seat],
}))

components: [board, pawns]

// Reference the exact authored definition instead of rebuilding its ID.
const pawn = { component: pawns.byKey[seat], position }

seats.components(...) is the direct form for the common seat-keyed family. defineComponentSet accepts a literal key tuple or any SDK definition with typed .names, such as teams. The returned components and byKey data are frozen and JSON-serializable; the set flattens anywhere a component source is accepted, including defineGame, catalogs, and manufacturing. Duplicate keys or generated component IDs fail when the set is authored.

Physical definitions and in-game objects are distinct. instantiate(catalog, 'deck-id') expands a deck recipe into stable { id, componentId } instances, including repeated copies of the same manufactured card. For a normal card deck, defineCardDeck keeps shared manufacturing details in one place and compiles ordinary card and deck definitions:

const prototype = defineCardDeck({
  id: 'ranks',
  cards: ['ace', 'king'],
})

prototype.cardIds // readonly ['ace', 'king']

A string is a complete prototype card: its display name and front label derive from the ID, while size and back artwork come from the deck defaults. Replace only the entry that gains rules data or artwork with { id, metadata, color, label, count, ... }; literal and detailed entries can coexist in one deck.

Playable pieces can start with useful physical defaults. Names are derived from component IDs when omitted, an ordinary piece defaults to a 20 × 30 × 12 mm meeple, a die without size or sides becomes a numbered 20 mm D6, tokens default to a 25 mm circular, 2 mm thick piece, and cards default to the standard 63 × 88 mm poker size:

const firstPlayer = component.token({
  id: 'first-player',
  color: '#f59e0b',
  label: '1st',
})

const boardTile = component.token({
  id: 'forest-tile',
  shape: 'hex',
  size: { width: 32, height: 28 },
  image: '/assets/forest.png',
})

const board = component.board({
  id: 'main-board',
  size: { width: 420, height: 280, thickness: 3 },
  image: '/assets/board.png',
})

const photoCard = component.card({
  id: 'pacific-heights',
  name: 'Pacific Heights',
  image: '/assets/pacific-heights.jpg',
  description: 'Pay $100. Keep this card until the end of your turn.',
})

const reference = component.card({
  id: 'reference-card',
  label: 'Turn summary',
  back: { color: '#111827' },
})

const die = component.die({ id: 'movement-die' })
const dieValue = dieValueSchema(die) // 1 | 2 | 3 | 4 | 5 | 6
const lastRoll = dieValue.nullable().default(null) // state field
const rolled = { value: dieValue } // event payload shape

const pawn = component.piece({
  id: 'red-pawn',
  color: '#dc2626',
})

All defaults are expanded into explicit serializable names, dimensions, faces, and die sides at definition time. A later manufacturing build therefore uses the exact same values that the renderer and playtest used. Cards, boards, and tokens accept color, image, label, and description directly for their front face; add back when needed. description prints body copy beneath the artwork on portrait components, so rules and effects remain part of the physical object when it is moved or inspected. image is a URL or data URI; the table renderer cover-fits it onto the face so a landscape JPEG on a portrait card crops instead of stretching. Portrait cards that also set color keep a Monopoly-style color bar — the photo is inset so the group color stays readable in a fan. Use face for an explicit front visual or faces for custom face names. These authoring forms are mutually exclusive and all normalize to the same named face map. Pieces and dice similarly accept color as shorthand for appearance.color; use appearance for image, label, or combined visual data.

const deck = defineCardDeck({
  id: 'numbers',
  copies: 2,
  cards: [
    { id: 'one', color: '#0369a1', label: '1', metadata: { value: 1 } },
    { id: 'two', color: '#7c3aed', label: '2', metadata: { value: 2 } },
  ],
})

const catalog = defineCatalog(deck)
deck.cardIds // readonly ('one' | 'two')[] in declaration order
const cardId = cardIdSchema(deck) // reusable exact runtime schema
const instances = deck.instances // four stable card instances
deck.back // anonymous renderer-only card using the shared size and artwork
resolveCard(deck, 'one') // typed authored definition, including metadata
resolveCard(deck, instances[0]!) // resolves instance.componentId or throws if foreign
const card = resolveCard(deck) // bind once for card-heavy rules
card(instances[0]!)

This prototype deck derives One/Two fronts, a Numbers back, and standard poker dimensions. Individual cards can override their name, front, copy count, tags, and metadata; the deck can override shared size, back artwork, default copies, and tags once. A game can use components: [board, deck, rules]; collections are flattened into plain, serializable component data at its boundary. Existing catalogs can be composed the same way. deck.back is compiled into that catalog for face-down or identity-hidden scene objects, so games do not repeat a fake card definition. It is marked manufacturing: false and therefore never adds a physical card to the deck recipe or bill of materials. deck.instances contains the frozen default logical inventory compiled from that exact recipe, with component IDs narrowed to the authored card union. deck.cardIds exposes that union as frozen serializable data in declaration order, while the pure cardIdSchema(deck) turns it into a reusable Zod boundary for events, state, results, or inputs. resolveCard(deck, idOrInstance) lets rules consume the same typed card metadata instead of maintaining a parallel component-id lookup table. Its one-argument form returns that same checked resolver bound to the deck, keeping repeated choice and scoring code concise without adding methods to the serializable deck definition. defineZones('deck', 'hand', 'discard') provides a state schema plus immutable find, move, moveAll, ordered draw, and deterministic shuffle operations, while enforcing that an instance exists in only one physical location. Its initial-deal helper removes index arithmetic:

const gameZones = defineZones(
  seats.zones('hand', 'played'),
  'drawPile',
  'discard',
)
const { hand: hands, played } = gameZones.groups
const emptyZones = gameZones.create() // every declared zone starts as a fresh []
const emptyZoneField = gameZones.default() // same value as a state-field default
const shuffled = random.shuffle(deck.instances)
const zones = gameZones.dealEach(
  shuffled,
  hands,
  2,
  'drawPile', // optional destination for every remaining card
)

hands.of('north') // 'north.hand', preserving the literal type
hands.get(zones, actor.seat) // that player's physical hand
hands.count(zones, actor.seat) // number of cards in that hand
hands.isEmpty(zones, actor.seat)
hands.allEmpty(zones) // true only when every player's hand is empty
played.oneEach(zones) // undefined, or { north: northCard, south: southCard }
gameZones.containsExactly(zones, deck.instances) // no missing or foreign cards

A one-name call still returns that group directly: seats.zones('hand'). With two or more names, the returned collection is itself a defineZones source. The composed zone definition retains either form’s typed groups under .groups, so defineZones(seats.zones('hand'), 'deck').groups.hand works without a parallel variable. The standalone defineSeatZones(seats, ...) form remains available. Both forms keep the per-seat zone list in one declaration without changing the normalized flat state keys or any zone operation. gameZones.create() initializes all of those keys with independent empty arrays; gameZones.default() supplies that same fresh value from the state schema when no setup is needed. Pass an object to create(...) when setup needs an explicitly populated state and the same strict schema validation.

Inside an action or rule update, use the draft-native operations so the SDK’s existing isolated copy is edited directly:

update(draft, { actor, input }) {
  gameZones.draft.move(
    draft.zones,
    input.instanceId,
    played.of(actor.seat),
  )

  if (played.allOccupied(draft.zones)) {
    gameZones.draft.moveAll(draft.zones, played.names, 'discard')
    gameZones.draft.drawEach(draft.zones, 'drawPile', hands)
  }
}

gameZones.move, moveAll, draw, and shuffle remain immutable operations for setup helpers or reusable calculations outside an update. Their gameZones.draft counterparts validate the same identities, destinations, counts, and indices, but avoid cloning and reassigning the complete zone map for each physical move. draw moves one item by default—or an explicit positive count—from the front of its source to the end of its destination, preserving order. Its draft form also returns the drawn instances for rules that immediately inspect them. An insufficient pile, invalid count, unknown zone, or identical source and destination fails before anything moves. drawEach accepts a typed zone group such as the hands generated by defineSeatZones, draws the same count to every destination in group order, and returns a destination-keyed record of the drawn instances. It validates the entire group and total required inventory before moving the first item, so a short deck cannot leave only some players replenished. The setup-side dealEach performs the parallel operation while constructing a complete initial zone state. It allocates in typed group order, clones each instance, and requires a remainder zone whenever cards are left. Use generic deal(items, counts, remainder?) when opening zone counts are asymmetric.

shuffle accepts the seeded random source from setup, an action, or a rule. It changes only the selected zone and preserves every instance. Validation happens before randomness is consumed, so a rejected transition cannot shift the replay cursor. Recycling a discard pile is ordinary game code:

if (draft.zones.drawPile.length === 0) {
  gameZones.draft.moveAll(draft.zones, 'discard', 'drawPile')
  gameZones.draft.shuffle(draft.zones, 'drawPile', random)
}
const [drawn] = gameZones.draft.draw(draft.zones, 'drawPile', hands.of(actor.seat))

moveAll accepts one source or an ordered list of sources; every source is likewise validated before the first item moves. Seat-zone groups expose count, isEmpty, allEmpty, and allOccupied, keeping common hand and simultaneous-play checks out of manual zone-name loops. oneEach returns a seat-keyed record once every owned zone contains exactly one value, or undefined while any seat has not committed. It rejects a zone containing multiple values, turning malformed simultaneous-selection state into a useful error instead of silently choosing an arbitrary item.

defineZones accepts individual names and authored zone groups in the same call, flattening them into one literal-typed state schema. Duplicate names are rejected after composition. containsExactly compares both the stable instance IDs and their component IDs, making physical inventory conservation a concise named game invariant:

const deckInstances = instantiate(deck)

invariants: {
  cardsConserved: ({ state }) =>
    gameZones.containsExactly(state.zones, deckInstances) ||
    'Every card must remain in exactly one physical zone',
}

Generic deal allocations follow the order passed to defineZones; dealEach follows its destination-group order. If cards remain and no remainder zone is supplied, or a requested count exceeds the deck, either deal fails rather than losing physical instances. zones.project(state, visibility, context) produces a safe view for every zone as { count, items }: hidden items become null, while counts remain public unless separately restricted. Unconfigured zones default to hidden items, so adding a zone does not accidentally expose its contents. A seat-zone group’s visibility() maps each zone to its owning seat; pass one audience selector or a per-seat callback to override that default.

The projected zone map can be returned directly; there is no need to copy it into separate hand arrays, count maps, or a duplicate view schema:

project({ state, audience, players }) {
  return {
    round: state.round,
    zones: gameZones.project(
      state.zones,
      {
        ...hands.visibility(),
        discard: visibleTo.everyone,
      },
      { audience, players },
    ),
  }
}

Each zone remains { count, items }, with null precisely marking fields the audience cannot inspect. This stable shape is convenient for generic clients and prevents UI-derived secrecy rules.

deckSet.instances is the shortest path for one default copy of a defineCardDeck result. Use instantiate(deckSet, options) for additional deck copies or a custom stable ID prefix; it validates the same compiled catalog. The lower-level instantiate(catalog, componentId, options?) overload remains available for catalog-selected components and dynamically chosen recipes.

The same catalog compiles into a physical bill of materials:

const artifact = compileManufacturing(game)
const plan = compileTheGameCrafter(artifact, currentTgcProducts)

Every component defaults to one manufactured copy. Use manufacturing: { quantity: 4 } on a definition for repeated physical parts, or manufacturing: false for a presentation-only object. Cards owned by a deck take their quantities from its recipe and are never counted twice. The artifact includes the game ID and version alongside the full normalized catalog, tying the physical output to the playable build.

The Game Crafter adapter takes a snapshot from the vendor’s live product-schema endpoint rather than embedding a stale product list. It resolves supported dimensions and emits an auditable creation plan with structured errors for missing artwork, unsupported parts, mismatches, and vendor limits. Uploading, proofing, and credentials remain host operations. See the @boardengine/manufacturing guide for the target contract.

visibleTo builds the same serializable audience selectors everywhere: everyone, seated players, none, or specific seats(...), teams(...), and playerIds(...). Hidden Card Duel uses these selectors for private hands, face-down played cards, and communication channels. The system audience can inspect all data for trusted tooling; spectators only match everyone.

defineChannels(...) makes communication part of deterministic game state. Channels declare separate read/write audiences for everyone, seated players, specific seats, teams, or player IDs. Fixed membership can come directly from the game’s defineTeams player contract. A literal ID is the safe shorthand for a players-only channel, with its display name derived from that ID. Any public or restricted audience remains visible in the definition:

const chat = defineChannels(
  'players',
  {
    id: 'table',
    name: 'Table talk',
    read: visibleTo.everyone,
    write: visibleTo.players,
  },
  { id: 'north-notes', read: visibleTo.seats('north') },
)

Object-form channel names are also derived when omitted. An omitted write audience inherits read; only authenticated game players can author messages in either case. chat.default() is a state-field schema that starts every session with its own empty message history, so setup does not repeat messages: chat.create(). Each channel set can build the conventional state.messages action directly. That action combines the typed input, permission guard, deterministic command ID, and draft update while leaving communication visible in normal game state. Pass an optional description when the default “Send an in-game message” is not appropriate:

state: { messages: chat.default() },

actions: ({ action }) => ({
  sendMessage: chat.action(action),
})

The lower-level sendInput, available, and send members remain available for games that store messages somewhere other than state.messages or add game-specific effects. Passing an action context to send uses commandId as the deterministic message ID; players do not submit a second identifier. post creates system messages, and project returns only the channels and messages visible to the current player or spectator. Hidden Card Duel demonstrates public, player-only, and team-private communication as normal game state.

Randomness is available through setup and action contexts. It is seeded and cursor-based, so sessions can be snapshotted and replayed exactly. Time is also explicit: hosts pass a monotonic logicalTime with commands rather than games reading the wall clock.

Physical dice and rule randomness share one definition. random.roll(die) selects one declared side, advances the deterministic cursor once, and returns { index, side, value }. value uses the side’s declared numeric value, or its one-based ordinal when omitted. Explicit side values remain literal TypeScript unions, and dieValueSchema(die) derives their reusable runtime schema. State, events, and action inputs can therefore validate the same outcomes without restating a numeric range. A default die produces the exact 1 | 2 | 3 | 4 | 5 | 6 value type; a custom side without an explicit value uses the ordinal fallback and widens its value type to number.

See examples/minimal-counter for the minimal public-state definition. See examples/tic-tac-toe for completion-aware projection and a clickable Three.js scene. See examples/hidden-card-duel for simultaneous choices and private player projections as well as shared card/deck authoring. See examples/dice-race for ordered turns, action-time random die rolls, and a declarative end condition. All three generate generic Three.js scenes and run through the same headless and Cloudflare room runtimes without game-specific infrastructure.

Finished card art, physical backs, and generic status

Use imageLayout: 'full-bleed' for finished artwork whose typography is already composed. The default framed behavior reserves a title stripe and optional rules panel. This is a visual choice, never a rule:

const events = defineCardDeck({
  id: 'events',
  back: { image: '/assets/events-back.svg', imageLayout: 'full-bleed' },
  cards: [{ id: 'sunrise', name: 'Sunrise', image: '/assets/sunrise.svg', description: 'Collect 2 coins.' }],
})

Three.js prints both physical surfaces: a visible front has its authored back underneath. Use events.back for a hidden stack; it is an anonymous component whose two sides reveal only the shared back. A face: 'back' object referencing a known card can be inspected from underneath, so it is not a substitute for a visibility-safe hidden projection.

component.piece also accepts shape: 'house' for a pitched-roof building and shape: 'standee' for a double-sided printed plaque on a base. Its size gives width, height, and depth in millimeters. These shapes are generic renderer primitives; no game-specific model handling is needed.

A scene may expose public status: { label, message, activeSeat?, stats: [{ label, value, active? }] }. Hosts show it without a custom presenter. Use guide for the current decision’s explanation. Both are serializable and available to headless agents, and neither enforces legality. The game actions remain authoritative.

The CLI serves public/ from the nearest game package, regardless of the shell’s working directory. Keep source assets and a reproducible distribution script with the game; a remote store can later serve the same logical asset URLs without changing rules.

Components as the player’s workspace

Keep the complete action vocabulary headless. Bind property- or card-specific choices to actions on a scene object, or sceneZone(..., { itemActions }) for a tableau. Omit action when a click should open a menu before committing. Visible, inspectable cards without an immediate action open a compact menu beside the click, including cards owned by another player. The piece stays on the table with a subtle lift; opening its action menu does not magnify it. Hold Command/Alt to inspect, or use the explicit Inspect menu item on touch devices. The host validates menu offers against the current legal action list; inspection and flipping never dispatch a game command.

Use action for an intentional immediate operation, such as collecting one spice or deploying one troop. Use actions for a purchase or target choice that should show a named command (such as Buy card) before committing, even when only one offer is legal. Do not infer immediate execution from the number of options. Right-click can still expose secondary choices on a piece with a primary action. Browser adapters use setMenuTarget for the local lift and the shared positionComponentMenu helper to keep menus within the visible table; presentObject is reserved for explicit inspection or an authored prompt subject.

itemDetail (or an object’s detail) supplies public inspection notes such as rent, cost, or the reason building is unavailable. Never include private deck information. itemLabel carries visible status such as a mortgage or building count. Grid zones accept rowSpacing independently of horizontal spacing, so portrait cards do not overlap. Sort projected items into the game’s natural groups before laying them out.

Seats accept matSize: { width, height }. Give the corresponding scene region a seat so camera locations can frame a player workspace via renderer.focusSeat(seat); Whole table restores the authored camera. Match the region and mat dimensions. Use a hotspot region when the seat already supplies physical felt. Give workspace components surface: { seat: player.seat } to rest them on the felt. Keep workspace rectangles clear of the shared board and one another. Rotate their components with the seat when seating players on opposite sides.

Objects can declare a physical support instead of guessing their center height:

defineScene({
  objects: [
    { id: 'board', component: board, surface: 'table' },
    { id: 'tile', component: tile, surface: { object: 'board' }, position: [1, 0, 0] },
    { component: pawn, surface: { object: 'tile' }, position: [1, 0, 0] },
  ],
})

With surface, x/z and rotation remain in table coordinates; position[1] is nonnegative clearance above the support (usually zero). The renderer raises the component’s bottom above the support’s top, accounting for geometry, rotation, scale, and visible pile height, plus a small separation to prevent flicker. Seat supports use the mat top. References may be nested and need not follow array order. Tilted supports use their bounding top; this is intended for tabletop layouts, not sloped contact simulation. A zero-count pile still has a ghost placeholder’s height. The rendered pile cap also caps its support height.

Omitting surface retains absolute center positions. Paths remain absolute world coordinates. Supports resolve final resting layout on every scene update; they do not attach x/z movement or yaw to the support or simulate objects riding a moving platform. Hovering a support does not lift it through its children. Use absolute positions for room physics and scripted rolls; combining those with surface placement is rejected. Missing support objects/seats, cyclic references, and duplicate object ids fail scene validation. Real geometry keeps depth, shadows, and picking consistent; there is no draw-order override or visibility change for hidden cards. Seats may also set handPosition: [x, y, z] in local world units to keep the resting hand fan clear of their tableau. It rotates with the seat; omitting it preserves the default [0, 0.055, 0.32]. Dune places its fan beyond the player edge of the mat. Use guide prose and look links to teach available interactions. Both browser hosts show a read-only Actions & rules directory; selecting an entry explains the move and offers links to its components, without submitting it. Main controls retain choices not already attached to components. Agents still receive every legal action.

Searching decks and discard piles

Use a projected sceneZone to make a pile searchable in both generic browser hosts. Players click an inspectable pile (or Browse piles), search names and visible rules text, then select a physical card to read it. Reading, filtering, minimizing, and Show on table never dispatch a command or reorder the pile. Duplicate copies remain distinct, ordered top first with their original positions shown.

sceneZone(view.zones.discard, {
  id: 'discard', label: 'Discard',
  anchor: { kind: 'table', position: [1, 0.08, 0] },
  layout: { kind: 'stack' }, back: cards.back,
  browse: 'all',
  itemDetail: item => card(item).rulesText,
  itemActions: item => [action('recover', item.id)],
})

Use itemActions when selecting a card should open its options before committing. The browser offers explicit buttons for current legal bindings, including action.pick and action.form, using the same actions as agents and the CLI. itemAction retains its immediate table-click behavior, with inspection available through Browse piles or the context menu. Illegal cards remain readable; their actions are omitted. Use itemDetail to explain eligibility. The game must guard actor, source zone, eligibility, and any recovery limit; use state and scene.resolution for required decisions.

browse: 'all' allows every projected item, 'top' allows only the final item in the array, and 'none' disables browsing. Empty public zones show an empty state. Hidden zones (items: null) never appear in the browser. Browsing permissions are not a secrecy boundary: project hidden identities out with defineZones.project before building the scene. For a deck-search effect, let an ordinary guarded action enter a search phase, project the permitted cards only to its actor, and remove that access when the effect resolves. Any required shuffle is also an authored seeded action or transition; closing the browser cannot perform it.

The browser uses each item’s face, then backFace, then front; it does not search other faces or private metadata. Put searchable rules text in the visible face label or itemDetail when the artwork is an image. Search and selection survive ordinary projection updates and minimizing; lost access or a changed viewer clears them, and a removed card loses its selection. Actions are rechecked against current offers.

examples/discard-recovery is a complete headless example with duplicate cards, ineligible cards, public observation, a hidden deck, guarded completion, and replay. Dune uses the same zone browser for discards and binds eligible recovery cards to its ordinary resolveEffect choices.

Custom hosts can mount ZoneBrowser from @boardengine/table-ui, update it with { scene, catalog, actions, context, disabled }, and call open(zoneId) from the renderer’s TableTarget.zoneId. Change context when changing viewer/session; set disabled during an in-flight command. Call dispose() on unmount. The SDK’s browsableZoneItems(zone) returns the permitted inspection subset for other adapters.

Game-defined hand settings

Set scene.handSettings to tune the viewing player’s screen-anchored hand in both browser hosts. These settings affect presentation only; scene.hand still contains exactly the cards that viewer is allowed to see. For a game whose bottom-of-card effects matter, Dune uses:

handSettings: {
  layout: 'row',
  cardHeight: 0.25,
  maxWidth: 0.9,
  spacing: 1.04,
  visibleFraction: 1,
  bottomInset: 0.018,
  hoverLift: 0.035,
  hoverScale: 0.08,
},
KnobMeaningDefault
layoutStraight row or curved fanfan
cardHeightPreferred card height / viewport height; preserves aspect ratioPhysical component size × 0.3
maxWidthMaximum hand width / viewport width0.66
spacingPreferred spacing in card widths; 1 exposes whole cards, 1.04 adds a gap0.72
visibleFractionPortion visible above the bottom edge at rest; 1 shows the bottom0.55
fanAngleMaximum total fan angle in radians; ignored for rows0.4
bottomInsetBottom margin / viewport height0.015
hoverLiftHover lift / viewport height0.16 camera-plane units
hoverScaleExtra hover scale; 0.08 enlarges eight percent0.09

All knobs are optional and validated by handSettingsSchema (exported with HandSettings from the SDK). Size and width fractions are relative to the renderer’s canvas, so they respond to resizing and are unaffected by orbiting the table. The renderer reduces spacing when the hand exceeds maxWidth. It keeps every card in order with an exposed edge instead of dropping cards or shrinking the whole hand to unreadable thumbnails. A card wider than the available area is scaled to fit. Hover brings the selected card in front and raises it enough to reveal its bottom, within the viewport. Card inspection and actions stay available. When an explicit card height is supplied, required-decision panels reserve the visible hand area below them instead of covering the cards. Empty hands release that space again.

These settings are distinct from seat.handPosition, which positions physical cards on the table near a companion. Hidden opponents’ hands still use anonymous backs and are unaffected by the viewing player’s screen layout.

Zod inputs as editable forms

Use action.form('deployTroops') wherever a scene accepts an action to edit an input instead of rendering one button per legal value. The fields come directly from the action’s Zod input schema; .describe() supplies a label. For example:

// In actions: ({ action }) => ({ ... })
deployTroops: action({
  input: z.number().int().min(1).max(12).describe('Number of troops'),
  choices: ({ state }) => Array.from({ length: state.deployable }, (_, i) => i + 1),
  update: (draft, { input }) => {
    draft.deployable -= input
    draft.combat += input
  },
}),
// In withScene((view, action) => ({ ... })):
resolution: {
  id: 'deploy', title: 'Deploy troops', message: 'Choose how many to commit.',
  controls: [{ label: 'Deploy', action: action.form('deployTroops') }],
},

Forms appear inline in required decisions; component-bound forms open after the player clicks that component’s action. action.form('send', { space: 'north' }) can pin object fields while leaving the remaining fields editable. A scalar input remains scalar in the command, so adding a form does not change replay data.

Forms support numbers/integers, strings, booleans, scalar enums, select schemas, and flat objects with required or optional fields. Other arrays and nested objects fail with an authoring error; use explicit selection schemas or component-choice interactions for those. Forms require finite choices, which remain fully discoverable to agents. Guards and the original Zod schema still validate the submitted command. The runtime replaces any authored form inputs with only the current audience’s legal inputs; spectators and other unauthorized players receive none. Client forms validate exact combinations, not a Cartesian product of field values. Numeric ranges follow current choices, and stale edits become invalid instead of silently changing the selection. Scalar quantity fields initially select the lowest legal number, independent of choice ordering; include zero in both the schema and choices when doing nothing is legal. Editing or opening a form never commits a command; even one remaining value needs an explicit submit. The shared ActionInputFormView implements this in both hosts.

Selecting several options

Use select to define a bounded set of distinct option IDs once for headless validation and generic UI. It provides a Zod schema and a choices() helper; action.form(...) renders a standalone selection in the centered ChoiceTray: a pool of cards or tiles, selected-piece slots, optional extra slots, inspection, minimization, and an explicit confirmation button. Both generic hosts share this UI.

import { select } from '@tabletopwithfriends/sdk'

const factions = select({
  options: [
    { value: 'emperor', label: 'Emperor' },
    { value: 'guild', label: 'Spacing Guild' },
    { value: 'bene', label: 'Bene Gesserit' },
    { value: 'fremen', label: 'Fremen' },
  ],
  min: 2,
  max: 2,
})

// In actions: ({ action }) => ({ ... })
chooseFactions: action({
  input: factions.schema.describe('Secret factions'),
  available: ({ state, actor }) => state.decidingSeat === actor.seat,
  choices: () => factions.choices(),
  update: (draft, { input }) => {
    draft.factions = input
    draft.decidingSeat = null
  },
}),
// In a scene resolution:
controls: [{ label: 'Confirm factions', action: action.form('chooseFactions') }],

An option can be a string ID or { value, label, description?, visual?, kind? }. visual uses the SDK’s existing face description (image, color, etc.). For physical cards, explicitly supply the public face, e.g. visual: ticketCard.faces.front; factions can supply their symbol image with kind: 'token'. The default kind: 'card' uses card faces and card slots; tokens use contained art, separate name labels, and round selection slots. Only author art the deciding audience is allowed to see. The UI never reads hidden state or guesses a secret card’s front. Labels and descriptions remain available for inspection and accessibility. IDs are unique; repeated physical cards need distinct instance IDs. Labels and descriptions are plain text. min: 1, max: 1 defines a single choice; min: 2, max: 3 makes the third optional; min: 0 allows confirming an empty selection. Every selection starts empty, including a decision with only one legal combination. Editing and minimizing never commit actions. Click available pieces to add them; click selected pieces to put them back. Hold Alt/Option or Command/Ctrl to inspect a face at full size. Buttons support keyboard activation; only the confirmation button submits a complete legal selection, in canonical authored option order.

For changing offers, define the schema over the possible IDs, then narrow the choices using state. Ticket to Ride uses a zero-to-three schema and:

choices: ({ state, actor }) => tickets.choices({
  from: state.pendingTickets[actor.seat],
  min: Math.max(0, requiredToKeep - state.alreadyKept[actor.seat]),
}),

from must contain distinct authored IDs; min and max overrides must narrow the schema bounds. An offer too small to meet the minimum has no legal choices. Additional action guards can exclude combinations. Choices remain exhaustive finite inputs for agents, CLI inspection, simulation, and replay; this helper is intended for small board-game offers, and enumerating large subsets can be expensive. Headless callers use the canonical arrays returned by choices().

The runtime derives each field’s current bounds and visible options from the viewing player’s legal inputs. Other players and spectators get no selectable private options. The schema validates IDs, uniqueness, and count; finite choices and guards enforce the offer and actor. The UI checks the whole submitted input, including interactions between fields, and never treats counts alone as authority. Stale drafts stay invalid until edited, with removed options available to deselect. Use a distinct resolution.id for each new decision or responding player to reset local edits. An action can also use a selection as a required or optional field inside a flat object, alongside numbers, enums, and pinned component context; these mixed forms retain their inline field layout.

Masterstroke and Ticket to Ride demonstrate this contract through the same ActionInputFormView in the web playground and CLI table, without game-specific UI.

Camera locations and the action directory

A game can author its own camera stops and a small reference for families of actions:

locations: [
  { id: 'board', label: 'Board', shortcut: '1', focus: { kind: 'target', id: 'board' } },
  { id: 'alice', label: 'Alice', shortcut: '2', focus: { kind: 'seat', seat: 'alice' } },
  { id: 'whole-table', label: 'Whole table', shortcut: 'mod+1', focus: { kind: 'overview' } },
],
actionGuide: [{
  path: ['Your turn', 'Claim a route'],
  actionTypes: ['claimRoute'],
  description: 'Select an open route, then choose which train cards pay for it.',
  focus: { kind: 'target', id: 'board' },
}],

locations is ordered. Each stop has a unique id, a label, an optional shortcut, and a focus. Focus may name a scene object, region, or zone (target), a seat’s workspace (seat), the authored home view (overview), or an exact camera pose (camera, with position and target vectors). A seat destination requires a region with that seat. Camera locations never reveal or flip cards or change game state. Use an exact pose when framing a broad area such as several decks together.

Shortcuts are 19 or mod+1mod+9; mod means Command on macOS or Ctrl on other systems. Duplicate ids and bindings are rejected. Hosts ignore shortcuts while typing, composing text, holding Shift/Alt, or repeating a held key. Browser-reserved modifier shortcuts may not reach the page; every destination also has a visible button.

actionGuide paths form folders with an explanatory leaf. Entries remain visible even when unavailable, so players can learn future moves. One entry may describe multiple command types. actionDirectory(scene, actions, catalog) derives destinations from legal scene bindings, collapses repeated segments and payment variants, and provides fallback explanations for undocumented action types. Author partial action.pick bindings to represent all payments for one route as one target. Both hosts suppress fallback buttons for legal completions of those bindings, including picks in required resolution controls. Opening a picker temporarily yields the resolution panel; closing it restores the pending decision. Named components keep their names in the directory, with floating status labels appended; an explicit tooltip overrides the combined label. Hidden zone items are never enumerated. The directory returns explanations and camera targets, not commands.

The shared TableNavigator in @boardengine/table-ui mounts into separate Places and Rules & actions containers: new TableNavigator(placesElement, actionsElement, callbacks). Places stays on the left. Rules & actions opens a resizable rulebook with a folder sidebar, full-text search, All / Available / Rules filters, browsing history, and an independent reading pane. The same browser works in both hosts. Its host should use z-index: var(--be-rulebook-layer, 28); the shared widget raises that layer while the book is open so required-decision controls cannot cover its pages or intercept clicks. Closing the book restores the normal stacking order.

Use an empty actionTypes: [] for a reference page rather than an available move. Optional sections: [{ title, body }] add longer explanations, examples, or house rules. Optional relatedPaths: [['Properties', 'Build']] links to other pages; if omitted, the browser suggests siblings in the same top-level folder. Search covers these sections and the names of related table pieces. Keep all prose public and projected for the viewer. These are plain TypeScript data, not UI components or rules that replace the game’s action validation.

Reading a page never moves the camera or submits an action. Show on table or a piece link focuses the camera and minimizes the book, preserving the selected page for reopening. Repeated route segments and alternative payments stay grouped under one route. The only callbacks are onFocus and onHighlight; TabletopRenderer.navigate(focus) and highlightActionTypes(types) implement them without knowing game identities or rules. Headless play and replay remain unchanged.

Shared camera moments

Author scene.cameraCue when a public action should draw everyone’s attention:

cameraCue: {
  id: view.lastRevealCommandId,
  focus: { kind: 'target', id: 'revealed-cards' },
  holdMs: 2500,
},

Use the transition’s commandId (or another stable occurrence ID) and retain it in serializable game state. Project the same cue to everyone who should see that moment. SceneCameraCue and sceneCameraCueSchema are exported by the SDK. Cues accept any SceneFocus: target, seat, overview, or an explicit camera pose. Cues preserve each viewer’s bearing by default, including when using an explicit pose: its target and distance determine framing without rotating around the table. Set preserveBearing: false only for a deliberately authored shared angle. The renderer frames only the audience’s projected scene.

Both browser hosts follow a new cue once, after updating the table objects. The first projection establishes a baseline: attaching or reconnecting does not replay an old moment. After arriving, the camera holds for holdMs (default 2500ms, range 0–10000ms) and smoothly returns to the viewer’s previous view. Repeated updates with the same ID never restart the trip or extend the hold. Orbiting, zooming, or choosing a destination cancels both the focus and its scheduled return. A newer cue replaces the current moment while retaining the original return view. Reduced-motion viewers skip the automatic camera trip. Cues are presentation only; they never execute an action, hold up a turn, or reveal private cards. Use a new ID for each occurrence, including a second placement on the same space. An absent cue does not reset the remembered ID.

Dune briefly frames newly revealed public cards and agent placements, while its Buy cards control holds the market view until buying ends, then returns to the view from before reveal. Manual camera movement overrides that return.

Piece placement motion

Give a scene object a motion descriptor to show its journey to its authoritative position, followed by an optional landing pulse:

motion: {
  id: view.lastPlacementCommandId,
  from: 'reserve-worker-2',
  durationMs: 1200,
  arcHeight: 0.7,
  arrival: 'pulse',
},

SceneMotion and sceneMotionSchema are exported by the SDK. from references an object in the preceding projected scene; omit it to move the same object from its previous position. This also handles a reserve piece replaced by a new object ID at its destination. When that source disappears, it is replaced by the flying piece without a second removal animation. The source’s transform is captured before the update, so scene ordering does not change the journey. Only reference sources visible to the receiving audience.

The default journey takes 900ms with a 0.5-unit arc; durationMs accepts 100–5000ms and arcHeight accepts 0–5 table units. arrival: 'pulse' adds a brief gold ring and piece glow on landing. Each new ID plays once; ordinary updates do not restart it. Attach/reconnect, missing sources, and reduced-motion viewers settle directly at the current position. Persist the occurrence ID in game state for replay, as with camera cues. Animations never delay game actions or determine rules outcomes. Combine motion and a camera cue on the same command to show the placement clearly.

Drawing, shuffling, and turning cards

Use the same deal descriptor on a table object or an entry in scene.hand:

hand: privateCards.map(card => ({
  id: card.id,
  component: card.component,
  deal: { from: 'draw-deck', flip: true },
})),

from names a projected deck or other object. A new card travels from that source to its position in the hand, turning from back to front when flip is true. The endpoint follows camera movement and hand layout changes during the flight. Multiple cards are staggered. Missing sources settle normally; initial attachment, reconnecting, and switching viewers never replay old draws. Keep an empty deck object with pile.count: 0 so its physical source remains available for refills.

When moving a card out of a hand, use deal: { fromHand: seat } instead of from. The owner sees it leave their actual hand; other viewers see it leave that seat’s public fan. SceneDeal and sceneDealSchema are exported by the SDK.

For other players’ draws, declare handDeal: { from: 'their-deck' } on their scene.seats entry alongside handCount and handComponent. An increase in handCount deals anonymous backs into that seat’s fan. Never supply private faces or identifiers for other players’ hands; these flights use only the authored back component. The owner’s scene.hand controls their own visible cards. Hosts should call renderer.setViewerSeat(seat) before renderer.update(scene, catalog) so an audience switch establishes a baseline before processing new card moments.

A changed pile.shuffled serial plays the physical stack shuffle once. Keep this serial in game state and increment it when your seeded rules shuffle that pile. When the same update draws from it, dealing waits for the shuffle to finish. Repeated projections don’t restart either animation. If a card already on the table changes face (or switches from a back proxy to a public card component), the renderer lifts and turns it over. A replaced component with deal instead arrives from its source, allowing fixed market slots. Inspection’s Turn card over also animates without changing game state.

The renderer emits card-draw, card-shuffle, and card-flip feedback as those moments play, on every connected client. Both generic hosts use bundled CC0 card recordings through CardSoundPlayer from @boardengine/table-ui; sounds follow the mute setting and browser autoplay policy. UI command acknowledgement doesn’t guess card sounds from action names. Reduced-motion viewers settle cards directly. These descriptors remain presentation only: rules, hidden information, seeded randomness, and replay stay entirely in the game module.

Primary actions and table activities

Use scene.controls for the few actions that drive the current turn. Both generic hosts show these controls in a centered action bar above the hand. Keep required effect choices in scene.resolution, and put purchases on the cards themselves.

controls: [
  {
    id: 'buy-cards', label: 'Buy cards',
    focus: { kind: 'target', id: 'market' },
    actionTypes: ['buyCard'],
  },
  {
    id: 'finish', label: 'Finish reveal', action: action('finishReveal'),
    unavailableReason: 'Resolve the remaining effects first.',
  },
],

Every control needs a unique id, a label, and an action or focus. A focus-only control enters a local camera activity: the view stays focused until that control disappears, becomes disabled, or the turn/viewer changes. Keep its id stable while the activity is available. Finishing returns smoothly to the view from before entry; clicking cards and purchasing do not cancel that return. Orbiting, panning, zooming, resetting, or choosing another ordinary camera destination overrides it. Re-entering or switching activities preserves the original return point; entering during a shared camera moment uses the view from before that moment. Shared cues can briefly interrupt an activity and return to it. If the activity ends during a cue, its original return view wins.

Custom adapters can use TabletopRenderer.beginCameraActivity(id, focus) and endCameraActivity(id) for the same lifecycle; navigate(focus) is an ordinary user navigation that overrides any activity. None of these dispatch game commands.

actionTypes highlights components that offer those actions; it neither enables actions nor restricts headless play. An action control may also supply focus to frame its destination when invoked. The runtime recomputes enabled for each audience from current action guards; blocked controls remain visible with unavailableReason. sceneBoundActions includes these bindings so fallback controls do not repeat them.

Use the existing counter on a physical scene object for a resource display:

{ id: 'buying-power', component: resourceMarker, position: [0, 0, -4],
  label: 'Persuasion available', counter: { value: view.persuasion, display: 'surface' } }

display: 'surface' prints a large value on the component’s top face; omit it for the existing floating label. An optional icon URL places a bundled symbol beside the number on surface counters; missing artwork falls back to the label and value. The number stays on the table and animates when it changes. The game supplies the value; the renderer never computes budgets or affordability.

Automatic gains and activity feedback

Use SDK rules to collect authored deterministic effects in the same atomic transition as the player’s action. Do not auto-click a legal option in a browser or infer that one remaining option must be safe to execute. A draw, cost, or conditional effect may still have strategically meaningful timing.

Emit structured domain events from actions and rules, then provide narrate to turn public events into short feedback for logs or optional host interfaces. The generic browser hosts leave the activity overlay disabled to keep the table clear; narration remains available in the runtime and network room history.

events: { gained: { amount: z.number(), resource: z.string() } },
narrate(event, { actor }) {
  return `${actor.name ?? actor.seat}: +${event.payload.amount} ${event.payload.resource}`
},
// Inside an action or automatic rule with the named event builder:
// return event('gained', { amount: 2, resource: 'persuasion' })

Narration is public: never include hidden card identities or secret decisions. Events are validated and replayed headlessly; UI updates do not execute effects. Dune demonstrates automatic unconditional resource/dagger gains while preserving conditional effects, draws, recruitment, and paid or optional decisions. Its purchases report their card, destination, and resource changes. The web host retains room narration, and local tooling can read command results and narration from the runtime.

Required resolutions

Use ordinary state and action guards to pause a game for work that takes several commands: raising money, discarding down to a hand limit, choosing casualties, or preparing resources. Keep the continuation in serializable state so save/restore, network hosts, and agents resume the same decision. The responsible player may be different from the turn owner; authorize that player in the guards.

scene.resolution presents the decision even while its completion action is unavailable. The SDK runtime checks each control against the normal action guards for that audience and supplies enabled; it ignores authored enablement. Legal commands remain in projection.actions, while disabled controls and progress stay inspectable in projection.scene.resolution. Dispatch revalidates as usual. Both browser hosts use the same generic resolution widget.

// Inside withScene(view, action), when a decision is pending:
resolution: {
  id: 'prepare-resources',
  title: 'Prepare your resources',
  message: 'Use the pieces on your mat, then resolve.',
  requirements: [{ label: `${view.ready}/2 resources ready`, met: view.ready >= 2 }],
  focus: { kind: 'seat', seat: view.responsibleSeat },
  controls: [
    { label: 'Resolve', action: action('resolve'), unavailableReason: 'Prepare two resources first.' },
    { label: 'Give up', action: action('surrender') },
  ],
}

The matching resolve action must guard the responsible player, pending state, and resource requirement. Preparing resources does not resolve automatically; the player must invoke resolve. Remove the resolution when that action completes. Requirements are explanatory data, not rules, and the descriptor itself does not block unrelated actions: the game’s guards must enforce the pause.

Alternatives are ordinary actions, not special engine behavior. A game may omit surrender, always allow it, or guard it with its own solvency calculation. The SDK does not attempt to solve a game’s decision tree. Monopoly’s reference implementation queues payments, reserves the interrupted jail roll when needed, and exposes settleDebt / declareBankruptcy; its agent chooses financing through the same legal commands as a person. The solvency check operates on a copy, respecting even building sales and the finite house supply, without changing the player’s assets.

Complex effect ordering and automated opponents

examples/dune-imperium models the original base game through ordinary SDK actions. Its pending effects store an owner, source, and operation in game state. action.choose exposes valid effects and costs; mandatory opponent responses take priority; completion actions stay guarded until all decisions resolve. The scene’s resolution mirrors these guards with disabled completion controls. Purchasing can interleave with reveal effects because both are validated against the current state.

House Hagal rivals are game-state actors controlled by a seated human’s seeded actions. The effect recipient and the authorized human are explicit and separate, so an automated house does not require a synthetic network login. Private projections omit opponents’ card identities, deck order, and secret choices, while the scene renders anonymous backs. Inventory invariants and seeded full-game/replay tests cover the same actions used by the browser and CLI.

Hosted AI players

The Cloudflare example host can seat an AI player from the generic party lobby. It uses the same projected view and legal actions as a human, selects an action, and submits it through normal validated dispatch with an expected revision. It never receives the system snapshot, RNG seed, or another player’s projection. No AI dependency or callback is required in a game module.

Game source under src/ and the public README are bundled for that release into a read-only /game filesystem. The host adds /game/turn.json with only that AI player’s current view and indexed legal actions. The agent can explore these files with just-bash, and optionally run JavaScript analysis in Cloudflare Dynamic Workers. Source files must describe rules, not embed private live game data. Provide a seeded unattended action alongside explicit physical outcomes (see Dice Race’s rollDice); the AI host excludes commands reserved for physical settlement. Required decisions and off-turn responders still come from action guards, not inferred turn ownership. See host configuration.

Physical resolution instructions

Use scene.resolution.steps to explain unfinished work without enumerating its legal actions in the sidebar. Each step has a stable id, a label, an instruction, optional complete, and optional locations containing a label and SceneFocus. The shared panel shows a waiting indicator and navigation links. Clicking a link only moves the camera. Resolve through the guarded actions on the relevant cards, decks, resources, or other components:

resolution: {
  id: 'trash-card', title: 'Resolve Selective Breeding',
  message: 'Choose a card to trash, then draw two cards.',
  steps: [{
    id: 'choose-card', label: 'Trash a card',
    instruction: 'Use an eligible card in your hand, discard, played, or revealed area.',
    locations: [
      { label: 'Discard pile', focus: { kind: 'target', id: 'my-discard' } },
      { label: 'Played cards', focus: { kind: 'target', id: 'my-played-area' } },
    ],
  }],
  controls: [],
}

Attach action.pick('resolveEffect', { value: card.id }) to eligible cards, or attach the exact action when it is known. Runtime guards filter these bindings for the current viewer. The pile browser exposes a selected card’s concrete legal commands directly; declared input forms still open their form. Keep rare explicit confirmations, cancellation, and genuine selection forms in controls. Existing resolutions with controls continue to work. No required action is authorized by an instruction, spinner, or completion indicator.

When the remaining choice is a physical component, a source piece can offer one navigation entry instead of a list of all legal completions:

action.pick('resolveEffect', { task: view.task.id }, {
  onTable: {
    label: 'Choose a card from the row',
    focus: { kind: 'target', id: 'market' },
  },
})

onTable is optional metadata for a finite pick. Clicking it navigates only, even when just one legal choice remains. Bind the concrete choices to the actual cards (for example action.pick('resolveEffect', { value: card.id })), and use a resolution step to explain the pending decision and any separate decline action. Navigation neither adds game state nor resolves the decision; the existing guarded action remains available to headless clients. Use action.form selection fields when the player needs a collected multi-selection with confirmation.

Prefer targets on the table when the choice names an existing card, space, or piece. For unordered groups, choice(..., { components: [...] }) can reuse the component selection tray (for example, select two faction symbols). Keep short menus for abstract alternatives or a target’s few payment methods. This is an authoring preference, not a renderer heuristic based on option counts.

The shared ResolutionView.setObscured(true) temporarily yields to inspection without discarding its state. Both browser hosts restore the instructions when inspection closes; renderer adapters can use onInspectionChange for magnified components and combine it with their own component-menu visibility.

Rules includes a read-only Legal actions tab with each current command’s type and JSON input. The same complete choices remain available headlessly through projection.actions; instructions do not reduce machine discoverability.

For a resolution with one useful destination, author resolution.autoFocus:

autoFocus: {
  id: `payment-${view.payment.id}`,
  focus: { kind: 'target', id: 'my-spice' },
},

The renderer focuses once when this occurrence appears, including when resuming an unfinished resolution. Keep its ID stable across partial payments or other progress updates. Removing it (or the resolution) restores the prior view unless the player has navigated manually. Changing its ID starts a new focus request. Project it only to the relevant viewer; Dune focuses the payer’s resource pile, without moving other players’ cameras. Reduced-motion viewers skip this automatic movement. Keep the step’s navigation link available for revisiting the pile. This is presentation help only: it never pays, selects a card, or changes rules.

For an optional repeated operation, bind a concrete unit action directly to the piece: action: action('deployTroops', 1). Normal clicks execute that action; actions: [action.form('deployTroops')] can retain a secondary context form. Recompute the remaining allowance in the scene’s instructions after each command. Put a step’s Finish action in its own steps[].controls. Finishing one step must update only that decision in game state; unrelated required effects and parent turn completion keep their own guards. Use Finish for open-ended optional work such as incremental deployment, including stopping with zero work. A one-shot choice completes through its target action; use Skip or Decline for its optional alternative. Required effects complete when their authored conditions are satisfied. Add a separate completion button only when explicit confirmation or settlement serves the game; do not add Finish merely because a resolution exists. The runtime validates these controls for each audience and exposes them through sceneBoundActions, just like top-level resolution controls.

steps: [{
  id: 'deployment', label: `Deploy up to ${view.remaining} troops (optional)`,
  instruction: 'Click a troop to deploy one, or Finish to keep the rest.',
  controls: [{ label: 'Finish', action: action('finishDeployment'),
    keepVisibleWhenMinimized: true }],
}],

keepVisibleWhenMinimized keeps the control and its step label reachable after collapsing instructions or following a camera link on mobile. Keep the step present at zero allowance until its Finish command closes it. In Dune, closing deployment preserves the current turn and pending effects. Later recruitment can open a new deployment opportunity for only the newly recruited troops. Whole-turn completion remains a separate End agent turn action.

Counted resource piles and partial payments

A physical resource pile is an ordinary scene object with resourcePile:

{ id: 'my-spice', component: spiceToken, position: [1, 0, 0],
  surface: { seat: view.you },
  resourcePile: { amount: view.spice, label: 'Spice' },
  action: action('payOneSpice') }

SceneResourcePile / sceneResourcePileSchema describes a nonnegative integer amount, a label, and an optional bundled icon. The renderer stacks tokens, prints the count, and leaves an empty marker at zero. It does not deduct resources or infer prices. Use ordinary guarded actions for spending, moving, or gaining units. Counts may be stored as numbers in any game-owned inventory.

The SDK provides Payment, paymentSchema, createPayment(required), paymentRemaining(payment), canPayFromPile(balance, payment, amount = 1), and payFromPile(balance, payment, amount = 1). The last function returns a new { balance, payment }, rejecting insufficient resources, nonpositive or fractional amounts, and overpayment. Store payment progress with the responsible player and any interrupted continuation in serializable game state:

const progress = createPayment(2) // { required: 2, paid: 0 }
const first = payFromPile(1, progress) // balance 0, paid 1
const restored = paymentSchema.parse(JSON.parse(JSON.stringify(first.payment)))
const fundedBalance = first.balance + 2
const last = payFromPile(fundedBalance, restored) // balance 1, paid 2
// Another payment throws. Resume the reserved effect exactly once here.

Action guards must authorize the actor and pending payment, as well as calling canPayFromPile. Deduct resources immediately into the payment’s progress; do not charge the full cost again on completion. Keep the continuation paused until the required amount is paid. Games decide which financing actions remain available, whether a reservation can be canceled/refunded, and how reserved cards or pieces are protected. The SDK does not choose financing or strategy for a player.

Named mat sections

scene.sections paints labeled borders on a table or seat’s mat. Sections accept id, label, anchor (the same table/seat anchor as zones), size, optional color, and optional slots (1–32 evenly spaced printed slots). Section IDs work as ordinary camera targets:

sections: [{
  id: 'my-played-area', label: 'PLAYED CARDS',
  anchor: { kind: 'seat', seat: view.you, offset: [0, 0.07, 1] },
  size: { width: 5, height: 1.2 }, slots: 6,
}],

A section is a physical guide, independent of occupancy. Place ordinary objects inside it or anchor a sceneZone row/grid to the same area. Slot outlines do not move cards or enforce capacity; rules and zone state do that. Sections can stay visible when empty. Keep private holdings projected out as usual.

Effect labels on physical components

Author scene.effectCues for short, ordered feedback at the actual card or pile:

effectCues: [
  { id: 'reveal-42-card-a', target: 'card-a', amount: 1, label: 'persuasion',
    icon: '/assets/my-game/persuasion.png', delayMs: 800 },
  { id: 'reveal-42-card-b', target: 'card-b', amount: 1, label: 'dagger', delayMs: 1400 },
  { id: 'payment-43', target: 'my-spice', amount: -1, label: 'spice' },
],

SceneEffectCue / sceneEffectCueSchema uses a stable occurrence id, projected object target, numeric amount, label, optional bundled icon, and optional delayMs (0–10000). Labels follow the component as it moves, then fade. Retain a bounded recent list (up to 128 cues per scene); identical IDs never replay on a normal update. Initial connection and viewer changes establish a baseline without replaying history. Missing targets are skipped; cues never reveal a hidden face. Set counter.feedback: false when these cues already explain a counter change. Automatic effects must still resolve in game rules, not animation callbacks. Animation and sound never block a headless command or authorize a payment.

Deploying an external game repository

A deployed game still imports only @tabletopwithfriends/sdk. Install @tabletopwithfriends/cli as a development tool and check in boardengine.json with explicit visibility, the TypeScript entry, and the public asset directory. Run twf login, then twf deploy. See platform deployments for packaging, authentication, R2, and compatibility behavior.

The CLI bundles the game’s installed SDK and engine into the immutable executor. Compatible SDK copies share the labeled-choice marker so a separately installed CLI can inspect and execute a game’s choices. An engine update requires a new game deployment; existing rooms retain their executor and snapshot.

Dune now lives in Ben2W/dune-imperium, a private standalone repository. The optional examples/dune-imperium submodule pins it for renderer regression tests; it is not a dependency of the hosted website.

Tabletop with Friends Make something worth playing.