Phase A.5 of the rule-variants epic — the documentation gate. - Movement section now leads with a 7-step dispatch diagram covering the full per-piece-to-aggregate pipeline. Every new hook points at its step explicitly so future preset authors know where each hook slots in. - New hook reference entries with canonical-user callouts: overridePieceMoves, filterLegalMoves, getRoyalPieces (new Royalty section), shouldAdvanceTurn (new Turn flip section). Every entry has a concrete code snippet illustrating typical usage. - Design Notes 'Hook firing order' split into two sequences — the legal-move query (1-4) and move application (1-11). Application sequence now correctly reflects HalfMovesThisTurn increment + shouldAdvanceTurn poll + onTurnStart gating. - Scope-aware table extended with the 4 new hooks. - Notepad appended with Phase A close-out: 1417 → 1448 unit tests, 80/80 Playwright green, zero lint/type errors, four commits on master (4d05473,db8145f,f9475e9,1a11491).
25 KiB
Preset API Reference
This document is the contract between the chess engine core and preset authors. Every extension point is listed here. If a capability you need isn't in this doc, it either isn't supported or hasn't landed yet.
Audience: developers adding new presets (either in-tree in packages/chess/src/presets/ or as an external package that imports @paratype/chess).
Philosophy: the engine is a FIDE-minimum chess runtime with a compositional plugin surface. New mechanics — HP, custom pieces, visual effects, move history, resource tracking — attach via registries and hooks. Engine core should stay stable; extensions stay additive.
Architecture at a glance
┌──────────────────┐ declares attrs, registers hooks
│ PresetDef │──────────┐
└──────────────────┘ │
▼
┌────────────────────────────────────────────────────┐
│ PRESET_REGISTRY │
│ • getAll() / get(id) │
└────────────────────────────────────────────────────┘
│
▼
┌──────────────────┐ applyMove / dealDamage / spawnPiece / emitEffect
│ ChessEngine │──┬───────────────────────────────┐
│ │ │ PIECE_TYPE_REGISTRY │
│ • session │ │ moveGenerator / attackProbe │
│ • moveLog │ │ assets │
│ • activePresets │ └───────────────────────────────┘
│ • presetState │
│ • emitEffect │
└──────────────────┘
Writing a preset
Minimal preset — registers itself at module load via a side-effect import. Consumers add an import line in packages/chess/src/presets/index.ts.
import { PRESET_REGISTRY } from "./registry.js";
PRESET_REGISTRY.register({
id: "my-preset",
name: "My Preset",
description: "What this preset does in one sentence.",
incompatibleWith: [],
requires: [],
// ... hooks below
});
Required fields
id: string— stable identifier. Used for UI toggles, persistence,engine.presetState, hook dispatch order. Cannot be renamed without breaking saved games.name: string— display name for UI. Can change freely.description: string— one-line explainer shown in the rules drawer. Include variant-defining details ("captures deal 1 damage instead of killing").incompatibleWith: string[]— preset ids that must NOT be active under overlapping scope. Mutual — if A declares B incompatible, B must declare A too.requires: string[]— preset ids that MUST be active under overlapping scope. Examples:king-healsrequirespiece-hp.
Optional: declared attributes
pieceAttributes: ["Shield"],
When your preset owns a piece-level attribute (HP, Shield, Stamina, Mana, Armor), declare it here. The engine unions pieceAttributes from every active preset into engine.effectivePieceAttrs. That list drives:
- The default damage-retract path (dead pieces lose all effective attrs).
queen-splits's attacker retraction during fission.- Save-state serialization (all facts captured, including preset-declared attrs).
The attr MUST be declared in ChessAttrMap (via declare module "../schema" augmentation or by editing schema.ts directly). Adding a brand-new fact-type requires one schema edit + one preset edit.
Hook reference
Hooks take a single context object (rather than positional args) so new fields are additive. All hooks are optional — implement only what your preset needs.
Lifecycle
onActivate(ctx: LifecycleContext)
Fires when the preset transitions inactive → active. Use for:
- Seeding initial state (e.g.
Hp=2on every existing piece). - Mutating the session to reflect the preset's requirements.
onActivate({ engine }) {
for (const id of piecesOnBoard(engine)) {
engine.session.insert(id, "Hp", 2);
}
}
onDeactivate(ctx: LifecycleContext)
Symmetric cleanup. Fires when:
- User toggles preset off via the UI.
- Turn-timer expires (
turnsRemaining: 0). - Server issues
setPresetsthat excludes this preset.
Retract anything onActivate inserted. Preset-state is cleared automatically AFTER this hook returns.
onDeactivate({ engine }) {
for (const id of piecesOnBoard(engine)) {
if (engine.session.contains(id, "Hp")) {
engine.session.retract(id, "Hp");
}
}
}
Movement
The engine runs a 7-step dispatch for every legal-move query. Every hook below slots into a specific step. Read this before authoring a movement preset — conflating the steps is the #1 source of "my preset fires but its moves don't show up" bugs.
Per-piece dispatch (inside engine.getAllLegalMoves):
1. overridePieceMoves — first non-undefined wins, REPLACES default
2. PIECE_TYPE_REGISTRY — default per-type generator (skipped if 1 won)
3. transformMoveGenerator — wrap chain around 2 (skipped if 1 won)
4. getExtraMoves — each preset APPENDS to the piece's moves
(skipped if 1 won; collision-free because
it's additive)
5. En-passant / castling / promotion synthesis — skipped if 1 won; the
override owns those mechanics if it replaces pawns or kings.
6. filterMoves — each preset filters this piece's moves
(ALWAYS runs, even with override)
Aggregate dispatch (after all pieces collected):
7. Self-check filter — drops king-exposing moves.
Suppressible via shouldFilterSelfCheck.
8. filterLegalMoves — each preset filters the full list.
Runs in activePresets.list() order;
each sees the prior's output.
overridePieceMoves(engine, pieceId): readonly LegalMove[] | undefined
REPLACE the default move generator for a specific piece. First preset to return non-undefined wins. The winning set SKIPS: the type-registry generator, the transformMoveGenerator chain, getExtraMoves for THIS piece, and en-passant/castling/promotion synthesis. filterMoves and downstream filters still run.
Collision semantics: when two presets both return non-undefined for the same piece, the first wins and the engine emits a dev-mode console.warn naming both. This is a guardrail, not an opt-in — conflicts should be caught at config time via incompatibleWith. Warnings are suppressed when NODE_ENV=production.
overridePieceMoves(engine, pieceId) {
const type = engine.session.get(pieceId, "PieceType");
if (type !== "pawn") return undefined;
// Berolina pawns: forward diagonals push, forward orthogonal captures.
// Return the full replacement set; filter layers still compose on top.
return buildBerolinaMoves(engine, pieceId);
}
Canonical user: berolina-pawns (pawn redefinition).
getExtraMoves(engine, pieceId): LegalMove[]
Contribute additional legal moves for a specific piece. Used by wrap-board, knights-leap-twice, etc. NOT a context-object hook because it's called in a tight loop per piece per legal-move query — positional args are the perf-friendly choice. SKIPPED for pieces that have an active overridePieceMoves.
filterMoves(moves, engine, pieceId): LegalMove[]
Remove or modify moves from the aggregated list. Used by knight-immunity (filters captures of knights). ALWAYS runs, even on top of overridePieceMoves output.
filterLegalMoves(ctx: FilterLegalMovesContext): readonly LegalMove[]
Post-aggregation filter on the full per-color legal-move list. Runs AFTER the self-check filter, so this hook sees moves that are guaranteed to not leave the king in check. Iterated in activePresets.list() order; each preset sees the prior's output.
The returned array MUST be a subset of ctx.moves. Use getExtraMoves to CONTRIBUTE moves and this hook to DROP them. The engine does not enforce the subset contract at runtime — adding additional moves here works today but is considered a bug by contract.
filterLegalMoves({ moves }) {
// Suicide-chess: compulsory capture.
const anyCapture = moves.some(m => m.isCapture);
if (!anyCapture) return moves;
return moves.filter(m => m.isCapture);
}
Canonical user: suicide-chess (compulsory capture).
Damage
onDamage(ctx: DamageHookContext): DamageHookResult | void
The damage pipeline. Called by engine.dealDamage(target, amount, ctx). First preset to return { consume: true, died } wins — further presets are not consulted.
onDamage({ engine, target, amount, kind }) {
if (!engine.session.contains(target, "Hp")) return undefined;
const current = engine.session.get(target, "Hp") as number;
const next = current - amount;
if (next > 0) {
engine.session.insert(target, "Hp", next);
return { consume: true, died: false };
}
// Lethal — retract all effective attrs and report death.
for (const attr of engine.effectivePieceAttrs) {
if (engine.session.contains(target, attr)) engine.session.retract(target, attr);
}
return { consume: true, died: true };
}
Scope-aware: only fires for presets whose scope covers the target's color. A scope=white preset won't absorb damage on black pieces.
Damage kinds (extend as needed): "capture" (default from applyMove), "explosion" (explosive-rook), "poison" (poisoned-squares). Arbitrary strings allowed — document what YOUR preset emits.
Capture
onBeforeCapture(ctx: CaptureHookContext): CaptureHookResult | void
Fires BEFORE the engine resolves a capture. Return { consume: true } to REPLACE the default capture mechanic entirely — queen-splits fission, explosive-rook AoE, capture-to-win winner-recording. The consuming preset owns ALL post-capture behavior, including whether the attacker moves.
Unconsumed captures fall through to engine.dealDamage with kind: "capture".
Spawn
onPieceSpawn(ctx: PieceSpawnContext)
Fires when engine.spawnPiece() creates a new entity. Every active preset gets a chance to tag the piece (seed Hp, mark Shield, add custom attrs). Runs AFTER core attrs are inserted.
onPieceSpawn({ engine, pieceId }) {
engine.session.insert(pieceId, "Hp", DEFAULT_HP);
}
Phase hooks
onBeforeMove(ctx: BeforeMoveContext): BeforeMoveResult | void
Fires after move legality confirmed, BEFORE any mutation. Can veto by returning { cancel: true, reason: "..." }. The engine throws MoveCancelledError which the caller surfaces via toast.
Scope-aware: only fires for presets whose scope covers the mover.
Use cases: stamina systems, action-point economies, cooldowns.
onBeforeMove({ engine, pieceId }) {
const stamina = engine.session.get(pieceId, "Stamina") as number ?? 0;
if (stamina < 1) return { cancel: true, reason: "Piece too tired" };
engine.session.insert(pieceId, "Stamina", stamina - 1);
}
onAfterMove(ctx: MoveHookContext)
Fires AFTER all move mutations but BEFORE terminal-state check. Used by king-heals, poisoned-squares.
Fires for EVERY active preset regardless of scope — the preset decides based on ctx.mover. (This asymmetry is historical — changing it would break existing presets. See Design Notes.)
onTurnStart(ctx: TurnStartContext)
Fires AFTER turn counter flips and AFTER onAfterMove, BEFORE legal-move computation for the new side. Ideal for:
- Regenerating per-piece resources (+1 Stamina up to max).
- Decrementing cooldowns.
- Refreshing auras / blessings.
Scope-aware: only fires for presets whose scope covers the new side-to-move.
Terminal state
onCheckGameResult(ctx: GameResultHookContext): GameResult | undefined
Override the engine's default checkmate/stalemate/draw logic. First preset to return a non-undefined value wins. Used by:
capture-to-win— first capture ends the game.last-piece-standing— annihilation replaces checkmate.piece-hp— suppresses default terminal check when kings can survive attacks.
Self-check filter
shouldFilterSelfCheck(ctx: SelfCheckFilterContext): boolean | undefined
Opt out of the engine's "you can't leave your king in check" move filter. Used by piece-hp — with HP active, a king in check just takes damage, it doesn't lose.
Return false to skip the filter, true / undefined to keep it.
Royalty
getRoyalPieces(ctx: RoyalContext): readonly EntityId[] | undefined
Declare which piece entities count as "royal" (attack/mate on them ends the game) for ctx.color. The engine UNIONS contributions across every active preset and threads the resolved set through isInCheck / isCheckmate / isStalemate / the self-check filter.
Return semantics:
undefined→ this preset has no opinion; fall through.readonly EntityId[]→ contribute these entities. Deduped viaSetacross presets.- Empty array
[]→ valid non-undefined contribution. When the UNION is empty, the engine treats the position as having NO royalty —isInCheckshort-circuits to false, self-check filtering is a no-op. Used by suicide-chess / capture-all where king safety doesn't apply.
When NO preset contributes, the engine falls back to "every PieceType===king of this color" — the FIDE default.
getRoyalPieces({ engine, color }) {
// Knightmate: every knight is royal.
const ids: EntityId[] = [];
for (const f of engine.session.allFacts()) {
if (f.attr !== "PieceType" || f.value !== "knight") continue;
if (engine.session.get(f.id, "Color") === color) ids.push(f.id);
}
return ids;
}
Canonical users: knightmate-rules (knight royal), coregal (king + queen), dual-king / weak-dual-king (multiple kings), suicide-chess / capture-all (empty-royalty).
Turn flip
shouldAdvanceTurn(ctx: TurnAdvanceContext): boolean | undefined
Decide whether the current turn should flip after this move. The engine increments HalfMovesThisTurn BEFORE polling this hook, so ctx.halfMovesThisTurn is a 1-indexed count of half-moves in the CURRENT turn INCLUDING the move that just committed.
Return semantics:
false→ VETO the flip. First veto wins; engine stops polling. Mover plays again,HalfMovesThisTurnis NOT reset (next move's poll sees an incremented count).true/undefined→ no opinion; subsequent presets and the default flip proceed.
When the flip happens: Turn updates, HalfMovesThisTurn resets to 0, FullmoveNumber increments after black. When vetoed, none of these change; onTurnStart does NOT fire (so mid-turn resource regen doesn't trigger on the mover's second half-move).
shouldAdvanceTurn({ mover, halfMovesThisTurn }) {
// Monster-rules: white plays 2 half-moves before flipping, black plays 1.
if (mover === "white" && halfMovesThisTurn < 2) return false;
return undefined;
}
Canonical users: double-move (both sides play 2), monster-rules (white plays 2, black plays 1).
Description
describeMoveEffect(ctx: DescribeMoveEffectContext): string | undefined
Contribute to the MoveRecord.presetEffects array. Move-list UI, PGN export, and replay systems read these for human-readable annotations.
describeMoveEffect({ mover, movingType, capturedType }) {
if (movingType === "queen" && capturedType !== null) {
return `${mover} queen fissioned into R+B`;
}
return undefined;
}
Engine APIs available from hooks
Every hook receives ctx.engine: ChessEngine. Useful methods:
engine.session
The rete Session — direct insert/retract/get. Read preset-declared facts here.
engine.spawnPiece(type, color, square, opts?): EntityId
Create a piece. Fires onPieceSpawn on every active preset. Use this instead of direct session inserts when creating pieces mid-game (fission, summon, resurrection).
engine.dealDamage(target, amount, { kind, attacker? })
Canonical damage primitive. Routes through the onDamage pipeline (scope-aware). Returns { died: boolean }. Use this instead of direct retraction — HP-like absorber presets get a chance to intercept.
engine.emitEffect({ kind, square, ttl, data? })
Push a visual effect to subscribers. UI renders it. Presets emit from inside their hooks; no subscribers = no-op.
engine.presetState<T>(id)
Typed per-preset state bag. { get, set, delete, has, all }. Backed by session facts, so serialization is free. Auto-cleared on deactivate.
engine.moveLog
Read-only chronological list of MoveRecords. Use from UI components or tests; presets typically don't need it.
engine.effectivePieceAttrs
Full list of attribute keys currently treated as "piece state" — core + preset-declared. Use when you need to retract every attribute on an entity (e.g. custom death handling).
engine.setActivePresets(requests)
Replace the active set. Fires onDeactivate for dropped ids and onActivate for new ids. Validates incompatibleWith + requires constraints.
PieceTypeRegistry
Register new piece types.
import { PIECE_TYPE_REGISTRY } from "./piece-type-registry.js";
PIECE_TYPE_REGISTRY.register({
id: "cannon",
displayName: "Cannon",
moveGenerator: (session, id) => [/* LegalMove[] */],
attackProbe: (session, id, target) => /* boolean */,
assets: { white: cannonWhiteSvg, black: cannonBlackSvg },
});
The engine + check detection + UI all dispatch through the registry. Once registered, your custom type:
- Can be spawned via
engine.spawnPiece("cannon", ...). - Participates in
engine.getAllLegalMoves(). - Contributes to
isSquareAttacked/isInCheck/isCheckmate. - Renders via UI
<Piece>using the declaredassets.
Register from a module-level side-effect import. Core FIDE types register from presets/core-piece-types.ts.
Walkthrough: Building a Shield preset
// 1. Declare the attribute in ChessAttrMap (schema.ts):
declare module "../schema" {
interface ChessAttrMap {
Shield: number;
}
}
// 2. Register the preset:
PRESET_REGISTRY.register({
id: "piece-shield",
name: "Shield",
description: "Every piece has Shield=1. Absorbs one hit before HP.",
incompatibleWith: [],
requires: [],
pieceAttributes: ["Shield"],
onActivate({ engine }) {
for (const f of engine.session.allFacts()) {
if (f.attr !== "PieceType") continue;
if ((f.id as number) <= 0) continue;
if (!engine.session.contains(f.id, "Shield")) {
engine.session.insert(f.id, "Shield", 1);
}
}
},
onPieceSpawn({ engine, pieceId }) {
if (!engine.session.contains(pieceId, "Shield")) {
engine.session.insert(pieceId, "Shield", 1);
}
},
onDamage({ engine, target, amount }) {
if (!engine.session.contains(target, "Shield")) return undefined;
const current = engine.session.get(target, "Shield") as number;
if (current <= 0) return undefined;
engine.session.insert(target, "Shield", current - 1);
if (amount - 1 <= 0) return { consume: true, died: false };
return undefined; // let piece-hp / default handle the remainder
},
});
That's a full Shield preset in ~30 lines, zero edits to engine code. Activate it BEFORE piece-hp in the active set so it intercepts damage first.
Design notes and gotchas
Hook firing order
Two distinct dispatch sequences. Don't confuse them.
Legal-move query (every engine.getAllLegalMoves() call):
getRoyalPieces— union across active presets; resolves the royal set for this color.- Per-piece loop:
overridePieceMoves— first non-undefined wins, skips steps 2.2-2.5.- Type-registry generator +
transformMoveGeneratorchain. - En-passant / castling / promotion synthesis (type-specific).
getExtraMoves— each preset appends.filterMoves— each preset filters (ALWAYS runs, even with override).
- Self-check filter — uses the royal set from step 1. Suppressible via
shouldFilterSelfCheck. filterLegalMoves— each preset filters the aggregate list in registration order.
Move application (every engine.applyMove() call):
onBeforeMove— scope-aware, can cancel.onBeforeCapture— fires if the move is a capture. First consumer owns the capture mechanic.onDamage— if noonBeforeCaptureconsumed, the engine routes target through this. Scope-aware by target color.- Move mutations (position update, promotion, en-passant, castling).
HalfMovesThisTurn += 1BEFORE polling the flip hook.shouldAdvanceTurn— firstfalsevetoes the flip. If no veto:Turnflips,HalfMovesThisTurnresets to 0,FullmoveNumberincrements after black.- Duration tick (
turnsRemaining -= 1),onDeactivatefor expired presets. onAfterMove— unscoped, all active presets.onTurnStart— scope-aware by new side-to-move. SKIPPED when step 6 vetoed the flip.onCheckGameResult— all presets, first non-undefined wins. UsesgetRoyalPiecesunion for default checkmate/stalemate detection.describeMoveEffect— all presets contributepresetEffectsto the MoveRecord.
Registration order determines dispatch order
For hooks that iterate presets (onDamage, onCheckGameResult), the first preset to consume wins. Order follows the active-set list, which follows the order in which presets were activated via setActivePresets. Registration order in presets/index.ts sets the DEFAULT activation order when all presets are enabled at once, but user-toggle order during a game is what actually matters.
If your preset needs to run before / after another, document the expectation. There's no priority system.
Scope-aware vs unscoped
| Hook | Scope-aware? | Why |
|---|---|---|
onDamage |
Yes (target color) | A scope=white Shield only protects white pieces. |
onBeforeMove |
Yes (mover) | A scope=white veto doesn't block black's moves. |
onTurnStart |
Yes (new side to move) | Regen hooks should only fire for the color they care about. |
onBeforeCapture |
Yes (mover) | Inherited from pre-refactor semantics. |
onAfterMove |
No | Presets read ctx.mover and decide themselves. Breaking-change to flip. |
onCheckGameResult |
No | Terminal state is global. |
getRoyalPieces |
No (per-color via ctx) | Hook receives ctx.color; caller (engine) resolves per-color separately. |
filterLegalMoves |
No (per-color via ctx) | Fires once per color at aggregation time; receives the color in ctx.color. |
shouldAdvanceTurn |
No | First false wins regardless of scope; presets that only care about one color check ctx.mover themselves (e.g. monster-rules). |
overridePieceMoves |
Yes (mover / piece owner color via scoped iteration) | Runs only on presets scoped to the piece's color. |
Context objects are growable
All hook contexts are interface-defined and additive. Adding a field in a later release won't break existing hook implementations — they'll simply ignore the new field.
Don't use GAME_ENTITY for preset state
The reserved PRESET_STATE_ENTITY + engine.presetState<T>(id) API is namespaced, serializes automatically, and clears on deactivate. Putting preset-specific data on GAME_ENTITY (like the old capture-to-win.Winner fact) works today but collides with future presets and doesn't auto-clear.
Testing
packages/chess/src/presets/test-utils.ts exports:
pieceAt(engine, square)— find piece on algebraic square.hpOf(engine, id)/typeOf(engine, id)/exists(engine, id)— attribute queries.clearBoard(engine, { preserveKings })— strip the board to minimal pieces.placePiece(engine, type, color, square, opts?)— direct session insert (bypasses spawnPiece).
For preset tests that exercise hooks, see:
damage-pipeline.test.ts— damage + compositionpreset-state.test.ts— per-preset state bagmove-log.test.ts— MoveRecord / describeMoveEffectvisual-effect.test.ts— emitEffect / subscribeEffectsphase-hooks.test.ts— onBeforeMove / onTurnStartintegration.test.ts— Shield / Cannon / Berserker / Stamina end-to-end
Post-landing backlog
Documented but NOT yet available:
onPieceRetract— symmetric counterpart toonPieceSpawn. Needed for "death rattle" mechanics.- Per-preset UI panels (not just overlays) — extension slot for sidebar widgets.
- Server-side piece-type manifest echo — when custom types are authored outside the shared
packages/chess. - Save-state migration — versioning for saves that predate attribute additions.
File issues or propose extensions via pull request.