State-event lifecycle (spec)
Status: design locked for cursor / tree / header / view slices. Engine slice is in v1 by decision; its integration model is open (separate sibling spec required before implementation).
Why this exists
Section titled “Why this exists”Tabia’s current state-event surface (two callbacks: onPositionChange, onChange; a start() bootstrap method; payload objects with live references) carries enough rough edges that it’s worth redesigning before more consumers bake in the current shape. The redesign also doubles as the foundation for every app built on the stack — a chess-shaped reactive layer that hides reactive machinery behind a familiar API.
The design captures the wins of signals (surgical updates, free initial state, declarative dependencies) while avoiding the worst pain (identity headaches, batching coordination, live-state leaks, leaky implementation surface).
The thesis: chess state is a small set of orthogonal slices touched by a fixed mutator vocabulary. We can hardcode that structure and get reactivity without exposing reactive primitives.
§1 Slices
Section titled “§1 Slices”Five slices, chess-meaningful and orthogonal:
| Slice | What it represents | Example consumers |
|---|---|---|
cursor | Where the user is currently looking in the tree | Board renderer, FEN area |
tree | The move tree’s structure + annotations | MoveList, ECO opening bar, branch popover, context menu |
header | PGN tag pairs (Event, Date, players, result, etc.) | Player header, study metadata UI |
view | View preferences (commentsHidden, future zoom/layout flags) | Anything that respects view state |
engine | Engine analysis state (depth, eval, pv, etc.) per UCI vocabulary | Engine panel, eval bar, autoshapes-from-engine |
These cover everything Tabia currently tracks plus the natural extension point for engine integration. They’re stable — chess data shape is fixed; we don’t expect this list to grow under normal evolution.
Why these specifically
Section titled “Why these specifically”cursorvs.tree— separated because changing the cursor (clicking a move) shouldn’t trigger a tree-level re-render. MoveList highlights the current row via a cursor subscription; it walks the tree structure via a tree subscription. Two different update frequencies.headerseparate fromtree— editing the Date field shouldn’t trigger MoveList re-render. Header changes affect only player-header / study-metadata UIs.viewseparate —toggleCommentsshouldn’t firetree. View prefs are orthogonal to data.engineseparate — engine info updates can arrive 30+ times per second. Bundling them intotreewould force MoveList to ignore most fires manually. As its own slice, engine consumers can subscribe directly without bothering anyone else.
Future slices
Section titled “Future slices”OpenFile’s coordination state (peer cursors, connection state, watermark, conflict events) is not a Tabia concern — Tabia is 1:1 user-to-game; OpenFile coordinates many such 1:1s. Those slices live on the OpenFile target, not on Tabia’s game. The slice machinery itself can be re-used (extracted as a shared utility) but the slice taxonomy stops at the chess-data boundary.
§2 View shapes
Section titled “§2 View shapes”Each slice exposes a view — a curated snapshot the subscriber receives. Views are fresh per fire (no identity headaches). Subscribers treat them as read-only.
CursorView
Section titled “CursorView”type CursorView = { node: Node; // the current node object fen: string; // node.fen (or startingFen if at root) ply: number; // chronological ply (already derives from FEN) lastMove: { // null at root from: string; to: string; san: string; } | null; isInVariation: boolean; // ancestor-aware (one walk up at fire time) isLeaf: boolean; // node.mainChild === null};The cursor view bundles the commonly-needed view-time computations (ply, isInVariation, isLeaf) so consumers don’t recompute them per render. node is the actual node object — read-only, accessed via game.getNode(id) if more is needed.
TreeView
Section titled “TreeView”type TreeView = { nodes: (Node | null)[]; // live array; nulls = deleted slots currentNodeId: number; // included so MoveList can render w/o subscribing to cursor};nodes is the live array (with nulls in deleted slots), documented “read-only.”
Why live and not cloned: a shallow clone ([...nodes]) would cost an allocation per fire but wouldn’t actually prevent mutation — clonedNodes[5].comment = 'oops' still mutates the underlying node, because shallow clone duplicates the array, not the objects inside. The only safe clone is structuredClone(nodes) — a deep copy — which on a 500-node tree is ~5KB allocated per mutation. Not worth it for the partial protection. Apps don’t accidentally mutate the tree (mutations go through Tabia methods); the convention is sufficient.
currentNodeId is duplicated in the tree view so MoveList can render the highlight from a single subscription if it wants. Apps that prefer two separate subscriptions (cursor for highlight, tree for layout) can do that too — both patterns are valid.
HeaderView
Section titled “HeaderView”type HeaderView = GameRecord; // the record object (PGN tag-pair fields)A shallow copy of the record on each fire. Cheap (record is small), avoids the live-reference leak.
ViewView
Section titled “ViewView”type ViewView = { commentsHidden: boolean;};Small now. Grows as view prefs accrete.
EngineView
Section titled “EngineView”type EngineView = { engineId: string; // which engine; supports multi-engine state: 'idle' | 'analyzing' | 'stopped'; depth: number; multipv: Array<{ eval: { type: 'cp' | 'mate', value: number }; pv: string[]; // SAN sequence depth: number; }>; nodes: number; nps: number; // nodes per second time: number; // ms elapsed};UCI-shaped. Multiple engines = multiple subscribers to the engine slice; each fire carries the engineId so consumers can route. (For v1, we may launch with a single-engine subset; the multi-engine shape is reserved.)
§3 Mutation-impact map
Section titled “§3 Mutation-impact map”Each mutator in Tabia statically declares which slices it affects. Mutations fire all affected slices once at the end (not as they mutate intermediate state).
const MUTATION_IMPACT = { // Tree mutations playMove: ['tree', 'cursor'], deleteFromHere: ['tree', 'cursor'], promoteVariation: ['tree'], makeMainline: ['tree'], setComment: ['tree'], setShapeAnnotations: ['tree'], toggleNag: ['tree'],
// Navigation goToStart: ['cursor'], goToPrev: ['cursor'], goToNext: ['cursor'], goToEnd: ['cursor'], goToMove: ['cursor'],
// View toggleComments: ['view'],
// Header (future, when header-editing lands) setHeader: ['header'], removeHeader: ['header'],
// Engine (future, depending on integration model) // — fires from inside the engine module on UCI info events};Each slice fires exactly once per mutation, no matter how many internal writes happened. Subscribers to multiple slices that are both affected get one fire per slice (so up to N fires per mutation if N affected slices, but never duplicate fires within a single slice).
§4 Public API surfaces
Section titled “§4 Public API surfaces”Two surfaces. Most consumers use the first; advanced consumers use the second.
Surface A: construction callbacks (everyday)
Section titled “Surface A: construction callbacks (everyday)”The familiar pattern — pass callbacks at construction, they fire for the game’s lifetime:
const game = createGame(pgn, { onCursor: (view) => board.update(view), onTree: (view) => moveList.update(view), onHeader: (view) => playerHeader.update(view), onView: (view) => settings.update(view), onEngine: (view) => enginePanel.update(view),});Each callback fires only for its slice, with the corresponding view.
There is no catch-all onChange(slice, view). Apps that want to react to “anything changed” subscribe to specific slices. Forcing the slice choice at the call site keeps consumers honest about what they actually need — and prevents the “render everything on any change” anti-pattern that defeats the slice design.
Initial fire: every registered callback fires once immediately after construction with the current view. No start() method, no two-phase init.
No dispose: callbacks live for the game’s lifetime. When the game is GC’d, callbacks are too.
This is what 90% of consumers see and use.
Surface B: subscribe API (advanced / dynamic)
Section titled “Surface B: subscribe API (advanced / dynamic)”For consumers that need dynamic subscription — components that mount/unmount independently of the game’s lifetime:
const unsubscribe = game.subscribe('cursor', (view) => board.update(view));// later:unsubscribe();Slice-specific only. Same reason as Surface A — no catch-all. Apps that want to subscribe to multiple slices write multiple subscribe calls; this is one line each and forces a per-slice handler.
The construction callbacks (Surface A) are implemented as subscribe calls under the hood. They’re not a separate code path.
Bridge helper: bindGame (Motif / Lit)
Section titled “Bridge helper: bindGame (Motif / Lit)”In Motif, a Lit-aware helper auto-cleans on disconnect:
import { bindGame } from 'motif';
class MotifMoveList extends LitElement { connectedCallback() { super.connectedCallback(); bindGame(this, this.game, { tree: (view) => this.updateTree(view), cursor: (view) => this.updateCursor(view), }); // subscriptions auto-dispose in disconnectedCallback }}bindGame is ~20 lines in Motif. Wraps game.subscribe with auto-cleanup hooked to the element’s lifecycle.
What’s NOT in the API
Section titled “What’s NOT in the API”- No
start(). Initial fire is automatic on subscribe. - No payload-object
onChange({nodes, currentNodeId, ...}). View shapes are slice-specific. - No live state in views beyond the documented
tree.nodesarray (read-only). - No re-entrancy support. Calling a mutator during a subscribe fire throws (prevents infinite loops; apps batch via app-level state if needed).
§5 Implementation sketch
Section titled “§5 Implementation sketch”The core machinery, in pseudocode:
function createGame(input, callbacks = {}) { // ... existing state setup (nodes, currentNodeId, commentsHidden, record, startingFen) ...
const SLICE_NAMES = ['cursor', 'tree', 'header', 'view', 'engine']; const subs = Object.fromEntries(SLICE_NAMES.map(s => [s, new Set()]));
// View assemblers — one per slice. const buildView = { cursor: () => { const node = nodes[currentNodeId]; return { node, fen: node?.fen ?? startingFen, ply: node?.ply ?? 0, lastMove: node?.san ? { from: node.from, to: node.to, san: node.san } : null, isInVariation: computeIsInVariation(currentNodeId), isLeaf: node?.mainChild === null, }; }, tree: () => ({ nodes, currentNodeId }), header: () => ({ ...record }), view: () => ({ commentsHidden }), engine: () => engineState, // populated by engine integration };
let firing = false;
function fire(...sliceNames) { if (firing) throw new Error('Tabia: re-entrant mutation during fire is not allowed'); firing = true; try { for (const name of sliceNames) { const view = buildView[name](); subs[name].forEach(fn => fn(view)); } } finally { firing = false; } }
function subscribe(sliceName, fn) { subs[sliceName].add(fn); fn(buildView[sliceName]()); // initial fire return () => subs[sliceName].delete(fn); }
// Wire construction-callback sugar: if (callbacks.onCursor) subscribe('cursor', callbacks.onCursor); if (callbacks.onTree) subscribe('tree', callbacks.onTree); if (callbacks.onHeader) subscribe('header', callbacks.onHeader); if (callbacks.onView) subscribe('view', callbacks.onView); if (callbacks.onEngine) subscribe('engine', callbacks.onEngine);
// Mutators call fire(...) at the end of their work: function playMove(san) { // ... do the chess work ... fire('tree', 'cursor'); } // ... etc. for each mutator ...
return { subscribe, playMove, // ... other methods ... };}Re-entrancy
Section titled “Re-entrancy”A subscriber that calls game.playMove(...) during its fire would cause infinite recursion. The firing flag detects this and throws. Apps that need to dispatch mutations in response to other mutations should defer with queueMicrotask or similar.
Initial fire timing
Section titled “Initial fire timing”fn(buildView[name]()) fires synchronously when subscribe is called. This is intentional — apps that subscribe during their own setup get the current state immediately, no microtask wait.
For construction-callback sugar (Surface A), this means the callbacks fire DURING createGame. This is fine because:
- The callbacks are passed in as arguments to
createGame— they exist at construction time. - The callbacks don’t reference
game(since the variable is not yet assigned). If they do, that’s a bug in the consumer’s code. - This is the cleanest semantic — no microtask, no “wait, did I miss the initial fire?”
If a consumer needs game available before the initial fire, they use Surface B (subscribe after construction).
Engine slice integration
Section titled “Engine slice integration”The engine slice is special — its fires come from the engine module’s UCI info events, not from Game mutations. Two implementation options:
A. Game owns engine state. Attach an engine to the game; Game listens to UCI events, updates internal engineState, fires the engine slice.
B. Engine owns its own state. Engine module has its own slice/subscribe pattern (sharing the implementation utility). Game doesn’t bundle engine state.
For v1: B. Keep engine separate. Game’s engine slice fires only when the app explicitly pushes engine updates into the game. Or we ship without the engine slice initially and add it once the integration model is clearer.
Resolved: launch with 4 slices (cursor, tree, header, view). Engine slice is reserved for v1.1.
§6 Migration plan
Section titled “§6 Migration plan”Tabia (src/game.js)
Section titled “Tabia (src/game.js)”- Remove:
onPositionChange/onChangecallbacks at the current shape;notifyChangeinternal helper;start()method. - Add: subscribe machinery; view assemblers; new construction-callback shape; mutation-impact-driven
fire(...)calls in each mutator. - Touch points: every mutator. Each gets a
fire(...)call replacing the currentnotifyChange()(andonPositionChange(...)where applicable). - Estimated: ~150 lines changed/added across game.js.
Tabia tests (test/game.test.js)
Section titled “Tabia tests (test/game.test.js)”- Tests asserting on
onPositionChangeargument shape: update to subscribe tocursorand assert view-shape fields. - Tests asserting on
onChangepayload shape: update to subscribe to relevant slices. - Tests that call
start(): remove the call (initial fire is automatic). - Add new tests for the slice/subscribe machinery (re-entrancy throws, initial fire, fire-once-per-slice, etc.).
Motif components
Section titled “Motif components”Each component that currently consumes game.onChange payload or game.getNodes() etc. needs to switch to slice subscriptions.
MoveList: subscribe to tree (for the move list itself) and cursor (for highlight). Two subscriptions, both auto-cleaned via bindGame.
Board renderer (Rabbit binding): subscribe to cursor. The board updates the position; animation comes from view.lastMove.
FEN area: subscribe to cursor (to read view.fen).
Player header: subscribe to header.
ECO opening bar: subscribe to tree (walks mainline to find ECO match).
Branch popover: doesn’t subscribe — it’s invoked imperatively. Reads game.getNode(...) for children.
Context menu: doesn’t subscribe — invoked imperatively.
Toolbar: subscribes to view so action buttons can reflect logical state — e.g., the comments-toggle button highlights when commentsHidden is true. Otherwise dispatches via the game (game.goToStart() etc.).
Motif: bindGame helper
Section titled “Motif: bindGame helper”New file: Motif/components/utils/bindGame.js. ~20 lines.
export function bindGame(element, game, handlers) { const unsubs = []; for (const [slice, handler] of Object.entries(handlers)) { unsubs.push(game.subscribe(slice, handler)); } const origDisconnected = element.disconnectedCallback?.bind(element); element.disconnectedCallback = function () { unsubs.forEach(u => u()); origDisconnected?.(); };}dev/dev.js
Section titled “dev/dev.js”The harness wires up boards, FEN area, MoveList. Most goes away — components self-subscribe via bindGame. The harness becomes thinner.
Specifically:
onPositionChange(fen, from, to, annotations)— gone. Board’s binding handles it.onGameChange()— gone. MoveList’s binding handles it.game.start()calls — gone.
Test count impact
Section titled “Test count impact”Currently 351 Tabia tests. Estimate:
- ~20 tests reshape (event-shape assertions)
- ~10 new tests for slice machinery
- net: ~360 tests post-migration
§7 Decisions
Section titled “§7 Decisions”Resolved:
-
tree.nodesis the live array (with nulls in deleted slots), documented read-only. Shallow clone doesn’t actually prevent mutation (nodes inside the array are still shared by reference); deep clone is ~5KB per mutation on a 500-node tree. The convention is sufficient — apps don’t accidentally mutate Tabia’s internals. -
Re-entrant mutation throws. Synchronous re-entrancy is detected via a
firingflag and rejected with a clear error. This catches the obvious bug; cyclic loops at the logical level can’t be designed away (consumers can always write infinite loops), but the throw makes the common bug class loud. Apps that need cascading mutations usequeueMicrotaskexplicitly. -
No catch-all
onChange. Both Surface A and Surface B are slice-specific only. The catch-all rewards lazy “rerender everything on anything” patterns and defeats the slice design. Apps that genuinely need to log every change (testing / debug tools) write a 3-line helper that subscribes to each slice. -
No
subscribeAll. Same reasoning as above. -
Initial fire is synchronous in
createGame. Subscribers passed at construction get an immediate sync fire. Apps that needgameto be assigned before fire can use Surface Bsubscribe()after construction. -
Engine slice ships in v1. Engine panels are the highest-rerender-frequency consumer, so the dedicated slice is worth having from day one. The engine integration model (how Game and Engine connect, multi-engine handling, auto-restart behavior) is a separate question — designed in a sibling spec before implementation begins.
Open (separate spec):
- Engine integration model. Options: Game-attached engine (
game.attachEngine(engine)), Engine-owned state with separate slice system, app-mediated wiring. Affects whether Tabia depends on Engine, how multi-engine works, auto-analyze-on-cursor-change semantics. The engine slice’s view shape (§2) is locked; the integration plumbing is not.
§8 Why this design (the recap)
Section titled “§8 Why this design (the recap)”What we get:
- Surgical updates — each slice fires only its subscribers, only on mutations that affect it.
- Free initial state — subscribe fires immediately with current view. No
start(). - Declarative dependencies — slice subscription = static “this depends on that.”
- No identity headaches — views are fresh per fire; subscribers don’t compare.
- No batching pain — each mutator declares its impact statically; one fire per affected slice per mutation.
- No live-state leaks — header/view/cursor views are fresh snapshots; only
tree.nodesis live (read-only by convention). - Familiar API surface for everyday consumers — construction callbacks look like the current Tabia API, just slice-aware.
- Power-user escape hatch —
subscribe/bindGamefor dynamic / mount-aware patterns. - Forward-compatible — the slice machinery is extractable as a shared utility. OpenFile’s many:1 coordination state (presence, sync) can live on the OpenFile target using the same primitive, without bloating Tabia’s chess-data slice set.
What we give up:
- Generality — five fixed slices, not arbitrary state. Apps can’t define new slices on Game. For chess, this is fine; the slices are obvious.
- Slice-internal granularity — within a slice (e.g., one node’s comment changing), the whole slice’s subscribers fire. The dedicated
engineslice mitigates this for the highest-frequency case. - Catch-all convenience — no
onChange(slice, view)orsubscribeAll. Apps explicitly subscribe per-slice, which forces them to think about what they actually depend on. - Some implementation complexity — ~150 lines for the slice machinery (vs ~30 for the current callback shape). Foundational investment for the wins above.
§9 What’s next
Section titled “§9 What’s next”This spec covers cursor / tree / header / view. The engine slice is in v1 by decision, but its integration model (how Game and Engine connect, multi-engine handling, auto-analyze semantics) is unresolved and needs a sibling spec before implementation.
After both this spec and the engine integration spec are locked:
- Implement the slice machinery in
game.js. - Update the mutators to call
fire(...)per the impact map. - Implement engine slice + integration per the sibling spec.
- Migrate the test suite.
- Implement
bindGamein Motif. - Migrate Motif components (MoveList, FEN area, ECO bar, player header, board binding).
- Migrate dev/dev.js.
- Update GAME-CONTRACT.md and ROADMAP.md to reflect the new API surface.
Migration touches multiple files but each touch is mechanical once the design is locked. Estimated: a day or two of focused work after both specs sign off.