houserules/packages/chess/docs/PRESET-API.md
Joey Yakimowich-Payne cc30545ced
feat(chess): preset-flexibility architecture — decouple HP, damage, piece-types, state, effects
Decouples five cross-cutting concerns from engine core so new presets
compose without special-casing:

- Piece attributes: CORE_PIECE_ATTRS + PresetDef.pieceAttributes;
  engine.effectivePieceAttrs unions them. HP is now preset-owned.
- Spawn pipeline: engine.spawnPiece() + onPieceSpawn hook replaces
  hand-rolled materialization. Queen-splits seeds HP via the hook,
  not via direct knowledge of piece-hp.
- Hook signatures: all hooks now take single context objects
  (LifecycleContext, MoveHookContext, CaptureHookContext, DamageHookContext,
  SelfCheckFilterContext, GameResultHookContext, PieceSpawnContext,
  BeforeMoveContext, TurnStartContext, DescribeMoveEffectContext).
- Piece-type registry: PIECE_TYPE_REGISTRY + core-piece-types.ts
  replaces hardcoded FIDE switch-tables in engine/check/checkmate/
  stalemate/Piece.tsx. Custom piece types plug in without engine edits.
- Preset-scoped state: engine.presetState<T>(id) backed by facts on
  PRESET_STATE_ENTITY. Auto-cleared on deactivate. capture-to-win
  migrated off GAME_ENTITY.Winner.
- Move log: engine.moveLog + MoveRecord + describeMoveEffect hook.
- Visual effects: engine.emitEffect/subscribeEffects + VisualEffect +
  VisualEffectLayer. Explosion, heal, poison renderers. No-op when
  no subscribers.
- Phase hooks: onBeforeMove (with cancel), onTurnStart. Scope-aware
  dispatch routes onDamage/onBeforeMove/onTurnStart by target/mover
  color.

All 5 production presets migrated. 4 prototype presets (Shield, Cannon,
Berserker, PawnStamina) in integration.test.ts exercise every hook.
Added test-utils.ts and PRESET-API.md for preset authors.

930 tests passing; bun run check clean.
2026-04-18 16:26:17 -06:00

17 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-heals requires piece-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=2 on 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 setPresets that 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

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.

filterMoves(moves, engine, pieceId): LegalMove[]

Remove or modify moves from the aggregated list. Used by knight-immunity (filters captures of knights).

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.

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 declared assets.

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

Within a single applyMove, hooks fire in this order:

  1. onBeforeMove — scope-aware, can cancel.
  2. onBeforeCapture — fires if the move is a capture. First consumer owns the capture mechanic.
  3. onDamage — if no onBeforeCapture consumed, the engine routes target through this. Scope-aware by target color.
  4. Move mutations (position update, promotion, en-passant, castling).
  5. Duration tick (turnsRemaining -= 1), onDeactivate for expired presets.
  6. onAfterMove — unscoped, all active presets.
  7. onTurnStart — scope-aware by new side-to-move.
  8. onCheckGameResult — all presets, first non-undefined wins.
  9. describeMoveEffect — all presets contribute presetEffects to 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.

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 + composition
  • preset-state.test.ts — per-preset state bag
  • move-log.test.ts — MoveRecord / describeMoveEffect
  • visual-effect.test.ts — emitEffect / subscribeEffects
  • phase-hooks.test.ts — onBeforeMove / onTurnStart
  • integration.test.ts — Shield / Cannon / Berserker / Stamina end-to-end

Post-landing backlog

Documented but NOT yet available:

  • onPieceRetract — symmetric counterpart to onPieceSpawn. 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.