Skip to content

Engine integration (spec)

Status: design. Sibling to SPEC-lifecycle.md, which locks the engine slice shape but defers integration plumbing here.

The engine slice is in v1, but how Game and an engine actually wire together is its own problem with chess-shaped answers:

  • An engine is a long-lived handle, not a pure data source. It has commands (pause, resume, setMultiPv, setOption, analyze(fen)). The slice carries snapshots; the commands stay imperative.
  • An engine handle exists outside the Game’s lifetime. One engine can serve many games (sequential analysis); one game can attach many engines (comparison). Game can’t own engine lifecycle.
  • The natural fire rate of engine info events is 30+ Hz. The slice plumbing must be cheap.
  • Motif’s <motif-engine-panel> already exists and consumes engine.getInfo() pull-style with manual refresh() from engine events. The migration must improve, not break, that integration.

The goal: a small set of stable primitives that compose into single-engine, multi-engine, and headless-analysis use cases — with the engine slice as the re-render signal and the engine handle as the command surface.


The engine slice carries one entry per attached engine, keyed by engineId:

type EngineSliceView = {
[engineId: string]: EngineInfoSnapshot;
};
type EngineInfoSnapshot = {
engineId: string;
name: string;
variant: string;
state: 'idle' | 'analyzing' | 'stopped' | 'error';
paused: boolean; // engine.isPaused() at snapshot time
multipvSetting: number; // current setMultiPv() value (1..N)
fen: string; // position currently being analyzed
whiteToMove: boolean;
// Search stats
depth: number | null;
seldepth: number | null;
nodes: number | null;
nps: number | null;
time: number | null; // ms elapsed
hashfull: number | null; // 0-1000 per UCI
tbhits: number | null;
// Principal variations
multipv: Array<{
pvId: number; // 1-indexed
score: { type: 'cp' | 'mate', value: number };
depth: number;
moves: string[]; // SAN sequence from FEN
movesUci: string[]; // same line, UCI tokens
movesFen: string[]; // FEN after each move (lazy)
wdl?: [number, number, number]; // 1000-scaled if engine emits
}>;
bestmove: string | null; // SAN of bestmove if search has ended
sessionId: number; // monotonic; bumps on each analyze()
};

This is the shape produced by engine.getInfo() today — the slice view IS the engine snapshot. No new shape to learn for consumers already on the engine module.

The slice fires whenever a new snapshot is published for any engineId. Subscribers receive the whole slice view and route by id themselves; a single-engine consumer reads view['default'] (or whatever id was used).

  • Subscribers want O(1) lookup by id for the engine they care about.
  • A multi-engine UI keyed by id matches the underlying data model.
  • A view that’s already a routing table sidesteps the “what does it mean if this engine isn’t in the array” question.

One method, called from outside the Game by whoever owns engine state:

game.publishEngineInfo(engineId: string, snapshot: EngineInfoSnapshot | null): void

Semantics:

  • If snapshot is non-null: merge it into the slice state under engineId and fire the engine slice once.
  • If snapshot is null: remove engineId from the slice state and fire. Used on detach.
  • The Game does not validate snapshot contents — the bridge or app is responsible for shape correctness. Garbage in, garbage out.
  • The Game does not introspect engine handles. It receives plain objects.

This is the entire surface. publishEngineInfo is the ONLY mutator that fires the engine slice; everything else (attachEngine, autoAnalyze) is built on top.

The reason it’s a top-level Game method (not buried in the engine module): the slice machinery lives on Game, and publishEngineInfo is how non-Game state enters the slice. Keeping it on Game means a consumer can publish custom snapshots (e.g., from a non-UCI engine) without going through the engine module at all.

Snapshots are point-in-time states, not deltas. Each call replaces the previous snapshot for that engineId. A bridge that wants to drop redundant fires can de-dupe before calling publish; the Game does not.

The state field is a coarse top-level lifecycle marker:

  • idle — engine ready but no search has been requested
  • analyzing — a go is currently in flight
  • stopped — search ended by bestmove or stop
  • error — engine crashed or transport closed (terminal until reattach)

It mirrors what engine.on('state', ...) emits today. Consumers gate UI (“Engine crashed, please reload”) on state === 'error'.


attachEngine wires a UCI engine handle’s events to game.publishEngineInfo, optionally driving analysis from cursor changes in the same call:

import { attachEngine } from 'tabia/engine';
const detach = attachEngine(game, engine, {
id: 'default', // engineId in the slice; default 'default'
analyze: true, // also wire autoAnalyze; default false
options: { depth: 30 }, // forwarded to engine.analyze when analyze: true
when: (cursor) => true, // optional cursor filter when analyze: true
throttleMs: 500, // throttle 'info' fires; default 500 (2Hz)
});
detach(); // stops autoAnalyze, unbinds events, clears the slice entry

The analyze: true shortcut is the common case (“turn the engine on” = “analyze from here on”). Calling without it gives bare event→slice publishing for apps that drive analysis manually.

UCI engines emit info lines at 30+ Hz. Driving the slice (and therefore Lit re-renders) at that rate is wasteful and visually jittery — the eye can’t follow numbers updating faster than ~2Hz anyway. attachEngine throttles info events to a leading + trailing pattern with throttleMs between fires:

  • First info: publishes immediately.
  • Subsequent info within the window: coalesced into one trailing publish at the window’s end (latest snapshot wins).
  • bestmove, state, option, error: pass through immediately — they’re terminal/infrequent and apps need them prompt.

Default 500 (2Hz). Set lower for high-fidelity debug views, higher (1000/2000) for low-attention dashboards. 0 disables throttling.

function attachEngine(game, engine, opts = {}) {
const { id = 'default', analyze = false, options, when, throttleMs = 500 } = opts;
const buildSnapshot = () => ({
engineId: id, name: engine.name, variant: engine.variant,
paused: engine.isPaused?.() ?? false,
multipvSetting: engine.getMultiPv?.() ?? 1,
...engine.getInfo(),
});
const publish = () => game.publishEngineInfo(id, buildSnapshot());
let pending = false;
let timer = null;
const publishThrottled = () => {
if (throttleMs === 0) return publish();
if (timer) { pending = true; return; }
publish();
timer = setTimeout(() => {
timer = null;
if (pending) { pending = false; publishThrottled(); }
}, throttleMs);
};
const offs = [
engine.on('info', publishThrottled),
engine.on('bestmove', publish),
engine.on('state', publish),
engine.on('option', publish),
engine.on('error', publish),
];
publish(); // initial snapshot
const stopAnalyze = analyze ? autoAnalyze(game, engine, { options, when }) : null;
return () => {
stopAnalyze?.();
if (timer) clearTimeout(timer);
offs.forEach((off) => off());
game.publishEngineInfo(id, null);
};
}

Concrete properties:

  • Without analyze: true, the bridge does NOT call engine.analyze(fen). The app drives the search itself.
  • The bridge does NOT manage engine lifecycle (init, destroy). The app owns the engine handle.
  • Multiple attachEngine calls with different ids attach independently — one event subscription set per attach.

game.publishEngineInfo is generic — it works with any object shape. The UCI-specific bridge belongs with the rest of the UCI code in src/engine/. Game stays variant-and-engine-agnostic; the engine module provides convenience bridges. Apps that don’t use the engine module never load this code.


§4 The cursor-driven analysis loop: autoAnalyze

Section titled “§4 The cursor-driven analysis loop: autoAnalyze”

attachEngine({analyze: true}) uses autoAnalyze under the hood; autoAnalyze is also exported standalone for apps that want analysis without publishing to the slice (headless tools, shared-engine setups):

import { autoAnalyze } from 'tabia/engine';
const stop = autoAnalyze(game, engine, {
when: (cursor) => true, // optional filter
options: { depth: 30 }, // forwarded to engine.analyze
});
// later:
stop();

Implementation:

function autoAnalyze(game, engine, { when, options } = {}) {
let session = null;
const unsub = game.subscribe('cursor', (view) => {
if (when && !when(view)) return;
session?.cancel?.();
session = engine.analyze(view.fen, options);
session.done?.catch?.(() => {}); // swallow CancelledError
});
return () => {
unsub();
session?.cancel?.();
};
}

autoAnalyze is intentionally tiny — the canonical pattern lifted verbatim from the dev harness. Apps that need different semantics (debounce, manual triggering, analyze-only-on-leaf) write their own.


Today’s src/engine/uci.js keeps parseInfoLine and uciPvReplay internal. Power users (custom transports, non-handle integrations) need them. Promote to public exports:

ExportSignatureUse case
createUciEngine({transport, name, variant}) → enginealready exported; the canonical handle
attachEngine(game, engine, {id}) → detachbridge engine events to slice
autoAnalyze(game, engine, opts) → stopdrive analyze() from cursor
parseInfoLine(line: string) → InfoUpdateparse a raw UCI info ... line
uciPvReplay(fen, uciMoves[]) → {moves: SAN[], movesFen[]}reify a UCI PV against a FEN
sanToUci(fen, san) → ucitranslate a SAN move for position ... moves
uciToSan(fen, uci) → santranslate a UCI move for display
buildPositionCommand(fen, sanMoves[]) → stringposition fen ... moves ... builder
loadEngine(id, opts) → engineregistry-based handle factory; already exported
listEngines, getEngineDescriptor, KNOWN_ENGINESregistry; already exported
CancelledErrorclassthrown by aborted analyze() sessions; already exported

sanToUci, uciToSan, buildPositionCommand are new. They wrap chess.js move generation already used internally and give consumers stable, named primitives instead of “look at uci.js and rebuild this yourself.”

The bar for adding a new primitive: a real consumer needs it and the wrap saves a non-trivial chess.js dance.


import { createGame } from 'tabia';
import { loadEngine, attachEngine } from 'tabia/engine';
const game = createGame(pgn);
const engine = await loadEngine('stockfish-18-lite-single', {
workerUrl: '/engines/stockfish-18-lite-single.js',
});
attachEngine(game, engine, { analyze: true, options: { depth: 30 } });
// Panel self-subscribes to the engine slice for its snapshot
const panel = document.querySelector('motif-engine-panel');
panel.game = game;
panel.engine = engine; // command surface (pause/setMultiPv/setOption)
const stockfish = await loadEngine('stockfish-18-lite-single', { workerUrl: '...' });
const maia = await loadEngine('maia-1900', { workerUrl: '...' });
attachEngine(game, stockfish, { id: 'sf', analyze: true, options: { depth: 30 } });
attachEngine(game, maia, { id: 'maia', analyze: true, options: { depth: 12 } });
sfPanel.game = game; sfPanel.engineId = 'sf'; sfPanel.engine = stockfish;
maiaPanel.game = game; maiaPanel.engineId = 'maia'; maiaPanel.engine = maia;

Sometimes you want to attach the engine for display but only analyze on demand. Omit analyze:

attachEngine(game, engine); // publishes only on events
playButton.onclick = () => engine.analyze(game.getCurrentFen(), { depth: 30 });

A batch tool that just wants results doesn’t need a Game:

const engine = await loadEngine('stockfish-18-lite-single', { workerUrl: '...' });
const session = engine.analyze(fen, { depth: 35 });
const result = await session.done;
console.log(result.bestmove, result.multipv);

Tabia engine module works fine without Tabia Game.

An app with a non-UCI engine (cloud service, mock) skips attachEngine and calls publish directly:

cloudEngine.on('result', (r) => {
game.publishEngineInfo('cloud', {
engineId: 'cloud',
name: 'CloudEval',
variant: 'standard',
state: 'stopped',
fen: r.fen,
depth: r.depth,
multipv: r.lines.map((l) => ({ pvId: l.id, score: l.score, depth: r.depth, moves: l.san, movesUci: l.uci, movesFen: [] })),
// ... etc
});
});

The slice carries this just as cleanly as a UCI snapshot. Consumers don’t need to know the source.


Today the panel reads engine.getInfo() from render() and is refreshed imperatively by the app on each engine event. The migration splits these:

  • Data (search state, multipv, depth, nps, paused state, etc.) flows through a snapshot prop sourced from the engine slice view.
  • Commands (pause, resume, setMultiPv, setOption) stay on the engine prop — there’s no slice analog for these.
class MotifEnginePanel extends LitElement {
static properties = {
game: { attribute: false }, // for slice subscription
engineId: { type: String }, // routes the slice view; default 'default'
engine: { attribute: false }, // command surface
snapshot: { attribute: false, state: true }, // populated from slice
};
connectedCallback() {
super.connectedCallback();
if (this.game) {
bindGame(this, this.game, {
engine: (view) => { this.snapshot = view[this.engineId ?? 'default']; },
});
}
}
render() {
return html`
<motif-engine-info-bar .snapshot=${this.snapshot} .engine=${this.engine}>
<motif-engine-pv .snapshot=${this.snapshot}>
${this.onSettingsClick ? '' : html`
<motif-engine-settings .engine=${this.engine}>
`}
`;
}
}

No refresh() method, no manual app wiring of engine events to the panel. The panel mounts, finds its engineId in the slice view, and renders.

FileChangeLines
EnginePanel.jsAdd game/engineId/snapshot props; bindGame in connectedCallback; drop refresh() and the ref-based fanout~25
EngineInfoBar.jsReplace eng()?.getInfo?.() reads with host.snapshot?.X; replace eng()?.name/.variant/.isPaused()/.getMultiPv() with host.snapshot?.X; commands (pause/resume/setMultiPv) still call host.engine.X()~30
EnginePV.jsReplace the two this.engine?.getInfo?.() calls (render, _onClick) with this.snapshot~15
EngineSettings.jsNo data reads, only commands on engine handle — unchanged0

The engine prop stays on every sub-component that needs the command surface (info bar for pause/multipv, settings for setOption). The snapshot prop is the data feed.

  • The lifecycle migration touches every component anyway — bundling the panel into the same pass costs ~3 extra hours and means no follow-up migration ever lands on it.
  • Snapshot-driven rendering makes fixture testing trivial: feed a hand-rolled snapshot, render, assert. Today you’d need a real engine handle (or a mock implementing getInfo/isPaused/getMultiPv).
  • The split between data (snapshot) and commands (engine) is the cleaner mental model and aligns the panel with how every other Motif component consumes Tabia (slice subscription for data, method calls for commands).

engine.on('error', ...) fires with details; state transitions to error. The bridge republishes the snapshot with state: 'error'. Subscribers gate UI on this — typical pattern: show a “Engine crashed, reload” banner; offer a button that calls engine.destroy() + recreates.

The slice does NOT auto-restart engines. App-level policy decides.

detach() unbinds events but does NOT cancel the current analyze() session — the app owns the session via autoAnalyze or direct engine.analyze calls. If the app wants the engine to stop searching when detaching, it stops autoAnalyze first, then detaches.

After detach, the slice entry is removed. Subscribers receive the post-detach view with that engineId absent. Components keyed on engineId should render an empty state.

Replaces the prior attach. The bridge for the previous handle is NOT automatically cleaned — the caller is expected to call its detach() first. We don’t add bookkeeping for “engine id collision” because it’s a caller discipline issue, not a Game responsibility.

Allowed but discouraged. Each attachEngine(g, e, {id}) independently publishes to its game’s slice. Since the engine has a single getInfo() state, both games’ slices receive the same snapshot per event. Useful for a “compare across games” view; usually the app instantiates one engine per game.

autoAnalyze cancels its previous session before starting a new one. With one engine and one autoAnalyze, contention is bounded. With one engine and two autoAnalyze subscriptions (e.g., two games sharing an engine), they will fight — last-cursor-change wins. This is fine for sane usage; if it becomes a real problem, add engine.busy arbitration in a future helper.


  1. Promote parseInfoLine, uciPvReplay to exports in src/engine/uci.js.
  2. Add sanToUci, uciToSan, buildPositionCommand to src/engine/.
  3. Add attachEngine, autoAnalyze to src/engine/bridge.js (new file).
  4. Re-export from src/engine/index.js.
  5. Add publishEngineInfo to createGame, wired to fire the engine slice.

Total new code: ~80 lines (bridge.js) + ~30 lines (translation primitives) + ~15 lines (publishEngineInfo on Game) = ~125 lines.

Per §7: snapshot/engine split across EnginePanel + EngineInfoBar + EnginePV. ~70 lines across three files. App stops calling enginePanelEl.refresh() manually; panels are passed a game and self-subscribe via bindGame.

  • Replace the manual engine.on('info', () => enginePanelEl.refresh()) chain with one attachEngine(game, engine) call.
  • Replace the cursor-change → engine.analyze(fen) chain with one autoAnalyze(game, engine, { options: { depth: 30 } }) call.
  • Net: ~30 lines deleted, 2 lines added.
  • New tests in test/engine.test.js:
    • attachEngine publishes on each engine event
    • attachEngine cleanup removes the slice entry
    • autoAnalyze cancels prior session on cursor change
    • Multi-engine: two attaches don’t trample each other
    • publishEngineInfo directly (non-UCI source)
  • Translation primitives: ~10 round-trip tests (sanToUci(fen, san) then uciToSan(fen, uci) returns same SAN).

attachEngine consolidates with autoAnalyze via analyze: true. The common case is “turn the engine on” = “start analyzing.” Same call, same detach. autoAnalyze remains exported for apps that want it standalone (e.g., headless or shared across multiple engines).

No attachedAt timestamp on the snapshot. Not enough motivation; debug consumers can derive it from the first state: 'analyzing' they see.

Throttle by default at 500ms (2Hz). The UI doesn’t benefit from faster updates and burning re-renders at 30Hz is wasteful. Configurable via throttleMs; 0 disables. bestmove/state/option/error always pass through immediately.

GAME-CONTRACT.md addendum is opt-in. Engine integration is a Tabia-side convenience for apps that want it. The chess-semantics contract stays small; the addendum just notes “if you also want engine analysis state, subscribe to the engine slice and pass the handle for commands.” A Game implementation isn’t required to support any of it.


After this spec + SPEC-lifecycle.md are locked:

  1. Implement the slice machinery in game.js (§5 of SPEC-lifecycle).
  2. Add publishEngineInfo on Game.
  3. Implement attachEngine + autoAnalyze in src/engine/bridge.js.
  4. Promote parseInfoLine, uciPvReplay; add sanToUci/uciToSan/buildPositionCommand.
  5. Migrate Motif EnginePanel + EngineInfoBar + EnginePV to snapshot-driven rendering (Shape B), subscribing via bindGame.
  6. Migrate dev/dev.js to use the bridge helpers.
  7. Update GAME-CONTRACT.md with the engine-data addendum.

The work fits inside the broader lifecycle migration — engine plumbing piggybacks on the slice infrastructure rather than needing its own pass.