Engine integration (spec)
Status: design. Sibling to SPEC-lifecycle.md, which locks the engine slice shape but defers integration plumbing here.
Why this exists
Section titled “Why this exists”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
infoevents is 30+ Hz. The slice plumbing must be cheap. - Motif’s
<motif-engine-panel>already exists and consumesengine.getInfo()pull-style with manualrefresh()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.
§1 Shape recap
Section titled “§1 Shape recap”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).
Why a map, not an array
Section titled “Why a map, not an array”- 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.
§2 The publish API on Game
Section titled “§2 The publish API on Game”One method, called from outside the Game by whoever owns engine state:
game.publishEngineInfo(engineId: string, snapshot: EngineInfoSnapshot | null): voidSemantics:
- If
snapshotis non-null: merge it into the slice state underengineIdand fire the engine slice once. - If
snapshotisnull: removeengineIdfrom 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.
Snapshot semantics
Section titled “Snapshot semantics”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.
State field
Section titled “State field”The state field is a coarse top-level lifecycle marker:
idle— engine ready but no search has been requestedanalyzing— agois currently in flightstopped— search ended bybestmoveorstoperror— 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'.
§3 The bridge: attachEngine
Section titled “§3 The bridge: attachEngine”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 entryThe 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.
Throttling
Section titled “Throttling”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
infowithin 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.
Implementation sketch
Section titled “Implementation sketch”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 callengine.analyze(fen). The app drives the search itself. - The bridge does NOT manage engine lifecycle (
init,destroy). The app owns the engine handle. - Multiple
attachEnginecalls with different ids attach independently — one event subscription set per attach.
Why it lives in tabia/engine, not in Game
Section titled “Why it lives in tabia/engine, not in Game”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.
§5 UCI translation primitives
Section titled “§5 UCI translation primitives”Today’s src/engine/uci.js keeps parseInfoLine and uciPvReplay internal.
Power users (custom transports, non-handle integrations) need them. Promote
to public exports:
| Export | Signature | Use case |
|---|---|---|
createUciEngine | ({transport, name, variant}) → engine | already exported; the canonical handle |
attachEngine | (game, engine, {id}) → detach | bridge engine events to slice |
autoAnalyze | (game, engine, opts) → stop | drive analyze() from cursor |
parseInfoLine | (line: string) → InfoUpdate | parse a raw UCI info ... line |
uciPvReplay | (fen, uciMoves[]) → {moves: SAN[], movesFen[]} | reify a UCI PV against a FEN |
sanToUci | (fen, san) → uci | translate a SAN move for position ... moves |
uciToSan | (fen, uci) → san | translate a UCI move for display |
buildPositionCommand | (fen, sanMoves[]) → string | position fen ... moves ... builder |
loadEngine | (id, opts) → engine | registry-based handle factory; already exported |
listEngines, getEngineDescriptor, KNOWN_ENGINES | — | registry; already exported |
CancelledError | class | thrown 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.
§6 Usage examples
Section titled “§6 Usage examples”Single engine, drop-in panel
Section titled “Single engine, drop-in panel”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 snapshotconst panel = document.querySelector('motif-engine-panel');panel.game = game;panel.engine = engine; // command surface (pause/setMultiPv/setOption)Multi-engine comparison
Section titled “Multi-engine comparison”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;Lazy / manual analysis
Section titled “Lazy / manual analysis”Sometimes you want to attach the engine for display but only analyze on
demand. Omit analyze:
attachEngine(game, engine); // publishes only on eventsplayButton.onclick = () => engine.analyze(game.getCurrentFen(), { depth: 30 });Headless analyze (no slice)
Section titled “Headless analyze (no slice)”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.
Custom non-UCI engine
Section titled “Custom non-UCI engine”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.
§7 Motif EnginePanel migration
Section titled “§7 Motif EnginePanel migration”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
snapshotprop sourced from the engine slice view. - Commands (
pause,resume,setMultiPv,setOption) stay on theengineprop — there’s no slice analog for these.
Component-level shape
Section titled “Component-level shape”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.
Per-file changes
Section titled “Per-file changes”| File | Change | Lines |
|---|---|---|
EnginePanel.js | Add game/engineId/snapshot props; bindGame in connectedCallback; drop refresh() and the ref-based fanout | ~25 |
EngineInfoBar.js | Replace 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.js | Replace the two this.engine?.getInfo?.() calls (render, _onClick) with this.snapshot | ~15 |
EngineSettings.js | No data reads, only commands on engine handle — unchanged | 0 |
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.
Why Shape B now
Section titled “Why Shape B now”- 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).
§8 Failure modes
Section titled “§8 Failure modes”Engine crash
Section titled “Engine crash”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 mid-search
Section titled “Detach mid-search”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.
Re-attach with the same id
Section titled “Re-attach with the same id”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.
Engine attached to multiple games
Section titled “Engine attached to multiple games”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.
Auto-analyze contention
Section titled “Auto-analyze contention”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.
§9 Migration plan
Section titled “§9 Migration plan”Tabia changes
Section titled “Tabia changes”- Promote
parseInfoLine,uciPvReplayto exports insrc/engine/uci.js. - Add
sanToUci,uciToSan,buildPositionCommandtosrc/engine/. - Add
attachEngine,autoAnalyzetosrc/engine/bridge.js(new file). - Re-export from
src/engine/index.js. - Add
publishEngineInfotocreateGame, wired to fire the engine slice.
Total new code: ~80 lines (bridge.js) + ~30 lines (translation primitives) + ~15 lines (publishEngineInfo on Game) = ~125 lines.
Motif changes
Section titled “Motif changes”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.
dev/dev.js changes
Section titled “dev/dev.js changes”- Replace the manual
engine.on('info', () => enginePanelEl.refresh())chain with oneattachEngine(game, engine)call. - Replace the cursor-change →
engine.analyze(fen)chain with oneautoAnalyze(game, engine, { options: { depth: 30 } })call. - Net: ~30 lines deleted, 2 lines added.
Test impact
Section titled “Test impact”- New tests in
test/engine.test.js:attachEnginepublishes on each engine eventattachEnginecleanup removes the slice entryautoAnalyzecancels prior session on cursor change- Multi-engine: two attaches don’t trample each other
publishEngineInfodirectly (non-UCI source)
- Translation primitives: ~10 round-trip tests (
sanToUci(fen, san)thenuciToSan(fen, uci)returns same SAN).
§10 Decisions
Section titled “§10 Decisions”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.
§11 What’s next
Section titled “§11 What’s next”After this spec + SPEC-lifecycle.md are locked:
- Implement the slice machinery in
game.js(§5 of SPEC-lifecycle). - Add
publishEngineInfoon Game. - Implement
attachEngine+autoAnalyzeinsrc/engine/bridge.js. - Promote
parseInfoLine,uciPvReplay; addsanToUci/uciToSan/buildPositionCommand. - Migrate Motif EnginePanel + EngineInfoBar + EnginePV to snapshot-driven rendering (Shape B), subscribing via
bindGame. - Migrate dev/dev.js to use the bridge helpers.
- 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.