npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 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
han