Skip to content

Target interface (spec)

Status: v0.2 draft (refreshed 2026-05-17, post-lifecycle migration). Implementation pending review. Audience: Tabia maintainers; library authors who want to provide alternative targets (e.g., OpenFile). Related: SPEC-lifecycle.md defines the slice machinery that lives above this contract. SPEC-engine-integration.md is orthogonal — engine state is a Game-level concern, not Target’s. OpenFile op vocabulary defines OpenFile’s target implementation.

v0.1 was authored before the slice-based lifecycle landed. v0.2 reflects what actually shipped:

  • Notification model. v0.1 had Target exposing on('change' | 'positionChange', cb). v0.2 puts slices on Game; Target owns storage only and (optionally) signals upward for collab-target remote changes.
  • Hard delete via null sentinels (matching the lifecycle migration), not soft-delete.
  • Node shape, not NodeView. Target exposes Tabia’s existing Node shape via a live read-only array — no per-call materialization of parallel view objects.
  • Cursor ownership. Cursor is navigation / view state, not chess data — it fails the “would this sync across collaborators?” test that gates Target ownership. Lives on Game alongside the slice machinery. Same for commentsHidden (display preference).
  • Multi-author fields (segments, nagsByAuthor, etc.) moved off the base contract and into OpenFileTarget’s extension API.
  • Slice-level signals. onTargetChange (when present) carries a hint about which slices were affected, so Game can fire only the relevant subset rather than re-firing everything.

Tabia is a 1:1 chess game library: one user, one game session. Chess logic — legal moves, FEN computation, SAN parsing, NAG pairing rules, tree structure semantics — is inherently 1:1; it doesn’t need to know about other users or about network state.

But the 1:1 view can be backed by different storage strategies. Tabia’s default is an in-memory tree (LocalTarget). Other libraries can provide alternative implementations of the same storage contract — for example, OpenFile provides a target backed by a CRDT-based data structure that synchronizes multiple users’ 1:1 views across the network.

This document defines the target interface that decouples Tabia’s chess logic from its storage. The contract is small, chess-aware, and storage-agnostic. Any implementation that complies can serve as a Tabia target.

Target stores the chess work that gets shared, saved, or synced — the body of the played game. Tree, annotations, headers. Nothing else. Game does the chess work and owns the slice machinery; Target just persists what Game tells it.

The “would this sync across collaborators?” test cleanly separates Target state from Game state:

  • Tree, record, headers — sync. Target’s.
  • Cursor, view prefs, engine snapshots — don’t sync. Game’s.
  • Orientation, theme, hover — don’t sync. App’s.

What this means concretely:

  • Reading from the target returns the user’s 1:1 view of the chess-data state — the move tree, cursor position, PGN tag pairs. Targets that aggregate multi-author contributions (OpenFile) expose the aggregated view through the same Node fields; per-author detail is available via target-specific extension methods.
  • Writing to the target expresses the result of Game’s chess work. Target chooses how to record it (in-memory mutation for LocalTarget; op emission + apply for OpenFileTarget). The chess logic doesn’t change between targets.

Notification of state changes lives above the Target contract: Game’s slice machinery (see SPEC-lifecycle.md) is how consumers observe state. Target signals upward only for the collab case (remote ops arriving), and Game translates those signals into slice fires.

interface TabiaTarget {
// ── Reads ───────────────────────────────────────────────────────
/** Live read-only array of all nodes; nulls in deleted slots. */
getNodes(): (Node | null)[];
/** Convenience: same as getNodes()[id] ?? null. */
getNode(id: number): Node | null;
/** PGN tag pairs. Shallow-copied per call. */
getRecord(): GameRecord;
/** Starting FEN of the game. Immutable for the target's lifetime. */
getStartingFen(): string;
// ── Tree mutations ──────────────────────────────────────────────
/** Append a new child node under parentId. Returns the new node id.
* Appends to parent.children. If parent.mainChild was null
* (parent had no prior continuation), the new node becomes
* mainChild. Caller is responsible for not duplicating same-SAN
* siblings; the Target trusts what it's given. */
addNode(parentId: number, payload: NodePayload): number;
/** Atomically detach nodeId from its parent and cascade-null the
* entire subtree. The parent's children array drops nodeId; if
* nodeId was the parent's mainChild, mainChild falls back to the
* next sibling (or null if no siblings remain). IDs of surviving
* nodes remain stable. No-op if nodeId is the root. */
deleteSubtree(nodeId: number): void;
/** Make nodeId its parent's mainChild and move it to the front of
* parent.children. Returns true if a change was made (false if
* already mainChild or detached). */
promoteToMainChild(nodeId: number): boolean;
// ── Annotation mutations ────────────────────────────────────────
/** Replace the node's comment. null clears it. */
setComment(nodeId: number, text: string | null): void;
/** Replace the node's whole NAG set. null clears. The caller has
* already applied any NAG pairing / class-exclusion rules. */
setNagSet(nodeId: number, nags: number[] | null): void;
/** Replace the node's shape annotations (PGN [%cal]/[%csl]).
* Empty arrays clear. */
setShapeAnnotations(
nodeId: number,
arrows: string[],
squares: string[],
): void;
// ── Header mutations (future, when header-editing lands) ────────
/** Set a PGN tag value. startFen is reserved (structural, fixed
* at construction) and rejected. */
setHeader?(key: string, value: string): void;
removeHeader?(key: string): void;
// ── Upward signal (collab targets only) ─────────────────────────
/** Fires when state changed by something other than the caller —
* i.e., remote ops in OpenFileTarget. LocalTarget never fires this
* and may omit the method entirely. Game subscribes and translates
* into slice fires. */
onTargetChange?(
handler: (info: TargetChangeInfo) => void,
): () => void;
}
// Node is Tabia's existing node shape — see src/game.js buildMoveTree.
// Targets expose the same shape; no parallel "NodeView" type.
type Node = {
id: number;
parentId: number; // -1 for root
fen: string;
san: string | null; // null on root
from: string | null;
to: string | null;
ply: number;
mainChild: number | null;
children: number[];
comment: string | null;
nags: number[] | null;
annotations: object | null; // { arrows?, squares?, ... }
// Merge-attribution fields (optional, populated by mergeGames):
comment_segments?: object[] | null;
variationOwner?: string | null;
};
type NodePayload = {
san: string;
from: string;
to: string;
fen: string;
ply: number;
// Optional pre-merged fields (for buildMoveTree from parsed PGN):
comment?: string | null;
nags?: number[] | null;
annotations?: object | null;
comment_segments?: object[] | null;
variationOwner?: string | null;
};
type GameRecord = {
white?: string;
black?: string;
date?: string;
result?: string;
startFen?: string;
// ... other PGN tag-pair fields per FIELD_SCHEMA in src/record.js
};
type TargetChangeInfo = {
/** Which Game slices were affected. Cursor is never a Target concern,
* so it's not in this set. */
slices: ('tree' | 'header')[];
};

The Node shape is the existing one Tabia uses; no translation layer. Aggregation (for multi-author targets) happens inside the target — the fields exposed to Game are the resolved/displayed values.

Game owns slice machinery, view assemblers, chess-logic mutators, and Game-only state (currentNodeId, commentsHidden, engineState). Target owns the chess data — tree, record, starting FEN.

Mutator pattern. Each Game mutator:

  1. Does chess work (legality, FEN computation, NAG pairing).
  2. Calls one or more Target methods to persist.
  3. Fires the slices its mutation impact map declares.

Example — playMove:

function playMove(san) {
const parent = target.getNode(currentNodeId); // Game holds currentNodeId
const existing = parent.children.find(
(cid) => target.getNode(cid)?.san === san,
);
if (existing !== undefined) {
currentNodeId = existing;
fire('cursor');
return;
}
const chess = new Chess(parent.fen);
let move;
try { move = chess.move(san); } catch { return; }
if (!move) return;
const newId = target.addNode(parent.id, {
san: move.san,
from: move.from,
to: move.to,
fen: chess.fen(),
ply: parent.ply + 1,
});
currentNodeId = newId;
fire('tree', 'cursor');
}

Example — toggleNag (NAG pairing logic stays on Game):

function toggleNag(nodeId, nagNum) {
if (nodeId <= 0) return;
const node = target.getNode(nodeId);
if (!node) return;
const pair = NAG_PAIRS[nagNum] || NAG_PAIR_REVERSE[nagNum] || null;
const pairSet = pair ? new Set([nagNum, pair]) : new Set([nagNum]);
const isBlack = node.ply % 2 === 0;
const resolved = pair
? (isBlack ? Math.max(...pairSet) : Math.min(...pairSet))
: nagNum;
let nags = node.nags ? [...node.nags] : [];
if (nags.some((n) => pairSet.has(n))) {
nags = nags.filter((n) => !pairSet.has(n));
} else {
const isMoveNag = NAG_INFO[resolved]?.[2] === 'move';
nags = nags.filter(
(n) => isMoveNag !== (NAG_INFO[n]?.[2] === 'move'),
);
nags.push(resolved);
}
target.setNagSet(nodeId, nags.length ? nags : null);
fire('tree');
}

Why this pattern. Game expresses chess intent; Target persists. LocalTarget mutates in-place. OpenFileTarget translates each call into op emission + apply. The chess logic is identical in both cases.

§3 How Target interacts with the slice lifecycle

Section titled “§3 How Target interacts with the slice lifecycle”
StateOwnerNotes
nodes (move tree)Targetlive array, read-only via getNodes()
record (PGN tag pairs)Targetshallow-copied via getRecord()
startingFenTargetimmutable
currentNodeId (cursor)Gamenavigation state, not chess data
commentsHidden (view pref)Gamedisplay preference
engineState (engine slice)Gamepopulated by publishEngineInfo
Slice machinery (subs/fire/views)Gamereads Target via accessors

For mutations initiated by Game’s mutators (the only path in LocalTarget; the common path everywhere):

  1. Game mutator does chess work.
  2. Game calls Target methods to persist.
  3. Game fires affected slices.

Target does NOT fire signals back up. The mutator already knows what changed.

For state changes that come from outside Game — typically remote ops arriving over the network in an OpenFile session:

  1. Target applies the change to its internal state.
  2. Target fires onTargetChange({ slices: [...] }).
  3. Game’s wiring on this signal fires the affected slices.

Game subscribes to onTargetChange once at construction (if the method exists) and routes:

target.onTargetChange?.((info) => {
for (const slice of info.slices) {
if (slice === 'tree') fire('tree');
else if (slice === 'header') fire('header');
}
// If the tree changed, the node currentNodeId points at may have
// been removed (cascade). Verify and fall back if so.
if (info.slices.includes('tree') && !target.getNode(currentNodeId)) {
currentNodeId = 0;
fire('cursor');
}
});

LocalTarget omits onTargetChange. The ?. makes Game’s wiring a no-op when absent.

A naïve “something changed” signal forces Game to fire all Target-driven slices defensively. Per-fire that’s a tree+header re-render at every peer message — wasteful in OpenFile sessions with chat-frequency edits. Letting Target hint which slices it touched costs the implementation almost nothing and lets consumers stay surgical.

If a collab target doesn’t track per-slice impact internally, it can always fire { slices: ['tree', 'header'] }. The contract allows pessimism; it just doesn’t require it.

Two factories, both exported from tabia:

// Implicit: builds a LocalTarget from PGN/parsed input.
createGame(input, opts);
// Explicit: takes any conformant target.
createGameFromTarget(target, opts);

createGame(input, opts) is the existing API — backwards-compatible. Internally it calls createLocalTarget(input) then createGameFromTarget.

createGameFromTarget(target, opts) is new. It takes a fully- constructed Target and the same opts (slice callbacks, parse error handler) createGame accepts. This is the entry point for OpenFile:

import { createGameFromTarget } from 'tabia';
import { createOpenFileTarget } from 'openfile';
const target = createOpenFileTarget({ ... });
const game = createGameFromTarget(target, {
onCursor: (view) => board.update(view),
onTree: (view) => moveList.update(view),
});

createGameFromFen(fen, opts) keeps its current shape; internally it calls createGame({ record: { startFen }, moves: [] }, opts).

§5 LocalTarget — Tabia’s default implementation

Section titled “§5 LocalTarget — Tabia’s default implementation”

createLocalTarget(input) returns a Target backed by Tabia’s existing in-memory tree. Concrete properties:

  • Storage. Flat array of nodes with sequential int ids; record; starting FEN. (No cursor — that lives on Game.)
  • Single-author. No notion of “current user” or “my contribution”; no aggregation. Node fields reflect literal stored values.
  • Hard-delete. deleteSubtree cascade-nulls the slots. IDs stable.
  • No upward signal. LocalTarget doesn’t implement onTargetChange. All changes flow from Game’s mutators; Target has no other source.
  • No setHeader/removeHeader until header editing lands. (Game’s header slice exists; its producer doesn’t yet.)
  • buildMoveTree integration. The existing PGN-to-tree builder becomes createLocalTarget’s constructor; emits the same Node shape, including comment_segments / variationOwner when set by upstream merge.

§6 OpenFile target — the multi-author alternative

Section titled “§6 OpenFile target — the multi-author alternative”

OpenFile provides createOpenFileTarget(...) as a sibling library. Concrete differences from LocalTarget:

  • Storage. Op log + derived multi-author tree with content- addressed node identity (see OpenFile spec). Node ids exposed via getNodes() are still integers (a stable mapping nodeHash → int lives inside the target), so Motif components don’t see hash strings.
  • Aggregated views. Node.comment is the joined comment across authors; Node.nags is the resolved NAG set per OpenFile’s resolution rules. Per-author detail (segments, per-author NAGs, per-author drawables) is exposed via OpenFileTarget-only methods (getMultiAuthorView(nodeId), etc.), not on the base Node shape.
  • Visibility ⇒ null in getNodes(). Nodes that are tombstoned or hidden by cascading invisibility (per OpenFile’s visibility rules) appear as null in the returned array — the same shape LocalTarget uses for hard-deleted slots. Tabia consumers iterate live alive nodes uniformly; the underlying mechanism (hard-delete vs CRDT visibility) doesn’t leak. Apps that want to render shadow / hidden nodes as a UX choice use OpenFile-extension methods.
  • Mutations are op emissions. setComment emits a tombstone-old + new addSegment; addNode emits an addNode op; etc. Caller (Game) doesn’t see the difference — same imperative API.
  • onTargetChange is implemented. Fires when remote ops arrive and apply. Carries per-slice hints.
  • Cursor stays local. Each user’s cursor lives on their Game, not on the Target. Peer cursor sharing (“presence”) is a separate OpenFile-side feature exposed via extension methods on OpenFileTarget — never a property of the base Target contract.

Apps that want OpenFile-specific UX (peer attribution, “my contribution vs aggregate” toggles, conflict resolution UI) cast or otherwise access the OpenFileTarget-specific extension methods. Tabia’s chess logic doesn’t care.

A few choices worth justifying.

v0.1 left NodeRef as string | number. v0.2 commits to integers, matching what Tabia uses today. OpenFileTarget keeps its internal hash identity but exposes ints to Tabia consumers via a stable mapping. This is a small concession from OpenFileTarget’s side that buys huge simplification for Motif components (which assumed integer ids prior to the refactor).

Reads return Tabia-native Node, not a parallel view type

Section titled “Reads return Tabia-native Node, not a parallel view type”

A NodeView type would force every target implementation to materialize view objects on read. The chess-data shape Tabia already uses is fine; multi-author targets aggregate internally and emit the same shape. Per- author detail is an OpenFileTarget extension API, not a contract field.

getNodes() returns a live array, not a fresh allocation

Section titled “getNodes() returns a live array, not a fresh allocation”

Matches lifecycle’s TreeView.nodes semantics. Read-only by convention. Apps don’t mutate Tabia’s internals through this array; mutations go through Target methods.

setComment(id, text) says “set the comment for this node to this text.” Target chooses how to record (overwrite vs tombstone-then-add). This makes the contract uniform across single-author and multi-author targets.

See §3. Cheap for collab targets to provide; saves Game from re-firing everything on every peer message.

All Target methods are synchronous. Network-induced async (OpenFile’s op pipeline) lives entirely inside the target’s internals; from Game’s perspective, the target responds synchronously with optimistic local state.

These belong elsewhere even though they’re sometimes mistaken for “storage state.”

  • Slice machinery / subscribe / fire. Lives on Game. Targets don’t observe themselves; consumers observe via Game’s slices.

  • Cursor (currentNodeId). Lives on Game. Navigation state, not chess data. Per-user; never synced through Target. Peer cursor sharing (coach/student, presence) is an OpenFile extension feature, composed at the app/wire layer.

  • Engine analysis state. Lives on Game (engine slice). Target is unaware of engines; publishEngineInfo is a Game method.

  • View preferences (commentsHidden, future zoom/layout flags). Lives on Game. Not chess data.

  • Sync policy / read-only modes / permissions. Lives in the Target’s construction options + transport layer (for collab targets) and in app-level UI gating. Not in the base contract. Spectator, coach-broadcasts-to-student, and similar patterns compose Target’s storage with app-level UX choices — see OpenFile’s future sync-modes design.

  • Selection, hover, drag state. Visual / UX concerns; renderer or component territory.

  • Piece set, theme, board colors. Motif design tokens.

  • Variant rules / legality engine. Game’s chess-logic helper (currently chess.js). Target stores; Game validates.

  • Identity / authentication. Targets construct knowing who the current user is (if relevant). Tabia doesn’t ask.

  • Persistence / serialization. No save() / load(). Targets manage their own storage; Tabia’s getPgn() covers the LocalTarget serialize-to-PGN case via tree traversal.

  • Concurrency / locking. LocalTarget is single-threaded. OpenFileTarget’s op pipeline handles its own consistency.

  • History / undo. Targets may implement (OpenFile has the op log); not part of the contract.

  1. onTargetChange granularity. Currently typed as { slices: ('tree'|'header')[] }. Should there also be a per-node breakdown for tree changes? (i.e., “node 42 changed, not the whole tree.”) For v0.2 the slice-level signal is enough — Motif components rebuild from getNodes() anyway. Per-node hinting is a future optimization if profiling shows it matters.

  2. Multi-author extension API on OpenFileTarget. Not yet specified in detail. Likely:

    getMultiAuthorView(nodeId): {
    segments: Segment[];
    nagsByAuthor: Record<Author, number[]>;
    drawablesByAuthor: Record<Author, Drawables>;
    };

    Defer to OpenFile-side design.

  3. MoveData / NodePayload naming. v0.1 used MoveData. v0.2 uses NodePayload to reflect that the addNode call may carry merge-attribution fields beyond just the move itself. Worth bikeshedding? Probably not.

  • getStartingFen() as a Target method. Included. Small win; documents the immutable-for-target’s-lifetime contract clearly and OpenFileTarget wants this stable anyway.
  • Should createGame accept a Target directly via opts.target? No. Two factories (§4): createGame(input) for the implicit LocalTarget convenience, createGameFromTarget(target) for explicit injection. Cleaner mental model than overloading one factory.
  • Cursor ownership. Game, not Target (see “What changed from v0.1” and the design principle). Cursor is navigation, not chess data.

Concrete steps to land the abstraction. Order assumes the lifecycle migration is already in (it is).

New file: src/local-target.js. Move into it:

  • The chess-data state currently held by createGame: nodes, record, startingFen, plus the internal helpers buildMoveTree, deleteSubtree, promoteToMainChild. (Cursor stays on Game.)
  • Public methods per the interface: getNodes, getNode, getRecord, getStartingFen, addNode, deleteSubtree, promoteToMainChild, setComment, setNagSet, setShapeAnnotations.
  • createLocalTarget(input, { onParseError }). The onParseError callback fires during the parse phase of buildMoveTree, same semantics as today. createGame passes its opts.onParseError through transparently — apps that construct via createGame(pgn, { onParseError }) see no change.

Estimated ~150 lines.

Step 2 — Refactor createGamecreateGameFromTarget + wrapper

Section titled “Step 2 — Refactor createGame → createGameFromTarget + wrapper”

createGame(input, opts) becomes:

export function createGame(input, opts = {}) {
const target = createLocalTarget(input);
return createGameFromTarget(target, opts);
}

createGameFromTarget(target, opts) holds:

  • Slice machinery (subs, fire, subscribe, view assemblers, callbacks).
  • Game-only state: currentNodeId, commentsHidden, engineState.
  • Navigation mutators (goToStart, goToPrev, goToNext, goToEnd, goToMove) — update currentNodeId, fire cursor. No Target call.
  • Chess-logic mutators (playMove, deleteFromHere, promoteVariation, makeMainline, setComment, setShapeAnnotations, toggleNag) — do chess work, call target methods to persist, fire affected slices.
  • Game-only mutators (toggleComments, publishEngineInfo) — update Game-only state, fire slice.
  • Accessors: getCurrentFen, getCurrentNodeId, getCurrentNode, getNode, getNodes, getRecord, getPgn, getMovesTo, nodeHasNag. The Target-delegating ones (getNode, getNodes, getRecord) thin-wrap target calls; the rest are derived or Game-local.

The onTargetChange wiring (§3) lives here.

Estimated ~300 lines after the slice machinery is preserved as-is.

The Tabia tests don’t observe the Target/Game split; they exercise the public Game API. Should pass unchanged.

New test file: test/local-target.test.js. Property-based or contract-style tests that exercise the Target interface directly, without going through Game. Useful for verifying conformance when OpenFileTarget is added.

mergeGames calls createGame({ record, moves }). No change needed — it goes through the implicit-LocalTarget path.

src/index.js adds:

export { createLocalTarget } from './local-target.js';
export { createGameFromTarget } from './game.js';

Both are public-but-advanced. createGame stays the obvious entry point; README and getting-started docs stay Target-free. The two new exports are documented in advanced / OpenFile-integration sections and discoverable via the test suite, but a user who reaches for createGame(pgn) never has to encounter them.

Motif and dev/dev.js don’t change. They consume Game via the existing API surface.

A focused day: ~450 lines of code moved + ~50 lines of new test scaffolding. Tabia’s existing 391 tests should pass unchanged.

Tabia’s chess logic functions can be tested with any conformant target. A test suite for playMove, toggleNag, deleteFromHere, etc. should pass against LocalTarget AND against OpenFileTarget (when built), producing equivalent observable behavior at the Game API.

Property-based tests can verify:

  • For any conformant target, applying the same Game primitives in the same order produces the same observable state.
  • Round-trip: serialize a target’s state to PGN, parse back into a fresh target, get equivalent state.
  • Cross-target equivalence: same operations against LocalTarget and OpenFileTarget produce equivalent aggregated views (Node fields). Per-author detail differs by definition.

The slice machinery has its own tests in test/game.test.js that operate at the Game level; those don’t change.


If you remember nothing else:

  • Game owns slices; Target owns storage. Slice fires happen in Game’s mutators or in response to onTargetChange from a collab target.
  • getNodes() returns the live array. Same shape lifecycle’s TreeView assumes.
  • Cursor lives on Game. Navigation / view state, not chess data. Per-user, never synced through Target.
  • onTargetChange is optional. LocalTarget omits it; OpenFileTarget uses it to surface remote ops with per-slice hints ('tree' | 'header').
  • Node shape is Tabia’s existing one. No parallel view type. Multi-author detail moves to OpenFileTarget extension methods.
  • Two factories. createGame(input) for implicit LocalTarget; createGameFromTarget(target) for explicit injection.