Introduces a pluggable StartingLayout abstraction so the engine can
open from positions other than FIDE without per-caller special casing.
- layouts/{types,registry,index}.ts: StartingLayout + LAYOUT_REGISTRY,
mirroring the PRESET_REGISTRY / PIECE_TYPE_REGISTRY pattern.
- starting-position.ts: CLASSIC_LAYOUT + applyLayout(session, layout)
as the parametrized spawn path. generateStartingPosition stays as
a thin back-compat wrapper so no existing call site changes.
- layouts/{classic,empty}.ts: first two premades registered via
side-effect imports from the barrel.
- engine.ts: ChessEngine constructor now accepts either legacy
(activePresets) positional or new options-bag form
({ activePresets?, layout? }). Detection uses a method-shape
probe rather than instanceof so both overload forms compose
cleanly under strict TS.
- Tests: 11 new tests in starting-position.test.ts + engine-presets
cover applyLayout ordering, hasMoved pre-revocation, empty
layout, classic equivalence, options-bag equivalence with legacy.
Also lands the full execution plans for starting-layouts and
rule-variants under .sisyphus/plans/ (both Momus-reviewed OKAY).
941 tests passing; bun run check clean.
26 KiB
Starting Layouts — Custom Board Setups Before Game Start
TL;DR
Add a Starting Layout concept, orthogonal to rule presets, that controls which pieces start where. Ship 7 premades (Classic, Dunsany, Monster, Pawns-Only, Horde, Knightmate, Chess960) plus an Empty board sandbox, a visual drag-and-drop editor, FEN import/export, a shareable code, and a localStorage library of user-saved layouts.
Architecture is layered so a future "board shape/dimensions" extension (non-8×8, removed squares, hex) can land without re-plumbing the wiring installed here.
Deliverable: from the lobby, a user can pick "Dunsany" from a Layout dropdown (or paste a FEN, or draw their own) BEFORE creating a room. The selected layout is server-authoritative, broadcast on room.create, persisted through reconnect, and shown to both players in multiplayer.
Phases: 5 phases, 22 tasks, hard-gated by bun run check green between each.
Context
What Shipped Last
- Preset-flexibility architecture (commit
cc30545): decoupled piece-type registry, damage pipeline, preset-state storage, visual-effect bus, hook context objects. - Custom piece types plug in via
PIECE_TYPE_REGISTRY; new piece attributes viaPresetDef.pieceAttributes. - Engine construction:
new ChessEngine(activePresets?)— piece placement is always FIDE viagenerateStartingPosition(session)called unconditionally in the constructor.
Where "8×8" Is Hardcoded (inventory)
A parallel explore pass found the following coupling. This is a board-layouts plan, not a board-dimensions plan — we deliberately scope around these so a future shape plan can ride on top without double-plumbing.
Board-shape-tight (NOT touched by this plan):
coord.ts—fileOf/rankOf/squareOfassumerank*8+file.rules/castling.ts— king/rook home squares hardcoded (e1=4, h1=7, a1=0, e8=60, h8=63, a8=56).rules/promotion.ts—PROMOTION_RANK = {white:7, black:0}.rules/primitives.ts— pawn home ranks (1, 6), double-advance distance (16).rules/enpassant.ts— ±8 rank offset for captured pawn.ui/Board.tsx—grid-cols-8 grid-rows-8and 0..7 loops.protocol.ts— algebraic regex/^[a-h][1-8]$/.
Layout-tight (WILL be touched):
engine.ts:365— unconditionalgenerateStartingPosition(this.session)call in constructor.starting-position.ts— the function itself; must become configurable.server/rooms.ts+PROTOCOL.md—room.createpayload gains optionallayoutIdand/orfen.ui/Lobby.tsx— "Create Room" flow currently passes{}; must pass layout selection.net/types.ts+net/client.ts— request/response shapes for layout.
What Still Decouples Cleanly
engine.spawnPiece(type, color, square, {reason: "initial"})already exists and firesonPieceSpawn. A configurable starting-position generator uses this directly — sopiece-hpwill seedHpon every piece regardless of which layout is chosen, no preset edits needed.PIECE_TYPE_REGISTRYalready dispatches move generation by type string — so if a layout places Nightriders or Amazons at start (via a future piece-types preset), the engine handles them.
Out of Scope (Deferred)
- Non-8×8 boards, irregular shapes, hex. Layouts are piece-placement only; the board is always 64 squares of the same topology the engine already understands. A future "Board Shape" plan rides on top and adds a
BoardConfigbesideStartingLayout. - New rule-variant presets (Berolina pawns, Bouncing bishop, Coregal, Suicide, Knightmate's king-is-a-knight rule, Double-move, Monster's white-moves-twice). Those are preset work. This plan ONLY adds layouts. Exception: Knightmate layout ships without its rule variant; user can enable a hypothetical
knightmate-rulespreset later, or just play it as "two kings, no knights" for now. The layout selector shows a "Suggested rules" hint (read-only) alongside each premade. - Mid-game position editing. Editor is pre-game only. Editing a running game would need a whole analysis-mode workflow.
- Server protocol v2. We add OPTIONAL fields to
room.create; omission = classic layout (backward compatible). - Save-file migration for FIDE autosaves. Autosave is per-layout keyed; old FIDE saves keep working under the "Classic" layout.
Work Objectives
Core Objective
Introduce a StartingLayout abstraction the engine consumes on construction, split the existing generateStartingPosition into a registry + default, and build the UI + networking that lets users pick, create, save, share, and import layouts.
Primary Deliverables
StartingLayouttype —{ id: string; name: string; description: string; pieces: PiecePlacement[]; suggestedPresets?: string[] }.LAYOUT_REGISTRYwith the 7 premades (Classic, Dunsany, Monster, Pawns-Only, Horde, Knightmate, Empty) + one pseudo-entry (Chess960) that generates a seeded random layout on each.build(seed).engine.tsacceptslayoutoption;generateStartingPositionparametrized.ui/LayoutPicker— lobby dropdown of premades with preview thumbnails and "Custom…" entry opening the editor.ui/LayoutEditor— drag-and-drop piece placement, piece palette sidebar, FEN import/export, save to library, share-code generation, minimum-king validation.net/protocol—room.createpayload gains optionallayout: { id: string } | { fen: string } | { pieces: PiecePlacement[] }. Server validates, echoes viaroom.created.- Shareable layout URL —
?layout=ENCODED_FENor?layoutId=dunsany; lobby pre-selects if present. - localStorage library —
houserules:layouts:v1key, up to 20 recent + starred. - FEN utilities —
toFen(pieces),fromFen(string)round-trip with validation. - Autosave isolation — current autosave key
chess-autosavebecomeschess-autosave:${layoutId}so selecting a different layout doesn't resurrect a finished game.
Secondary Deliverables
- Solo mode respects layout too (Play Solo from lobby honors the selector).
- Playwright E2E: create room with Dunsany; both clients render identical position.
- Unit tests for every layout (piece counts, king-per-side, no-pawn-on-promo-rank warnings).
Non-Goals (clarifying)
- Editor operating on 10×10 board. Editor is 8×8 only this iteration.
- Undo/redo in the editor beyond natural drag-drop "pick up, put down".
- Auto-suggest of compatible presets (display only; no auto-enable).
Architecture Sketch
New file: packages/chess/src/layouts/types.ts
export interface PiecePlacement {
readonly type: PieceType;
readonly color: PieceColor;
readonly square: Square;
readonly hasMoved?: boolean; // default false
}
export interface StartingLayout {
readonly id: string; // kebab-case; stable for URLs
readonly name: string; // display label
readonly description: string;
readonly pieces: readonly PiecePlacement[];
/** Optional: preset IDs suggested alongside this layout.
* UI displays as a hint; user decides whether to enable. */
readonly suggestedPresets?: readonly string[];
/** "premade" — shipped in LAYOUT_REGISTRY.
* "custom" — user-authored, not in registry, carried by-value. */
readonly source: "premade" | "custom";
}
New file: packages/chess/src/layouts/registry.ts
class LayoutRegistry {
register(layout: StartingLayout): void;
get(id: string): StartingLayout | undefined;
list(): readonly StartingLayout[];
}
export const LAYOUT_REGISTRY = new LayoutRegistry();
Registry is registry-pattern identical to PRESET_REGISTRY and PIECE_TYPE_REGISTRY. Side-effect registration from a layouts/index.ts barrel.
New file: packages/chess/src/layouts/fen.ts
Implements standard Forsyth-Edwards piece placement (fields 1-2 of full FEN: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w"). Ignores castling rights / en passant / halfmove for v1 (engine doesn't need them from the layout — they come from gameplay).
Refactor: packages/chess/src/starting-position.ts
// NEW signature (old one becomes a thin wrapper over CLASSIC layout)
export function applyLayout(session: Session, layout: StartingLayout): EntityId[];
// Back-compat wrapper — existing callers in tests stay working.
export function generateStartingPosition(session: Session): EntityId[] {
return applyLayout(session, CLASSIC_LAYOUT);
}
Refactor: packages/chess/src/engine.ts
interface EngineOptions {
readonly activePresets?: ActivePresetSet;
readonly layout?: StartingLayout; // defaults to CLASSIC
}
constructor(opts: EngineOptions = {}) {
this.session = new Session({ autoFire: false });
applyLayout(this.session, opts.layout ?? CLASSIC_LAYOUT);
this.activePresets = opts.activePresets ?? new ActivePresetSet();
}
Backward compat: existing call sites new ChessEngine() and new ChessEngine(presets) continue to work — we overload the constructor signature.
Protocol extension
room.create payload gains OPTIONAL layout field:
type RoomCreatePayload = {
rulesetIds?: string[];
layout?:
| { kind: "premade"; id: string }
| { kind: "custom"; pieces: PiecePlacement[]; name?: string }
| { kind: "fen"; fen: string; name?: string };
};
Server resolves to a StartingLayout value, validates (king count, square bounds, piece counts), stores in room, passes to new ChessEngine({ layout }). Protocol stays at v1.
room.created response and room.joined event gain layout field (resolved form, always {kind:"custom"|"premade", name, pieces}) so late joiners can display the name.
UI shape
- Lobby: Layout selector above "Create Room" button. Dropdown with premades; last entry "Custom…" opens the editor modal. Selected layout encoded in the Create button's outgoing request.
- Editor modal: Full-screen overlay. Left sidebar = piece palette (drag source) + FEN field + "Save to Library" + "Copy Share Link". Center = interactive 8×8 board (drop target, click-to-delete). Right sidebar = validation panel + "Use This Layout" CTA.
- Library drawer: Opens from within the editor; lists saved layouts with name, piece count, last-used date, star/unstar, delete, duplicate.
Phase A — Foundations (engine + types)
A.1 — Add StartingLayout types and LAYOUT_REGISTRY
[packages/chess/src/layouts/types.ts]Create: DefinePiecePlacement,StartingLayout,LayoutValidationResult— expect: new exported types compile under strict TS.[packages/chess/src/layouts/registry.ts]Create: Implement registry class mirroringPRESET_REGISTRYshape — expect:register / get / listwork; duplicate-id registration throws.[packages/chess/src/layouts/index.ts]Create: Barrel that imports every premade module for side-effect registration — expect: one import line = all layouts registered.
A.2 — Parametrize starting-position.ts
[packages/chess/src/starting-position.ts]Refactor: ExtractapplyLayout(session, layout): EntityId[]that inserts piece facts viaspawnPiece-equivalent (usesession.nextId()+insertsequence matching the current loop). AddCLASSIC_LAYOUTconst built from existing hardcoded arrays — expect: newapplyLayout+ oldgenerateStartingPositionwrapper both return identical EntityId arrays for the default layout.[packages/chess/src/starting-position.test.ts]Update: Add tests thatapplyLayout(session, CLASSIC_LAYOUT)matches the existing 32-piece expectation — expect: green.
A.3 — Register Classic + Empty
[packages/chess/src/layouts/classic.ts]Create: Re-exportsCLASSIC_LAYOUTfromstarting-position.tsunder{id: "classic", name: "Classic Chess", ..., source: "premade"}and registers withLAYOUT_REGISTRY— expect:LAYOUT_REGISTRY.get("classic")returns the FIDE setup.[packages/chess/src/layouts/empty.ts]Create: Empty-board layout withpieces: []— expect: registered asid: "empty".
A.4 — Engine accepts layout option
[packages/chess/src/engine.ts]Refactor: Change constructor toconstructor(opts: EngineOptions = {})whereEngineOptions = {activePresets?, layout?}. Default layout toCLASSIC_LAYOUT. Support legacynew ChessEngine(activePresets)call via overload signature — expect: all existing callers keep working unchanged (tests green).[packages/chess/src/engine-presets.test.ts]Sanity: Add one test instantiatingnew ChessEngine({ layout: EMPTY_LAYOUT })and assertingsession.allFacts().filter(f => f.attr === "PieceType").length === 0— expect: green.
A.5 — Phase A verification gate
[repo root]Verify: Runbun run check— expect: typecheck + lint + all 930+ tests green. No functional behavior change.
Phase B — Premade Layouts + FEN
B.1 — FEN round-trip utility
[packages/chess/src/layouts/fen.ts]Create:toFen(pieces: readonly PiecePlacement[]): stringandfromFen(fen: string): {pieces: PiecePlacement[], errors: string[]}. Support only the piece-placement field (first space-delimited token); ignore side-to-move/castling/en-passant/halfmove — expect: round-trip on CLASSIC_LAYOUT produces"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR".[packages/chess/src/layouts/fen.test.ts]Create: 10+ tests covering classic round-trip, empty board ("8/8/8/8/8/8/8/8"), partial positions, malformed input (returns errors, not throws), non-FIDE pieces (usePIECE_TYPE_REGISTRYglyph mapping — fall back to placeholder*for unknown types) — expect: green.
B.2 — Premade: Dunsany
[packages/chess/src/layouts/dunsany.ts]Create: White = 32 pawns on squares 0..31 (ranks 1-4). Black = standard FIDE back rank on rank 8 + pawns on rank 7 — expect:pieces.length === 32 + 16 = 48; registered asid: "dunsany", name"Dunsany's Chess";suggestedPresets: [].
B.3 — Premade: Monster
[packages/chess/src/layouts/monster.ts]Create: White = king on e1 + 4 pawns on c2/d2/e2/f2. Black = full FIDE setup — expect:pieces.length === 5 + 16 = 21;suggestedPresets: ["double-move"](commented as not-yet-implemented preset, displayed as dimmed hint in UI).
B.4 — Premade: Pawns-Only
[packages/chess/src/layouts/pawns-only.ts]Create: Each side = king + 8 pawns on second/seventh ranks. No other pieces — expect:pieces.length === 2 + 16 = 18. (We keep 1 king per side to satisfy the "min 1 king" validator; the greenchess variant uses no king + first-promotion-wins — that's a rule-preset concern. Users enablecapture-to-winalongside if they want that flavor.)
B.5 — Premade: Horde
[packages/chess/src/layouts/horde.ts]Create: White = 36 pawns (all of ranks 1-4 except a4/b4/g4/h4 which are empty — standard Horde pattern per lichess). Black = full FIDE setup — expect:pieces.length === 36 + 16 = 52.
B.6 — Premade: Knightmate layout
[packages/chess/src/layouts/knightmate.ts]Create: Both sides = knights replace king on e1/e8; kings replace knights on b1/g1/b8/g8. (Per greenchess: "knights become royal". Since we don't ship the royalty rule yet, this is just a piece-swap — play as "no king, two knights to defend". The file comment notes this limitation.) — expect:pieces.length === 32;suggestedPresets: ["knightmate-rules"](future).
B.7 — Premade: Chess960 (Fischer random)
[packages/chess/src/layouts/chess960.ts]Create: Export functionbuildChess960Layout(seed: number): StartingLayoutthat follows the Chess960 generation rules (bishops on opposite-color squares, king between the two rooks, other pieces uniformly random). Register a SPECIAL SHIM in the registry withid: "chess960"whose name is"Chess960 (Fischer Random)"and whosepiecesis a placeholder — theLayoutPickerdetects this id and, on selection, callsbuildChess960Layout(Math.floor(Math.random() * 960))to produce the actual layout — expect: generator produces valid 960 positions; bishop-color and king-between-rooks invariants hold for all 960 seeds; unit test asserts 20 random seeds all satisfy invariants.
B.8 — Layout validator
[packages/chess/src/layouts/validate.ts]Create:validateLayout(layout): {errors: string[]; warnings: string[]}. Errors (block): not exactly 1 white king, not exactly 1 black king, duplicate square, square out of [0, 63]. Warnings (allow-but-flag): pawn on rank 1 or rank 8 (can't move / impossible end state), wildly unequal material, missing opponent's pieces — expect: unit tests cover every branch.[packages/chess/src/layouts/validate.test.ts]Create: 12+ tests — expect: green.
B.9 — Phase B verification gate
[repo root]Verify: Runbun run check— expect: green. Premade layouts loadable via registry, FEN round-trips, validator agrees with manually-verified cases.
Phase C — Server & Protocol
C.1 — Protocol schema for layout payload
[packages/server/src/protocol.ts]Extend: Add optionallayoutdiscriminated union toRoomCreatePayloadschema (Valibot) — expect: messages withoutlayoutfield still validate (backward compat); messages with{kind: "premade", id: "dunsany"}validate; malformed entries rejected with typed error.[packages/server/src/protocol.test.ts]Add tests for each kind — expect: green.
C.2 — Server resolves layout + validates
[packages/server/src/rooms.ts]Update: Onroom.create, resolve the incominglayoutfield to a concreteStartingLayout(registry lookup forkind:"premade",fromFenforkind:"fen", direct use forkind:"custom"), runvalidateLayout, reject withLAYOUT_INVALIDon validation errors — expect: server accepts premade ids, valid FENs, valid custom piece arrays; rejects invalid.[packages/server/src/game-session.ts]Update: Pass resolved layout intonew ChessEngine({ layout, activePresets })— expect: the engine instance starts from the requested layout.[packages/server/src/rooms.test.ts]Add 5+ test cases covering premade id, FEN, custom pieces, invalid FEN, invalid king count — expect: green.
C.3 — Echo resolved layout to clients
[packages/server/src/broadcast.ts]Update:room.createdandroom.joinedresponses includelayout: {id, name, pieces}field so clients can display the name and re-render if they arrived via shared URL without local layout state — expect: protocol test updated; server unit test verifies both events carry the field.
C.4 — Update PROTOCOL.md
[packages/server/PROTOCOL.md]Update: Document the newlayoutfield onroom.create, its discriminated union, and the echo inroom.created/room.joined. Include example payloads — expect: docs accurate.
C.5 — Phase C verification gate
[repo root]Verify: Runbun run checkand manually smoke-test viabun run packages/server/src/index.ts+ two browser tabs creating/joining rooms with different layouts — expect: green; smoke test clean.
Phase D — Lobby Integration + Client Wiring
D.1 — LayoutPicker component
[packages/chess/src/ui/LayoutPicker.tsx]Create: Dropdown + a mini-preview thumbnail. Lists all registry layouts + "Custom…" entry. Controlled viavalue: string/onChange: (layoutId, resolvedLayout) => void. Forchess960, regenerates seed on every selection — expect: renders all premades; selecting one fires change handler with the resolved layout.
D.2 — Wire picker into Lobby
[packages/chess/src/ui/Lobby.tsx]Update: AddLayoutPickerabove Create Room button. Store selected layout in component state. Pass tooneShotRoomRequest('room.create', { layout: selectedLayout }).handlePlaySolouses it too, passing tonew ChessEngine({ layout })— expect: E2E-visible: dropdown appears, selecting Dunsany then Create → server receives the layout.
D.3 — Protocol client updates
[packages/chess/src/net/types.ts]Extend: Mirror server'sRoomCreatePayloadtype — expect: compile-clean.[packages/chess/src/net/lobby-request.ts]Update: Payload type forroom.createincludes optionallayout— expect: typed end-to-end.
D.4 — GameView displays layout name
[packages/chess/src/ui/GameView.tsx]Update: Show a subtle label near the room code pill: "Dunsany's Chess" / "Custom layout" etc. Sourced from thelayoutfield on the room-state broadcast — expect: label renders; hidden when classic.
D.5 — Autosave keyed by layout
[packages/chess/src/persist/autosave.ts]Refactor: Change autosave key fromchess-autosavetochess-autosave:${layoutId}. Add migration reading the old key aschess-autosave:classic— expect: existing FIDE saves still load under Classic; a custom layout has its own autosave slot.
D.6 — Shareable layout URL
[packages/chess/src/app/App.tsx]Extend:/route reads?layoutId=<id>or?fen=<fen>query params and pre-selects the picker./game/:codeunchanged (room state already carries layout). Layout URL share button in Lobby: copies${origin}/?layoutId=dunsanyor for custom${origin}/?fen=<encoded>— expect: pasting a layout URL pre-selects; copying regenerates on demand.
D.7 — Phase D verification gate
[repo root]Verify: Runbun run check— expect: green. E2E smoke: lobby picker, premade selection, multiplayer flow each work.
Phase E — Custom Layout Editor + Library
E.1 — LayoutEditor component (shell)
[packages/chess/src/ui/LayoutEditor.tsx]Create: Full-screen modal overlay with three panels: piece palette (left), 8×8 board (center), actions panel (right). Uses existingBoardin "editor mode" — pieces draggable without move-legality checks, tap-to-remove — expect: modal opens from LayoutPicker's "Custom…" entry; closes via Esc or Cancel.
E.2 — Editor drag-and-drop placement
[packages/chess/src/ui/LayoutEditor.tsx]Extend: Palette items draggable onto squares; placing on an occupied square replaces; dragging off the board removes. Works on mobile via long-press + tap-target-to-place — expect: placing a queen on d4 yields{type: "queen", color: "white", square: 27}in the editor state.
E.3 — Editor validation panel
[packages/chess/src/ui/LayoutEditor.tsx]Extend: Live validation display usingvalidateLayout. Green check when valid; red errors block "Use This Layout"; amber warnings display but don't block — expect: removing a king shows "Missing white king" error, hides on re-add.
E.4 — FEN import/export in editor
[packages/chess/src/ui/LayoutEditor.tsx]Extend: Textarea bound to live FEN string viatoFen; "Load FEN" button callsfromFenand overwrites editor state; editing the field updates the board — expect: typing a valid FEN re-populates the board.
E.5 — Save to library
[packages/chess/src/ui/LayoutEditor.tsx]Extend: "Save to Library" button prompts for name, writes tolocalStorage[houserules:layouts:v1]as an array of saved layouts (max 20, FIFO eviction) with timestamp + starred bool. Duplicates (same piece set) replace-in-place — expect: saved layouts appear in LayoutPicker under a "Saved" section.
E.6 — Library drawer
[packages/chess/src/ui/LayoutEditor.tsx]Extend: "Library" button toggles a side drawer listing saved layouts: name, piece count, last-used, actions (load, star, duplicate, delete) — expect: drawer opens; actions persist to localStorage.
E.7 — Share-code export
[packages/chess/src/ui/LayoutEditor.tsx]Extend: "Copy Share Link" button writes${origin}/?fen=${encodeURIComponent(fen)}&name=${encodeURIComponent(name)}to clipboard. Toast confirms — expect: pasted link pre-selects custom layout with matching FEN in a fresh browser.
E.8 — "Use This Layout" commits to Lobby
[packages/chess/src/ui/LayoutEditor.tsx]Extend: CTA disabled until validation green. On click, callsonApply(layoutAsStartingLayout)passed from Lobby; editor closes; Lobby's LayoutPicker now reflects "Custom" selection — expect: Create Room after this uses the custom layout.
E.9 — E2E + final check
[packages/chess/e2e/layout-editor.spec.ts]Create: Playwright test for full flow — open editor, drag pieces, save, load, create multiplayer room, verify both clients render the same position — expect: green on first run.[repo root]Verify:bun run check+ Playwright — expect: green.
Risks
- Layout-data size over the wire. A full custom layout is ~32 piece placements ≈ 500 bytes. Well under the 64KB WS limit. FENs are under 80 bytes. Non-issue.
- Mobile drag-and-drop UX in the editor. Mitigation: tap-select-then-tap-square fallback plus haptic on place. Ship both.
- Chess960 bishop-color invariant bugs. Mitigation: unit-test all 960 seeds (cheap — generation is pure).
- Autosave migration. Moving the key is one-time; we read the old key on startup and rewrite under the new namespace. A single test covers round-trip.
- "Knightmate without its rule" confusion. Mitigation: layout description text spells out "Layout only — Knight-as-royal rule coming later; today this plays as kingless".
- Server-side DoS via huge custom layouts. Mitigation: validator caps piece count at 64 (board size), rejects duplicate squares, enforces square bounds. Already part of
validateLayout. - Preset compat regressions. Piece-hp, queen-splits etc. all run through
spawnPiecewhich layouts also use. Mitigation: keepspawnPieceas the single spawn path; add a direct unit test (start from Dunsany with piece-hp active → every pawn has Hp=2).
Deferred / Post-Landing
- Preset suggestions auto-apply toggle — "Enable suggested rules for Monster? (adds double-move)".
- Board shape/dimensions refactor — non-8×8 boards, hex, removed squares. Rides on
StartingLayout.board?: BoardConfigextension. - Monster / Knightmate / Berolina rule presets — new preset work to make those layouts play their intended way. Orthogonal to this plan.
- Mid-game position editor — analysis-mode workflow.
- Thumbnail preview in LayoutPicker — render actual mini-board SVG instead of piece-count badge. Nice-to-have.
- Layout import from lichess/chess.com PGN headers — nice-to-have.