@tabletopwithfriends/sdk
v0.1.2
Published
TypeScript SDK for complete, headless tabletop games
Downloads
20
Readme
@tabletopwithfriends/sdk
Build complete tabletop games in TypeScript. The same rules run headlessly, in tests, and on the browser table.
npm install --save-exact @tabletopwithfriends/[email protected]Documentation · Version compatibility
Game repositories pin their SDK. The CLI loads that installed SDK for rules execution and worker builds. Older npm versions and their documentation remain available; existing deployed rooms retain their immutable executor and assets.
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 testThe 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 // 1This 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 actionsFor 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 traceTo 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.recordsEvery 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.tsSuccessful 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 eventsThe 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 simulationHosts 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 directionsAll 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
han