Adds a modifier profile picker next to the layout picker in the Lobby, and a header badge in GameView that surfaces the active profile's name.
Lobby:
- New <select data-testid="profile-picker"> loads entries from loadLibrary() on mount and refreshes when the ModifierProfileEditor closes (auto-selecting the most recently updated entry).
- Selecting a saved profile sets the active ModifierProfile; selecting 'Custom…' opens the existing editor modal.
- URL param ?modifierProfile=<b64> decodes + pre-selects even when the profile isn't in the local library, via a synthetic '<name> (from link)' option so the <select> can reflect the choice without collapsing it.
- handleCreate now sends payload.profile when a profile is selected and stashes modifier-profile-name in sessionStorage.
- handleJoin reads profile from the server's room.joined echo so late joiners see the badge on first paint.
GameView:
- New ModifierProfileBadge component mirrors LayoutBadge but reads modifier-profile-name from sessionStorage and uses fuchsia tones so it's visually distinct when both badges are present.
lobby-request.ts:
- OneShotRoomResult exposes the optional profile field the server now echoes (T19).
E2E:
- 2 new Playwright tests: 'create room with profile — badge shows in game' seeds the library via localStorage, selects the profile, creates the room, and asserts the badge text. 'URL pre-select loads profile in picker' base64-encodes a profile into ?modifierProfile= and verifies the picker shows the correct value + 'from link' synthetic label.
All 8 modifier-profiles e2e tests pass; bun run check green (1213/1213 unit tests).
Adds PerTypePanel component (left panel of ModifierProfileEditor).
- Lists existing TypeModifier rows with piece type, color, and described
value; each row has a delete button.
- Inline add form: piece type, color, and modifier kind selects + a
uiForm-driven value input (number, percentage, promotion-target,
or placeholders for direction-set/capture-flags).
- Save button disabled via Zod schema.safeParse — invalid values (e.g.
range-bonus=100 > max 7) cannot be submitted.
- Wired into ModifierProfileEditor left panel via perType state.
- 2 new Playwright tests: add-modifier row appears, invalid value disables save.
Wires the T17 protocol's optional modifier profile through the server's room-create flow:
- rooms.Room gains an optional profile field; RoomRegistry.createRoom/joinRoom accept and echo it.
- GameSession constructor + GameSessionRegistry.create pass the profile through to ChessEngine's EngineOptions so modifier facts get seeded and the integration preset auto-activates at game start.
- broadcast.handleRoomCreate validates an inline profile via chess's validateProfile against the resolved layout, mapping validator codes (E_PROFILE_*) onto the wire protocol's MODIFIER_PROFILE_* family. Invalid profiles produce a non-fatal error and leave room / session state untouched; the creator is not bound.
- room.created and room.joined echo the active profile when present, so late joiners render piece modifier badges on first paint.
- RoomCreatedPayloadSchema and RoomJoinedPayloadSchema gain matching optional profile fields.
- @paratype/chess barrel: re-exports CaptureFlag + validateProfile + ModifierValidation* types so the server can consume them without reaching into internals.
Tests: 5 new cases (happy path, join echo, backward compat, INVULN_KING rejection, layout-invalid precedence). Full check green (1213/1213).
Adds wire schemas and error codes for the modifier-profile feature:
- ModifierProfileSchema mirrored in server/protocol.ts (server pins a different zod major, so the chess-side schema cannot be re-exported directly). A keyof parity check guards against drift.
- RoomCreatePayloadSchema gains an optional 'profile' field — additive, existing callers unaffected.
- modifier-profile.update (client->server) payload schema with roomCode, newProfile, and version (for optimistic-concurrency checks).
- modifier-profile.updated (server->client) broadcast payload interface, typed for future promotion to the discriminated union when the broadcast is wired through rooms.
- Four new error codes: MODIFIER_PROFILE_INVALID / NO_KING / INVULN_KING / DEADLOCK, exported as const literals alongside the enum.
- Client wire types (packages/chess/src/net/types.ts) mirror all of the above: ModifierProfileWire shape, optional 'profile' on Room{Create,Created,Joined} payloads, and the new modifier-profile.* client/server message envelopes.
- PROTOCOL.md documents the new request/response flow and extends the error-code table.
Tests: 20 new cases across ModifierProfileSchema, RoomCreate profile integration, ModifierProfileUpdatePayloadSchema, and error-code acceptance.
Adds ModifierProfileEditor modal shell with 3 placeholder panels
(T21 catalog / T22 board preview / T23 profile list). Esc closes
the modal via a window keydown listener active only while isOpen.
Wires a 'Modifier Profiles' button into the RulesDrawer footer that
opens the editor. Adds e2e/modifier-profiles.spec.ts with 2 tests:
open-from-drawer and esc-to-close.
- validateProfile(profile, layout) checks 4 rules:
- E_PROFILE_NO_KING: layout must have ≥1 king per color
- E_PROFILE_INVULN_KING: king cannot have CANNOT_BE_CAPTURED flag (per-type and per-instance)
- E_PROFILE_ORPHAN_INSTANCE (warning): per-instance entry targeting empty square
- E_PROFILE_ATTR_LIMIT: >12 distinct modifier kinds on one piece
- E_PROFILE_DEADLOCK: reserved TODO (requires session simulation)
- 17 tests covering all codes, both warning/error paths, and valid field integrity
Adds DIRECTION_ADDITIONS_DESCRIPTOR (ModifierDescriptor<Direction[]>) and
generateDirectionMoves() helper. The descriptor seeds DirectionAdditions fact
on pieces; the helper generates 1-square non-capture moves for each listed
direction. Includes 11 tests covering registration, apply, describe, and
generateDirectionMoves with white/black color-relative semantics, blocking,
union stacking, and edge-board clamping.
- Add PromotionOverride descriptor (queen/rook/bishop/knight/disabled)
- getPromotionMoves: returns [] when override='disabled'; single-type
moves when override is a piece type
- applyPromotion: uses override value instead of promoteTo arg when set
- 24 tests: descriptor registry, describe(), apply(), valueSchema,
getPromotionMoves integration (6 scenarios), applyPromotion integration
Add RANGE_BONUS_DESCRIPTOR that seeds RangeBonus fact on piece entities,
and update getSlidingMoves to respect it via a per-ray maxSteps cap.
Clamped to [0,7] in apply(); standard boards are unaffected (RangeBonus=0
→ maxSteps=7, identical to prior behaviour). Foundation for range-limit presets.
Adds two optional hooks to PresetDef:
- transformMoveGenerator wraps a piece's move generator. Presets compose in list order; each receives the previous wrapper's output. Returned moves are pseudo-legal and still pass through the engine's self-check filter.
- modifyMoveAttrs lets presets contribute additive range/direction deltas for generators that support them.
ChessEngine.getAllLegalMoves folds the transform chain before getExtraMoves/filterMoves, preserving existing hook ordering and the downstream self-check filter.
Tests cover: no-op wrap preserves moves, wrap adds pawn backward, two wraps compose, and self-check filter still prunes transform-added illegal moves.
Previously the FEN textarea held an independent draft that only
synced to the board when the user clicked Reset. Placing a piece
left the FEN showing stale content, which was confusing — the
field looked like a proxy for the board but wasn't.
Now the textarea mirrors the live board FEN by default:
- Placing, erasing, or clearing pieces updates the textarea
immediately.
- Starting to type into the textarea marks it 'dirty' (via a ref
so we don't rerender), pausing the auto-sync until the user
Loads (accept the draft) or Discards (restore the live FEN).
- The 'Unsaved edit' indicator appears while dirty.
- Load / Discard buttons disable when draft equals live FEN —
nothing to apply.
- Library load also resets the dirty flag, as that's a clean
state reset.
- Removed the redundant 'Current: <fen>' helper line below the
textarea; the textarea itself is the live FEN now.
E2E: 2 new tests
- FEN field updates live as pieces are placed.
- Typing into FEN field pauses live sync until Load or Discard.
25/25 Playwright tests passing. 1025 unit tests green.
The Empty layout had no real user need:
- Can't Play Solo (board with no pieces, nothing to click).
- Server rejects it for Create Room (validator requires >=1 king
per side).
- The 'blank canvas' use case is already handled by the
LayoutEditor's Custom... flow, which opens with an empty piece
list and lets the user compose.
It was UI clutter in the picker dropdown — users would see
'Empty Board' alongside real layouts and have no way to actually
use it.
EMPTY_LAYOUT stays exported for unit tests that need a zero-piece
starting state, but it no longer registers in LAYOUT_REGISTRY, is
not imported by the layouts barrel side-effects, and no longer
appears in the picker. Source flipped to 'custom' to signal it's
not a user-selectable premade.
Tests updated:
- premades.test.ts asserts 'empty' is NOT in the registry.
- e2e/layouts.spec.ts removes the Empty-solo test and asserts the
'empty' option is absent from the picker dropdown.
1025 unit tests + 23 e2e tests green.
Covers gaps in the original Phase E spec:
- Solo play reaches the engine: picking Pawns-Only and Classic and
asserting the rendered board matches the layout's piece placement
(not just that the UI dropdown says so).
- Empty layout solo: documents current behavior (solo lets users
poke at any layout, validator only gates multiplayer).
- Chess960 re-randomizes: takes 8 consecutive snapshots of the
back rank and asserts they're not all identical (p(collision)^7
is effectively zero).
- Valid ?fen query pre-selects Custom (inverse of the existing
malformed-FEN test).
- Library operations: full save → star → unstar → delete round
trip via the library drawer's aria-labeled buttons.
- Layout badge hidden for classic solo games.
- Multiplayer with Dunsany: full pipeline proof — picker →
Create Room UI → server resolves + validates → room.created
echo → joiner renders same Dunsany setup via room.joined echo.
Compares rank-1 and rank-8 snapshots between two browser
contexts to verify bit-identical rendering.
- Server-side rejection: sends a kingless custom layout via raw
WebSocket and asserts LAYOUT_INVALID error with a human-readable
king-related message.
New server-spawn block mirrors multiplayer.spec.ts so the layouts
tests can run in isolation or against an existing dev server.
24/24 Playwright tests passing (21 layouts + 2 multiplayer +
1 full-flow) in ~40s.
- LayoutEditor: move Esc handling to a window keydown listener so
the modal closes regardless of where focus currently sits (the
prior onKeyDown on the modal div only fired when the root div
itself held focus).
- LayoutPicker: add data-testid on the description <p> so e2e
assertions can target it unambiguously instead of relying on
free-text matches that also select the hidden <option> labels.
- e2e: use the new testid.
All 12 layouts e2e tests pass; existing multiplayer + full-flow
e2e suites still green.
Adds a full in-browser editor for authoring custom starting
layouts and a persistent library to save them across sessions.
persist/layout-library.ts:
- SavedLayout shape with id/name/pieces/starred/updatedAt.
- saveToLibrary / loadLibrary / deleteFromLibrary / setStarred /
duplicateEntry / makeId helpers.
- Capacity cap at 20 entries; oldest non-starred is evicted on
overflow; all-starred + full returns { ok: false, reason }
so the UI can surface an actionable message.
- Localstorage key is versioned (houserules:layouts:v1) for a
future migration path.
- 14 unit tests cover eviction, shape validation, star toggles,
duplicate flow, and the crypto.randomUUID fallback.
ui/LayoutEditor.tsx:
- Modal overlay with three panels — palette (white/black pieces +
Erase + Clear), interactive 8x8 board with click-to-place
brushes, and an actions panel.
- Live FEN textarea (toFen/fromFen round-trips) with a Load
button that replaces the board and a Reset-to-board sync.
- Live validation panel shows errors (block CTA) and warnings
(inform but don't block). The 'Use This Layout' CTA is disabled
until errors clear.
- Save to Library writes a SavedLayout; the integrated Library
drawer lists saved layouts sorted by starred-first + most-recent,
with Load / Star / Duplicate / Delete actions.
- Copy Share Link writes a \${origin}/?fen=...&name=... URL to the
clipboard — pastes straight into the lobby's existing query-param
pre-select flow from Phase D.
- Esc closes the modal.
ui/Lobby.tsx:
- Custom... entry in the layout picker opens the editor.
- onApply(layout) commits the custom layout as the lobby's
current selection so Create Room ships it to the server.
e2e/layouts.spec.ts:
- Picker renders every premade + Custom entry.
- Selecting Dunsany updates description; ?layoutId=dunsany and
malformed ?fen behave correctly.
- Editor opens on Custom, validates king count, erases pieces,
loads FEN, commits via 'Use This Layout', saves to library
with cross-reload persistence, closes on Esc.
1025 unit tests passing; bun run check clean. Playwright suite
requires dev server — run with \`bun run --filter @paratype/chess e2e\`
when needed.
Wires the starting-layout picker into the lobby UI. Users can now:
- Pick a premade (Classic / Dunsany / Monster / Pawns-Only / Horde /
Knightmate / Chess960 / Empty) from the Host Game section.
- Paste a shareable link (?layoutId=dunsany or ?fen=<encoded>) to
pre-select a layout when arriving at the lobby.
- See the selected layout's name as a purple badge on the game view
header next to the room code (hidden for Classic, the default).
Components:
- ui/LayoutPicker.tsx: dropdown reading LAYOUT_REGISTRY. Chess960
re-seeds on each selection. Custom... entry can be wired to the
editor modal in Phase E.
- ui/Lobby.tsx: LayoutPicker above Create Room, URL-param reader
for ?layoutId / ?fen / ?name (validated via validateLayout —
malformed links silently fall back to Classic).
- ui/GameView.tsx: new LayoutBadge (inline) reads sessionStorage
'layout-name' written by Lobby on create/join.
Networking:
- net/types.ts: LayoutRequest discriminated union, PiecePlacementWire,
ResolvedLayoutWire added; RoomCreate/RoomCreated/RoomJoined
payloads now carry optional 'layout' fields mirroring the server.
- net/lobby-request.ts: OneShotRoomResult returns the resolved
layout from room.created / room.joined when the server echoes it.
Persistence:
- persist/autosave.ts: keyed-by-layout storage slots
(paratype-chess:v2:autosave:${layoutId}). One-time migration moves
the legacy v1 key into the classic slot so existing FIDE saves
survive. New clearAllAutoSaves wipes every layout slot; used by
Lobby when starting a fresh game.
1011 tests passing; bun run check clean. Manual smoke pending.
Extends the WebSocket protocol so clients can request a specific
starting layout when creating a room, and receive the resolved
layout echoed back on room.created / room.joined.
Protocol additions (Zod):
- room.create.payload.layout: optional discriminated union of
{ kind: "premade", id } | { kind: "fen", fen, name? }
| { kind: "custom", pieces, name? }
- room.created / room.joined: new optional 'layout' field with
resolved { id, name, pieces } — present on new servers, absent
on legacy ones (backward compat).
- LAYOUT_INVALID error code for validation failures.
- PiecePlacement + ResolvedLayout schemas.
Server implementation:
- New layouts.ts: resolveLayoutRequest() handles premade lookup,
FEN parse, custom pieces. Chess960 picks a random seed server-
side (the ultimate authority on 'which position'). Always runs
validateLayout — invalid input returns LAYOUT_INVALID, never
mutates room state.
- rooms.ts: Room.layout field + RoomRegistry.createRoom/joinRoom
accept/return resolved layouts. Default is CLASSIC_LAYOUT.
- game-session.ts: GameSession + GameSessionRegistry.create
accept StartingLayout; constructs ChessEngine({ layout }).
- broadcast.ts: handleRoomCreate resolves+validates first, rejects
with LAYOUT_INVALID toast, otherwise threads layout into the
room + session + response. handleRoomJoin echoes the room's
stored layout to the joiner.
Chess package:
- packages/chess/src/index.ts exports LAYOUT_REGISTRY, all premade
layouts, buildChess960Layout, toFen/fromFen, validateLayout,
and the related types — so server can pull them without
importing internal module paths.
Tests: 20 new tests (11 in protocol.test.ts for the layout union,
12 in layouts.test.ts for server-side resolution).
1011 tests passing; bun run check clean.
PROTOCOL.md updated with examples of all three layout kinds.
Ships the 7 premades (Classic was in Phase A, the rest here) plus
the FEN round-trip utility and layout validator.
New premades:
- dunsany: 31 white pawns + king vs full black army (asymmetric).
- monster: white king + 4 central pawns vs full black army.
- pawns-only: each side = king + 8 pawns on home rank.
- horde: white 'wall' of 35 pawns + king + 4 advanced vs full army.
- knightmate: swaps kings and knights on the back rank.
- chess960: Fischer Random shim with buildChess960Layout(seed)
factory following the Scharnagl numbering scheme. Position 518
matches FIDE. All 960 seeds are exhaustively tested for bishop-
opposite-colors + king-between-rooks invariants.
Deviations from canonical are documented in each file:
- Dunsany, Horde: canonical versions are kingless; we add a king on
e1 and swap out the conflicting pawn so the current validator
accepts them. Will ship canonical versions once capture-all or a
no-royalty preset lands.
- Knightmate: validator relaxed to 'at least 1 king per side'
(from 'exactly 1') to support multi-king layouts.
New utilities:
- fen.ts: toFen / fromFen for piece-placement field of standard FEN.
Supports custom piece types via {Type} bracket extension.
- validate.ts: errors on king-count/bounds/duplicate-square;
warnings on pawn-on-rank-1/8 and >32-material.
Tests: 50 new tests across fen.test.ts (16), validate.test.ts (17),
premades.test.ts (13), chess960.test.ts (4 incl. exhaustive 960-seed
invariant check).
991 tests passing; bun run check clean.
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.
React 18 StrictMode runs effects twice on mount in dev. The previous
state-based guard (joining) couldn't prevent the second invocation
from firing room.join because both passes see the deferred state
as null. The server accepts the first join as black, rejects the
second with ROOM_FULL, and the user sees an incorrect error toast.
Switching to useRef gives synchronous visibility — the second pass
sees joinedCodeRef.current already set and bails. Ref is reset on
.catch so a legitimate retry from the lobby isn't permanently
blocked.
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.
HP pips and any other per-piece overlays lived in the grid cell as
siblings of <Piece> \u2014 outside the transforming motion.div, so they
stayed pinned to the origin square while the piece visually slid
toward the cursor. Moved them inside the transform layer so they
inherit the same x/y/rotate/scale motion values and follow the
piece during drag and FLIP animations.
Piece accepts overlays + pieceFacts as props; Board just forwards
what it already computes. Same render cost, same wiring, visible
result: pips dangle with the piece.
Implements king-heals, poisoned-squares, capture-to-win,
last-piece-standing, explosive-rook, and queen-splits via the new
preset hook infrastructure. All six were registered but no-op stubs
with "// Full integration in ChessEngine (P3.11)" comments that
never got followed up.
New hooks on PresetDef
~~~~~~~~~~~~~~~~~~~~~~~
- onAfterMove(engine, moverColor)
Fires after applyMove`s turn switch but before game-result check.
Scope filtering is preset-side because rules have different
notions of "who does the hook target" (king-heals targets the
non-mover; poisoned-squares targets everyone on a poisoned
square).
- onCheckGameResult(engine) -> GameResult | undefined
Lets a preset override the engine`s default terminal-position
logic. First non-undefined return wins in registration order.
GameResult type extended with white-wins / black-wins so variant
rules can name the winner explicitly (checkmate implicitly means
"side to move loses" — insufficient for capture-to-win which can
end mid-move with either side winning).
Preset implementations
~~~~~~~~~~~~~~~~~~~~~~~
- king-heals: onAfterMove heals non-mover`s king +1 HP (max 3) when
not in check. Requires piece-hp.
- poisoned-squares: onAfterMove damages every piece standing on
d4/e4/d5/e5. Retracts pieces hitting 0 HP. Requires piece-hp.
Exports POISONED_SQUARES set for the UI overlay.
- capture-to-win: onBeforeCapture records the capturer`s color on
the game entity via Winner fact. onCheckGameResult converts it
into white-wins/black-wins. onDeactivate clears the Winner fact.
- last-piece-standing: onCheckGameResult counts pieces by color.
Zero on one side -> the other wins. Override returns "ongoing"
otherwise, suppressing default checkmate.
- explosive-rook: onBeforeCapture intercepts rook captures,
detonates all pieces within Chebyshev distance 2 on the rank
and file of the target (diagonals spared), moves the rook onto
the target square. Does not chain.
- queen-splits: onBeforeCapture intercepts queen captures,
retracts target and queen, spawns rook on target square and
bishop on first empty clockwise-from-N neighbour. Bishop is
forfeit if all 8 neighbours are occupied.
Board.tsx gains a per-SQUARE overlay registry (separate from per-
piece) so poisoned-squares can render a pulsing green tint on the
four central squares. The poisoned-squares.ui.tsx module registers
its overlay at load time via ui-overlays-index.ts.
GameView shows "White wins!"/"Black wins!" for the new result
variants; confetti fires on any decisive result; useChessEngine
and useMultiplayerGame play the checkmate sound on decisive results.
Server-side: GameEndReason extended with "variant-win" so the wire
protocol can signal decisive variant results without conflating
them with checkmate. MoveResult.gameOver exposes the correct
winner (not the mover) for white-wins/black-wins.
Testing
~~~~~~~
New fleshed-presets.test.ts: 13 integration tests covering each
preset`s canonical behaviour (healing cap, denial on check, poison
decrement + kill, first-capture-wins, annihilation override,
detonation AoE, queen fission spawn). 874 tests pass (+13); all 3
E2E specs green.
PredictionManager.applyPresets was calling activePresets.replaceAll
directly, bypassing preset onActivate / onDeactivate hooks on the
client engine. piece-hp installs Hp facts from onActivate, so in
multiplayer the client knew the preset was active but had no Hp
facts to render \u2014 the overlay silently drew nothing.
Routed through setActivePresets. Both server and client now run
the same idempotent onActivate, arriving at the same state without
serializing Hp facts over the wire. applyFullState (snapshot path)
still uses bare replaceAll because facts in the snapshot already
include any preset-installed attributes.
Implements the piece-hp (Hit Points) preset end-to-end and, more
importantly, sets up the infrastructure for rules that need state,
capture interception, or custom UI. Adding a new "guns" rule, a
"poison-cloud" visual, or similar now requires zero changes to
engine.ts, Board.tsx, or protocol.ts.
Engine / preset registry
~~~~~~~~~~~~~~~~~~~~~~~~
PresetDef gains three new optional hooks:
- onActivate(engine) \u2014 fires once on active-set transition
(inactive \u2192 active). Idempotent by
convention so sync paths that re-apply
from server state don\`t stomp values.
- onDeactivate(engine) \u2014 symmetric cleanup. Also fires when a
turn-limited preset expires via
tickAfterMove.
- onBeforeCapture(engine, attacker, target)
Fires immediately before the engine\`s default capture path.
Returns `{ consume: true }` to short-circuit: engine skips
target retraction AND attacker move. Used by piece-hp for
non-lethal damage; future rules can override however they like.
New ChessEngine.setActivePresets(requests) is the single entry point
that diffs old vs new and fires lifecycle hooks in deterministic
order (deactivate-then-activate). replaceAll stays public for tests
that want to bypass hooks.
ActivePresetSet.tickAfterMove now returns the list of expired ids
so the engine can fire onDeactivate on them.
piece-hp preset
~~~~~~~~~~~~~~~
- onActivate seeds Hp=2 on every piece.
- onDeactivate retracts Hp from all pieces.
- onBeforeCapture decrements Hp; if > 0 consumes the capture (attacker
stays, target survives, turn advances). At 0, returns without
consuming so the engine\`s default retract-and-move fires normally.
All capture sites intercepted: regular captures (engine.ts:200) AND
en-passant captures (engine.ts:194). The check-simulation path in
check.ts does NOT fire the hook \u2014 it uses an isolated snapshot
session, so lifecycle side effects don\`t leak into legal-move
filtering.
UI overlay registry
~~~~~~~~~~~~~~~~~~~
New packages/chess/src/ui/preset-overlays.tsx: a module-level registry
mapping preset id \u2192 React component that renders above each piece.
Board.tsx loads the registry via side-effect import and renders any
registered overlays for every active preset.
New packages/chess/src/presets/piece-hp.ui.tsx registers HealthBarPips:
2 pip dots above each piece, filled = remaining HP, empty = lost HP.
Pip color contrasts piece color for readability on either square.
Adding a new visual rule now costs 3 files and zero engine changes:
presets/foo.ts (mechanic + lifecycle hooks)
presets/foo.ui.tsx (overlay component + registry call)
presets/ui-overlays-index.ts (single-line import)
Testing
~~~~~~~
New piece-hp.test.ts: 8 integration tests covering lifecycle (seed,
retract, idempotent re-apply, no-fire on scope-only change) and
capture resolution (non-lethal decrement, lethal retract at HP=0,
HP drain over multiple captures, deactivate mid-game leaves damage
but retracts Hp). Total 861 tests pass (+8), 3/3 E2E green.
Product name: Houserules \u2014 chess with the rules you negotiate at the
table, not the ones FIDE hands you. Updated the three user-visible
strings: browser title, game-view header, lobby header. Kept the
@paratype/* npm scope intact since those are technical internals.
New tagline on the lobby: "Chess. Your rules." replacing the more
verbose "Realtime multiplayer with custom rules"; the shorter phrase
leans into the product name rather than describing features.
wrap-board previously only wrapped rooks and queens horizontally,
which meant knights, kings, bishops, and pawn captures couldn`t
cross the file seam at all — a knight on h4 with cylindrical
enabled had no wrap targets, contrary to the "horizontal cylinder"
framing. Rewrote the hook to handle every piece type:
knights/kings via mod-8 file offsets; bishops/queens/rooks via
cylindrical ray walkers; pawn captures via mod-8 diagonal targets.
Rank bounds still terminate walks (cylinder, not torus). Added 4
piece-type tests to cover the new cases.
Piece image flash on remount: every move triggers a FLIP unmount
at the source square and remount at the destination, producing a
brand-new <img> element. Browsers don`t block paint on image
load, so the alt text briefly rendered before the SVG decoded.
Preload and eagerly-decode every piece SVG at module init to keep
the decoded bitmap warm in the browser image cache, and set
`decoding="sync"` plus empty alt on the Piece img so the alt
never has a paint window. aria-label preserves the accessible
name.
wrap-board previously contributed a single one-square hop when the
rook/queen was already on an edge file, which is essentially a no-op
for 99% of positions. Rewrote the hook to implement an actual
cylindrical topology: sliders walk past the a/h seam and continue
along the rank on the opposite side, stopping at the first ally (no
destination) or enemy (capture). Preserves the rook-warp incompatibility
since the two presets have different "when does the wrap apply"
preconditions.
Check indicator: GameView looked for an `InCheck` boolean fact that
doesn`t exist in the session — check status is a derived predicate
(isInCheck), not a stored fact. Exported isInCheck from the package
public API, wired GameView to query it per render, added a pulsing
red "Check!" banner in the header, and fixed the useChessEngine
audio path which had the same stale-fact bug (was never playing the
check sound).
Tests: 6 new wrap-board tests covering empty-rank slides, partial
blocks, queen slides, and the no-op case for non-sliders. Total
849 tests pass (+6).
Pieces you can actually pick up (your color, your turn, game ongoing)
now lift slightly on mouseover — spring scale to 1.08 with a deeper
drop shadow — so you see at a glance which piece you are about to
grab. Opponent pieces and your pieces on the opponent\`s turn stay
static, so the hover never advertises an action you cannot take.
The effect is suppressed while actively dragging; the larger drag
scale already owns the visual treatment there.
Board now renders three visual states during a drag: faint dot for
legal quiet-move targets, ring for legal capture targets, and a bright
emerald fill when the cursor is actually over a valid drop square so
the user sees exactly where the piece will land. Hovering an invalid
square shows a subtle red tint telling the player the drag will snap
back on release.
Turn indicator shows "Your turn" (with a pulsing emerald dot and
green card treatment) when it`s the local player`s turn in
multiplayer, and "Opponent`s turn" otherwise. Solo play falls back
to the neutral "White`s turn" / "Black`s turn" phrasing.
Audited all 15 preset incompatibilities. Five of the six declared
pairs are genuine mechanical conflicts (rook-warp vs wrap-board have
contradictory board-topology semantics; piece-hp vs explosive-rook
define capture as damage vs removal; capture-to-win vs
last-piece-standing are competing win conditions). The
pawns-move-backward vs double-pawn-sprint pair is not — both are pure
additive getExtraMoves hooks over the base pawn rule; the move sets
are disjoint (backward-1 vs forward-2); and no generated move
contradicts any other.
Dropped the declaration from both preset files. Moved the
mutually-incompatible-pair tests to use rook-warp vs wrap-board,
which is the canonical genuine-conflict pair and exercises the same
compat machinery.
Presets previously lived on a process-global singleton with only a
binary on/off toggle. Two bugs followed:
1. Multiplayer illegal-move errors — the client-side toggle didn`t
reach the server, so optimistic moves legal under client rules got
rejected by the server`s unmodified ChessEngine.
2. No way to apply a rule to just white or just black, or to time-box
it for N turns.
Replaces the shared `PRESET_REGISTRY.active: Set<string>` with
instance-owned `ChessEngine.activePresets: ActivePresetSet`. Each
activation carries:
- scope: `both` | `white` | `black`
- turnsRemaining: positive int or null (permanent)
Engine reads `getForColor(color)` per piece, so scope=white never
contributes moves during black`s turn. `applyMove` calls
`tickAfterMove(moverColor)` which implements player-local counting:
white-only durations tick only when white moves.
Compatibility is the LOOSE rule — `incompatibleWith` blocks only when
the two activations have overlapping scopes. `scope=white` + `scope=black`
pair of otherwise-incompatible presets is allowed because the engine
never evaluates both for the same side.
Server changes: GameSession owns an ActivePresetSet. New protocol
messages:
- client → server: `room.setPresets` with full activation list
- server → client: `game.presets` broadcast on every set change
(post-setPresets + post-move-with-expiry)
`game.state` snapshots now include `activations` so reconnects pick
up the current rule set without extra round-trips.
Client changes: PredictionManager applies `game.presets` to the base
engine`s ActivePresetSet and re-renders via onStateChange; cloneEngine
carries activations onto the predicted clone. New hook surface:
- activations: readonly PresetActivation[]
- setPresets(next): replace the active set
useMultiplayerGame dispatches setPresets through the socket
(server-authoritative); useChessEngine mutates in-place (local mode).
UI: RulesDrawer + RulesView render scope radios (Both/White/Black)
and a duration input per active preset. Empty duration means
permanent, positive integers last N player-local turns.
Tests:
- 15 new ActivePresetSet unit tests (scope, tick, loose compat,
atomicity, clone)
- 4 new engine-presets integration tests (per-color, duration,
white-only vs black-only)
- Migrated older preset tests from `PRESET_REGISTRY.activate` to
the instance API
- New E2E regression test: enable knights-leap-twice scope=white in
multiplayer; verify the double-leap is accepted by the server,
verify black`s knight cannot use it