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,
},
| Knob | Meaning | Default |
|---|---|---|
layout | Straight row or curved fan | fan |
cardHeight | Preferred card height / viewport height; preserves aspect ratio | Physical component size × 0.3 |
maxWidth | Maximum hand width / viewport width | 0.66 |
spacing | Preferred spacing in card widths; 1 exposes whole cards, 1.04 adds a gap | 0.72 |
visibleFraction | Portion visible above the bottom edge at rest; 1 shows the bottom | 0.55 |
fanAngle | Maximum total fan angle in radians; ignored for rows | 0.4 |
bottomInset | Bottom margin / viewport height | 0.015 |
hoverLift | Hover lift / viewport height | 0.16 camera-plane units |
hoverScale | Extra hover scale; 0.08 enlarges eight percent | 0.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 1–9 or mod+1–mod+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.