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.
What changed from v0.1
Section titled “What changed from v0.1”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
Nodeshape 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.
Background
Section titled “Background”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.
Design principle
Section titled “Design principle”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
Nodefields; 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.
§1 Interface
Section titled “§1 Interface”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;}Type definitions
Section titled “Type definitions”// 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.
§2 How Game wraps Target
Section titled “§2 How Game wraps Target”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:
- Does chess work (legality, FEN computation, NAG pairing).
- Calls one or more Target methods to persist.
- 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”Ownership map
Section titled “Ownership map”| State | Owner | Notes |
|---|---|---|
nodes (move tree) | Target | live array, read-only via getNodes() |
record (PGN tag pairs) | Target | shallow-copied via getRecord() |
startingFen | Target | immutable |
currentNodeId (cursor) | Game | navigation state, not chess data |
commentsHidden (view pref) | Game | display preference |
engineState (engine slice) | Game | populated by publishEngineInfo |
| Slice machinery (subs/fire/views) | Game | reads Target via accessors |
Game-driven changes
Section titled “Game-driven changes”For mutations initiated by Game’s mutators (the only path in LocalTarget; the common path everywhere):
- Game mutator does chess work.
- Game calls Target methods to persist.
- Game fires affected slices.
Target does NOT fire signals back up. The mutator already knows what changed.
Target-driven changes (collab only)
Section titled “Target-driven changes (collab only)”For state changes that come from outside Game — typically remote ops arriving over the network in an OpenFile session:
- Target applies the change to its internal state.
- Target fires
onTargetChange({ slices: [...] }). - 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.
Why slice-level granularity in the signal
Section titled “Why slice-level granularity in the signal”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.
§4 Construction API
Section titled “§4 Construction API”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.
Nodefields reflect literal stored values. - Hard-delete.
deleteSubtreecascade-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/removeHeaderuntil 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, includingcomment_segments/variationOwnerwhen 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 mappingnodeHash → intlives inside the target), so Motif components don’t see hash strings. - Aggregated views.
Node.commentis the joined comment across authors;Node.nagsis 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 baseNodeshape. - Visibility ⇒ null in
getNodes(). Nodes that are tombstoned or hidden by cascading invisibility (per OpenFile’s visibility rules) appear asnullin 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.
setCommentemits a tombstone-old + newaddSegment;addNodeemits anaddNodeop; etc. Caller (Game) doesn’t see the difference — same imperative API. onTargetChangeis 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.
§7 Why this shape
Section titled “§7 Why this shape”A few choices worth justifying.
Node ids are integers, not opaque
Section titled “Node ids are integers, not opaque”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.
Writes express user intent, not raw data
Section titled “Writes express user intent, not raw data”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.
Slice-level granularity in onTargetChange
Section titled “Slice-level granularity in onTargetChange”See §3. Cheap for collab targets to provide; saves Game from re-firing everything on every peer message.
Synchronous API
Section titled “Synchronous API”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.
§8 What this interface does NOT include
Section titled “§8 What this interface does NOT include”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;
publishEngineInfois 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’sgetPgn()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.
§9 Open questions
Section titled “§9 Open questions”-
onTargetChangegranularity. 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 fromgetNodes()anyway. Per-node hinting is a future optimization if profiling shows it matters. -
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.
-
MoveData/NodePayloadnaming. v0.1 usedMoveData. v0.2 usesNodePayloadto reflect that the addNode call may carry merge-attribution fields beyond just the move itself. Worth bikeshedding? Probably not.
Resolved in v0.2
Section titled “Resolved in v0.2”getStartingFen()as a Target method. Included. Small win; documents the immutable-for-target’s-lifetime contract clearly and OpenFileTarget wants this stable anyway.- Should
createGameaccept 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.
§10 Refactor implications for Tabia
Section titled “§10 Refactor implications for Tabia”Concrete steps to land the abstraction. Order assumes the lifecycle migration is already in (it is).
Step 1 — Extract LocalTarget
Section titled “Step 1 — Extract LocalTarget”New file: src/local-target.js. Move into it:
- The chess-data state currently held by
createGame:nodes,record,startingFen, plus the internal helpersbuildMoveTree,deleteSubtree,promoteToMainChild. (Cursor stays on Game.) - Public methods per the interface:
getNodes,getNode,getRecord,getStartingFen,addNode,deleteSubtree,promoteToMainChild,setComment,setNagSet,setShapeAnnotations. createLocalTarget(input, { onParseError }). TheonParseErrorcallback fires during the parse phase ofbuildMoveTree, same semantics as today.createGamepasses itsopts.onParseErrorthrough transparently — apps that construct viacreateGame(pgn, { onParseError })see no change.
Estimated ~150 lines.
Step 2 — Refactor createGame → createGameFromTarget + 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) — updatecurrentNodeId, firecursor. 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.
Step 3 — Update tests
Section titled “Step 3 — Update tests”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.
Step 4 — Update mergeGames
Section titled “Step 4 — Update mergeGames”mergeGames calls createGame({ record, moves }). No change needed —
it goes through the implicit-LocalTarget path.
Step 5 — Update exports
Section titled “Step 5 — Update exports”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.
Estimated scope
Section titled “Estimated scope”A focused day: ~450 lines of code moved + ~50 lines of new test scaffolding. Tabia’s existing 391 tests should pass unchanged.
§11 Testing strategy
Section titled “§11 Testing strategy”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 (
Nodefields). 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.
Summary of what’s load-bearing
Section titled “Summary of what’s load-bearing”If you remember nothing else:
- Game owns slices; Target owns storage. Slice fires happen in
Game’s mutators or in response to
onTargetChangefrom 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.
onTargetChangeis optional. LocalTarget omits it; OpenFileTarget uses it to surface remote ops with per-slice hints ('tree' | 'header').Nodeshape 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.