Skip to content

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.

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.

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.

getCurrentFen(): string

Full 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).

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(): number

Id of the node the game cursor is currently positioned at. 0 is the root (starting position, before any move).

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.

FieldTypeNotes
idnumberStable within the game’s lifetime; 0 is root.
parentIdnumber-1 for root; otherwise the parent node’s id.
fenstringFull FEN of the position after this node’s move (root: starting FEN).
sanstring | nullThe move that led to this position. null only on the root.
fromstring | nullOrigin square (algebraic). null on root.
tostring | nullDestination square. null on root.
plynumberHalfmove count from root. 0 on root, 1 on White’s first move, etc.
mainChildnumber | nullId of this node’s mainline successor, or null if leaf.
childrennumber[]Ids of all successors (mainline first, then variations in order).
commentstring | nullFree-text annotation, if any.
nagsnumber[] | nullNumeric Annotation Glyph codes (e.g. [1] for !, [14] for ±).
annotationsobject | nullRenderer-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.
deletedboolean?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).

getRecord(): GameRecord

Returns 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).

goToStart(): void
goToPrev(): void
goToNext(): void
goToEnd(): void
goToMove(nodeId: number): void

Move 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).

setComment(nodeId: number, text: string | null): void
toggleNag(nodeId: number, nag: number): void
nodeHasNag(nodeId: number, nag: number): boolean

Mutators 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).

toggleComments(): boolean

toggleComments 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.

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);
SliceFires whenView shape
cursorCursor moves (nav or new move){ node, fen, ply, lastMove, isInVariation, isLeaf }
treeMove tree mutates (new node, edit, NAG, comment){ nodes, currentNodeId }
headerPGN tag pairs change (future)flat GameRecord
viewView preferences change (e.g., toggleComments){ commentsHidden }
engineAn 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 / flip in 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 engine slice — but only as a pass-through: apps that want engine data call game.publishEngineInfo(engineId, snapshot) (or use Tabia’s attachEngine(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.

Most Motif components are read-mostly. The two patterns for mutating the Game:

  1. Direct method call. E.g. ContextMenu calling game.setComment(...). Used for narrowly-targeted mutations from explicit user actions.

  2. Cancelable DOM event. E.g. MoveList dispatching motif-move-click { nodeId } — by default the component calls game.goToMove(nodeId); consumers can preventDefault to override (useful when wrapping in a custom history controller).

    The events Motif components emit:

    EventDetailDefault 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.

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.

The reverse-engineered footprint after the slice-lifecycle migration:

Method / eventUsed 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.