Game contract
What Motif requires of a chess game state object. This is the minimal surface a “Game” must expose for Motif’s data-bound components to work; satisfy it and Motif composes with your Game.
Tabia is the canonical implementation. Tabia is also the canonical data layer of the PROMOTE stack — alternative game implementations are uncommon by design, but the contract is written down so Motif components stay duck-typed and so the surface stays honest about what Tabia owes its consumers.
Philosophy
Section titled “Philosophy”The game contract is chess semantics only. Move history, current position, side to move, NAGs, comments, navigation, annotations, metadata — everything that a chess GUI considers “the game” lives here.
No pixels, no DOM, no viewport coordinates, no animation. Anything geometric belongs in the renderer contract.
The two contracts are about categorically different things; Motif components ask one or the other (or both, never ambiguously).
This is also the wedge for OpenFile (multi-user collaboration) and for future variant support. If chess semantics flow exclusively through the Game, then:
- OpenFile syncs Game state across users; nothing else changes.
- A pluggable legality engine (chess.js → chessops → custom) is a Game-internal swap; consumers don’t see it.
The narrower the contract, the easier these futures become.
The contract
Section titled “The contract”A Game is an object — Motif components don’t care how it was constructed (PGN parse, FEN seed, multi-source merge, etc.) — that exposes the methods below. Almost all methods are synchronous reads or pure mutations; consumers re-read after mutating to get fresh state.
Position
Section titled “Position”getCurrentFen(): stringFull 6-field FEN of the current position. Includes board, side to move, castling rights, en passant target, halfmove clock, fullmove number — the canonical complete-position representation.
Used by: FenArea, PositionSetup (initial-state seed).
Game tree
Section titled “Game tree”getNodes(): Node[]Flat array of all nodes in the move tree. Indexed by node id. Tree
structure is encoded by parent / child references between nodes
(parentId, mainChild, children).
getCurrentNodeId(): numberId of the node the game cursor is currently positioned at. 0 is
the root (starting position, before any move).
Node shape
Section titled “Node shape”The objects returned by getNodes() have the following observable
properties. Implementations may include additional fields (Tabia
attaches comment_segments, variationOwner, etc. for the merged-
games case); consumers should ignore unknown fields.
| Field | Type | Notes |
|---|---|---|
id | number | Stable within the game’s lifetime; 0 is root. |
parentId | number | -1 for root; otherwise the parent node’s id. |
fen | string | Full FEN of the position after this node’s move (root: starting FEN). |
san | string | null | The move that led to this position. null only on the root. |
from | string | null | Origin square (algebraic). null on root. |
to | string | null | Destination square. null on root. |
ply | number | Halfmove count from root. 0 on root, 1 on White’s first move, etc. |
mainChild | number | null | Id of this node’s mainline successor, or null if leaf. |
children | number[] | Ids of all successors (mainline first, then variations in order). |
comment | string | null | Free-text annotation, if any. |
nags | number[] | null | Numeric Annotation Glyph codes (e.g. [1] for !, [14] for ±). |
annotations | object | null | Renderer-agnostic decorations. arrows: string[] of ${color}${from}${to} codes (e.g. 'Gd2d4'). squares: string[] of ${color}${sq} codes (e.g. 'Re4'). Conventional G/R/B/Y palette per the PGN [%cal]/[%csl] standard. |
deleted | boolean? | Soft-delete flag. Consumers filter these out of display. |
Used by: MoveList (rendering), BranchPopover (variation listing), EcoOpeningBar (move-sequence classification), ContextMenu (per-node state queries).
Metadata
Section titled “Metadata”getRecord(): GameRecordReturns a copy of the game’s header record — player names, event,
date, result, ECO, opening, etc. The keys are lowercased and the
shape is flat. See Tabia’s FIELD_SCHEMA
for the canonical key set; consumers tolerate missing keys and ignore
unknown ones.
Used by: PlayerHeader (W/B names + result), EcoOpeningBar (ECO and opening fallbacks).
Navigation
Section titled “Navigation”goToStart(): voidgoToPrev(): voidgoToNext(): voidgoToEnd(): voidgoToMove(nodeId: number): voidMove the game cursor. goToStart walks to the position before any
move; goToEnd to the end of the current mainline. goToMove jumps
to an arbitrary node. Implementations fire onPositionChange and
onChange callbacks (see Notifications) so
consumers re-read after mutating.
Used by: Toolbar (nav-start / prev / next / end), MoveList (default
motif-move-click handler), BranchPopover (default selection
handler).
Per-node edits
Section titled “Per-node edits”setComment(nodeId: number, text: string | null): voidtoggleNag(nodeId: number, nag: number): voidnodeHasNag(nodeId: number, nag: number): booleanMutators that take a node id explicitly (rather than implicitly
operating on the current node) so the user can edit out-of-sequence
nodes from a context menu. toggleNag adds the NAG if absent,
removes it if present; implementations may enforce mutual exclusion
within a NAG class (move vs position) per chess convention. The
nag-pair convention (e.g. $22/$23 for zugzwang White/Black) is
also implementation-managed; nodeHasNag checks for either member
of a pair.
Used by: ContextMenu (comment editing, NAG quick-toggles, NAG picker).
View toggle
Section titled “View toggle”toggleComments(): booleantoggleComments hides/shows inline comments globally. Returns the new
state for the caller’s convenience.
Used by: Toolbar (toggle-comments).
Auto-play and branch-aware navigation are no longer Tabia primitives; they’re documented in RECIPES.md as compositions on top of Tabia’s tree-walking primitives + Motif’s keybinder.
Notifications — slice subscriptions
Section titled “Notifications — slice subscriptions”Game state is observed through slices, each addressing one
chess-meaningful concern. Consumers either pass per-slice callbacks at
construction or call subscribe(slice, fn) post-construction:
createGame(input, { onCursor: (view) => void, onTree: (view) => void, onHeader: (view) => void, onView: (view) => void, onEngine: (view) => void,});
// Or, after construction:const unsub = game.subscribe('cursor', (view) => void);| Slice | Fires when | View shape |
|---|---|---|
cursor | Cursor moves (nav or new move) | { node, fen, ply, lastMove, isInVariation, isLeaf } |
tree | Move tree mutates (new node, edit, NAG, comment) | { nodes, currentNodeId } |
header | PGN tag pairs change (future) | flat GameRecord |
view | View preferences change (e.g., toggleComments) | { commentsHidden } |
engine | An engine publishes a new analysis snapshot | { [engineId]: EngineInfoSnapshot } |
Each subscriber fires immediately with the current view (no separate
bootstrap step) and then on every slice fire until unsubscribed. Slices
fire exactly once per mutation; a playMove fires both tree and
cursor, but a pure navigation only fires cursor.
Motif uses bindGame(element, game, handlers)
to wire components to slices with auto-cleanup on disconnect. Apps that
embed components push the game in by setting el.game = game; the
component handles its own subscriptions from there.
See PROMOTE/Tabia/docs/SPEC-lifecycle.md for the full lifecycle design — re-entrancy rules, mutation impact map, implementation sketch.
What is intentionally NOT in this contract
Section titled “What is intentionally NOT in this contract”These belong elsewhere even though they’re sometimes mistaken for “game state.”
-
Orientation. A per-view preference, not a per-game property. Two users of the same Game can have different orientations. Lives on the renderer (
getOrientation/flipin the renderer contract). -
Engine analysis state (mostly). The Game describes the played game, not what an engine thinks about it. The Game does host an
engineslice — but only as a pass-through: apps that want engine data callgame.publishEngineInfo(engineId, snapshot)(or use Tabia’sattachEngine(game, engine)bridge), and the slice carries whatever was published. The Game has no opinions about which engine, how to drive it, or how to translate UCI; that’s the engine module’s concern. Components that consume the engine slice (EnginePanel) are opt-in — a Game that never publishes engine info is conformant. -
Selection, hover, drag state. All visual / UX concerns; renderer or component territory, not game state.
-
Piece set, theme, board colors. Motif design tokens / per-app preferences, not game state.
-
Variant rules / legality engine. A Game implementation may use any legality engine internally (Tabia currently uses chess.js; a future pluggable engine will allow chessops / fairy-stockfish). The contract here is what consumers see; the engine choice is a Game internal.
How consumer mutation flows back
Section titled “How consumer mutation flows back”Most Motif components are read-mostly. The two patterns for mutating the Game:
-
Direct method call. E.g. ContextMenu calling
game.setComment(...). Used for narrowly-targeted mutations from explicit user actions. -
Cancelable DOM event. E.g. MoveList dispatching
motif-move-click { nodeId }— by default the component callsgame.goToMove(nodeId); consumers canpreventDefaultto override (useful when wrapping in a custom history controller).The events Motif components emit:
Event Detail Default behavior motif-move-click(MoveList){ nodeId }game.goToMove(nodeId)motif-fen-load(FenArea, PositionSetup){ fen, ops, raw }App rebuilds game from FEN motif-position-done(PositionSetup){}App exits setup mode Apps that want to intercept or instrument these listen at any level above the component (events bubble + composed).
Implementing the contract for a non-Tabia Game
Section titled “Implementing the contract for a non-Tabia Game”Plausible cases: a server-rendered “game” backed by a database row; a test fixture; a variant-specific implementation. Wrap the underlying state in an adapter that exposes the methods above.
For most apps, the right answer is “use Tabia.” This contract exists so that’s a choice, not a forced dependency, and so Tabia itself is held to the smallest stable API surface it can offer.
How this contract evolves
Section titled “How this contract evolves”Adding a method: someone needs the method, AND the method is chess-semantic (not visual / not per-view). If a Motif component finds itself wanting orientation or square sizes, the answer is the renderer contract, not this one.
Removing a method: only if no Motif component still uses it. Mark deprecated for one release minimum before removal.
When in doubt: keep the contract specific to chess concepts that a non-chess-aware implementation would have no business expressing.
Audit (current use, 2026-05-17)
Section titled “Audit (current use, 2026-05-17)”The reverse-engineered footprint after the slice-lifecycle migration:
| Method / event | Used by |
|---|---|
subscribe('cursor', fn) | MoveList, FenArea, EnginePanel (via the bridge), PositionSetup |
subscribe('tree', fn) | MoveList, EcoOpeningBar |
subscribe('header', fn) | PlayerHeader |
subscribe('view', fn) | Toolbar (for comments-toggle button state) |
subscribe('engine', fn) | EnginePanel |
getCurrentFen() | FenArea, PositionSetup |
getNodes() | MoveList, BranchPopover, EcoOpeningBar, ContextMenu |
getCurrentNodeId() | MoveList, EcoOpeningBar |
getRecord() | PlayerHeader, EcoOpeningBar |
goToStart/Prev/Next/End() | Toolbar |
goToMove(id) | MoveList, BranchPopover (default event handlers) |
toggleComments() | Toolbar |
setComment(id, text) | ContextMenu |
toggleNag(id, nag) | ContextMenu |
nodeHasNag(id, nag) | ContextMenu |
publishEngineInfo(id, snapshot) | Engine bridge (attachEngine from tabia/engine) |
Tabia exposes more than this. Surface listed here is what Motif
currently depends on; Tabia’s other methods (playMove,
getPgn, setShapeAnnotations, etc.) are used by apps and
the renderer-binding glue, not by Motif components themselves.
This is the size we’re committing to keep modest. New requirements add to it deliberately, not by drift.