All 7 decisions locked 2026-04-21:
Feature 1 (MP color):
- Joiner-side choice: NO (creator picks, joiner takes rest)
- Re-pick mid-session: NO (fixed at room creation)
Feature 2 (Extinction UI):
- Multiplayer target-sync: NO in v1 (solo-only; MP fixed at
preset-activation time)
Feature 3 (Berolina ep):
- Variant: Parton 1952 (standard ep through Berolina geometry)
Feature 4 (Royalty transfer) — cleared to execute after 1-3:
- Turn-consumption: YES (prevents 'abdicate out of mate')
- UI: dedicated 'Actions' button in GameView
- Stakeholder demand: YES, validated
25 KiB
Post-Epic Deferrals — Design + Execution Plan
Scope: Four independent features previously deferred during or after the rule-variants epic. Each ships as its own mini-epic with verification gate. The features are sequenced so the SMALLEST / HIGHEST-VALUE item lands first.
Prior art:
.sisyphus/plans/rule-variants-v2.mdfor execution discipline, commit conventions, and the Final Verification Wave pattern. This plan mirrors that structure.
TL;DR
Ship 4 deferred items in sequence:
- Multiplayer color choice — Lobby UI + protocol field + server assignment logic. High user value, isolated surface, ~1 session.
- Extinction-chess UI target cycling — chip UI inside RulesDrawer to pick which piece type is the extinction target, without opening the preset-state API. ~½ session.
- Berolina en-passant — rule decision + implementation for pawn en-passant under
berolina-pawnsandberolina-pawns-2. PureoverridePieceMovesextension. ~½ session.- Tier 3 royalty-transfer — new preset family for "crown" moves that relocate royalty mid-game. Requires new engine surface (likely a lifecycle hook) + UI for target selection. Biggest risk + longest. ~1-2 sessions.
Recommendation: work items 1-3 sequentially (each is small and independent). Defer item 4 into its own follow-up session after 1-3 ship. The plan below fully specifies all four so any of them can be started independently.
Feature 1 — Multiplayer Color Choice
Current state (audited)
- Server hardcodes creator=white, joiner=black in
packages/server/src/rooms.ts:212,255. - Protocol has NO color preference field on
room.create/room.join; the server only RETURNS the assigned color inroom.created/room.joined. - Lobby UI has no color selector — creating a room gives you white, joining gives you black.
- Tests (
packages/chess/e2e/multiplayer.spec.ts) assume the hardcoded order: pageA=white, pageB=black. - No rematch / swap mechanism exists.
Design
Add a 3-option host-side preference: white | black | random. The
creator picks; the server resolves random at room-creation time; the
joiner gets whatever color the creator didn't take.
Explicitly OUT of scope for this feature:
- Joiner-side preference (complex: what if both want white?). Defer.
- Rematch-with-swapped-colors. Defer — separate feature.
- Color re-selection after room creation. Defer — needs server-side swap handling for an existing WS session.
Protocol changes
-
packages/server/src/protocol.ts:- Add
preferredColor: z.enum(["white", "black", "random"]).optional()toRoomCreatePayloadSchema. Omitting it ="white"(current behaviour, backward compat). - No change to
RoomJoinPayloadSchema(joiner takes the remaining color). - No change to response payloads — they already return the resolved
color in
room.created/room.joined.
- Add
-
packages/chess/src/net/types.ts: mirror the new field on the clientRoomCreatePayloadinterface. -
Zod v3↔v4 parity test: add to
packages/server/src/custom-modifier-wire-parity.test.tssibling (or createlobby-wire-parity.test.ts) — ensures the new field round-trips across the server/client schema boundary. Pattern: T3's Q4.2 parity test.
Server changes
packages/server/src/rooms.ts:
createRoom(
rulesetIds: string[] = [],
layout?: StartingLayout,
profile?: ModifierProfile,
preferredColor: "white" | "black" | "random" = "white",
): { code, token, color, layout, profile? } {
// Resolve random at creation time.
const creatorColor: "white" | "black" =
preferredColor === "random"
? (Math.random() < 0.5 ? "white" : "black")
: preferredColor;
// Store both the resolved creator color AND the joiner color
// on the room so joinRoom() returns the right one.
const joinerColor: "white" | "black" =
creatorColor === "white" ? "black" : "white";
// ... rest of createRoom, using creatorColor instead of the
// hardcoded "white" literal. Room now has a `joinerColor` field.
}
joinRoom(code: string): JoinResult {
// Read `room.joinerColor` instead of hardcoding "black".
const player: RoomPlayer = {
token,
color: room.joinerColor,
// ...
};
}
Random-seed determinism: use crypto.randomBytes or Math.random?
Math.random is fine for non-security randomness here. No test
flakiness concern — the color is known AFTER room creation.
Backward compat: clients sending the old payload (no
preferredColor) get the default "white", matching current
hardcoded behaviour exactly.
UI changes
packages/chess/src/ui/Lobby.tsx:
Add a color selector in the "Host Game" section, between the LayoutPicker and the Modifier Profile picker:
<div className="space-y-1.5">
<label className="text-xs font-semibold text-neutral-600">
Play as
</label>
<div className="flex gap-2">
{(["white", "black", "random"] as const).map((color) => (
<button
key={color}
data-testid={`color-preference-${color}`}
type="button"
onClick={() => setPreferredColor(color)}
className={/* active/inactive styling */}
>
{color === "white" ? "White" : color === "black" ? "Black" : "Random"}
</button>
))}
</div>
</div>
State: const [preferredColor, setPreferredColor] = useState<"white" | "black" | "random">("white");
handleCreate() includes createPayload.preferredColor = preferredColor.
Visual style matches the existing modifier-profile chip selector pattern for consistency.
Tests
Unit tests in packages/server/src/rooms.test.ts:
- Default (no
preferredColor) → creator=white, joiner=black (back-compat). preferredColor: "white"→ creator=white.preferredColor: "black"→ creator=black, joiner=white.preferredColor: "random"→ both outcomes sampled across 20 rooms.
E2E tests in packages/chess/e2e/multiplayer.spec.ts:
- Update existing "Scholar's Mate checkmate" test to still pass with the new default — should need zero changes.
- Add NEW test: host picks "black" → pageA=black, pageB=white. Verify color assignment end-to-end.
- Add NEW test: host picks "random" → one of the two valid assignments; test asserts "each client has exactly one of {white, black}" and they differ.
Playwright UI test: verify the color selector renders, all three options clickable, default is "white".
Execution checklist
- F1.1. Add
preferredColortoRoomCreatePayloadSchema(zod server) + client type mirror + parity test. - F1.2. Update
RoomRegistry.createRoomsignature; storejoinerColoron the room. - F1.3. Update
RoomRegistry.joinRoomto readroom.joinerColor. - F1.4. Add color preference UI to Lobby.tsx with
data-testidon each option. - F1.5. Update
Lobby.handleCreate()to includepreferredColorin the payload. - F1.6. Unit tests on rooms.ts (4 scenarios).
- F1.7. E2E tests: add 2 new multiplayer scenarios for black-host + random-host.
- F1.8. Run full Playwright (expect 85/85).
- F1.9. Commit:
feat(multiplayer): host color preference (white/black/random)
Risk register
- Test flakiness from random: ZERO risk — tests assert either color assignment works, not a specific outcome.
- Backward compat: backfilled default =
"white"makes old clients indistinguishable. - UI footprint: 3-button group; no new dependencies; matches existing Tailwind patterns.
Feature 2 — Extinction-Chess UI Target Cycling
Current state
extinction-chess preset (shipped commit 08b8e0f) uses
engine.presetState<{ targetType: PieceType }>("extinction-chess")
for configuration. Default seeded to "pawn". No UI exposure — users
must set via code or dev console.
Design
Add a chip cycler inside the RulesDrawer's extinction-chess card. Appears ONLY when extinction-chess is active. Clicking cycles through the 6 piece types: pawn → knight → bishop → rook → queen → king → pawn.
The card should visually indicate "Target: Pawns" (or similar) above the chip so inactive-mode users still see the default.
UI surface
packages/chess/src/ui/RulesDrawer.tsx: inside the
data-preset="extinction-chess" block, after the normal
description/toggle/deps rendering, insert a configuration row:
{preset.id === "extinction-chess" && isOn && (
<div className="mt-3 flex items-center gap-2">
<span className="text-xs font-semibold text-neutral-600">
Target:
</span>
<button
data-testid="extinction-target-cycler"
type="button"
onClick={cycleExtinctionTarget}
className="px-2.5 py-1 rounded-md bg-neutral-100 hover:bg-neutral-200 text-xs font-medium text-neutral-700 capitalize"
>
{currentExtinctionTarget}
</button>
</div>
)}
The cycleExtinctionTarget callback is passed in as a prop (parent
owns the state). RulesDrawer reads the current target from
engine.presetState<{ targetType: PieceType }>("extinction-chess")
via a new prop extinctionTarget: PieceType and calls
setExtinctionTarget(next) on click.
State flow
The host manages extinctionTarget in GameView or via a dedicated
hook:
- On preset activation: read current from
engine.presetState. - On cycler click: compute next type in the sequence, update preset state, and force a re-render of the RulesDrawer.
- In multiplayer: changing the target is an authoritative action that must propagate via a new WS message (see below).
Protocol changes (multiplayer support)
Adding a custom-modifier-config.update style message would over-engineer
this. Piggyback on the existing preset-activation rebroadcast by
including the target type in a new lightweight message, OR bake it
into the existing preset activation request.
Recommended approach: Reuse the modifier-profile.propose /
modifier-profile.update mechanism's pattern but for preset state.
This is a bigger lift. ALTERNATIVE: defer multiplayer sync of the
target to follow-up. For this feature, support is local-only
(solo play) and multiplayer games use whatever target was set at
room-creation time.
DECISION: ship solo-only target cycling in v1. If user demand
follows, add a preset-config.update WS message in v2. Document the
solo-only limitation in-UI ("Target: Pawns (set before multiplayer
game starts)" hint).
Tests
Unit: none needed — the preset logic is already covered.
Component/integration: a RulesDrawer test verifying the cycler renders when extinction-chess is active, cycles through all 6 types, and updates the engine's preset state.
E2E (Playwright): solo scenario — activate extinction-chess, click cycler 3 times, verify displayed target is now "rook" (or whatever 3 clicks from pawn yields).
Execution checklist
- F2.1. Add
extinctionTargetprop +onExtinctionTargetCyclecallback to RulesDrawer props. - F2.2. Render cycler inside extinction-chess card when the preset is active.
- F2.3. GameView manages the state and threads props to RulesDrawer.
- F2.4. E2E test in
rule-variants.spec.ts(additive). - F2.5. Documentation: add a "Target cycling" note to
extinction-chess.tsdocblock referencing the UI. - F2.6. Commit:
feat(ui): extinction-chess target cycler in rules drawer
Risk register
- Multiplayer sync gap: shipped solo-only. Room-creation-time target is fixed for the session. Acceptable because changing extinction targets mid-game is unusual.
- Chip state-sync bug: engine preset state is the source of truth. RulesDrawer reads on every render; no local component state.
Feature 3 — Berolina En-Passant
Current state
berolina-pawns + berolina-pawns-2 (shipped commits 5393b96,
ee08e20) explicitly defer en-passant. Current behavior: no
en-passant captures exist for Berolina pawns. Pawns that
double-diagonal-push past a square an enemy pawn could have
captured it on cannot be ep-captured — the opportunity is lost.
Design
Classic Berolina rule variant: if a Berolina pawn double-pushes via a diagonal, and an enemy Berolina pawn is adjacent on the SKIPPED square's file (the middle square of the diagonal), the enemy can capture orthogonally forward onto the skipped square, removing the double-pushed pawn (standard en-passant semantics reflected through Berolina geometry).
Rule authority: multiple Berolina rule sets exist; we're picking
the most common (Parton 1952 variant). Document explicitly in the
preset docblock that other variants exist and can be added as
additional presets if demand emerges (e.g. berolina-pawns-3 with
different ep semantics).
Implementation path
Since berolina-pawns uses overridePieceMoves (not composes with
default en-passant), we must synthesize ep moves in the override
itself. The engine already tracks EnPassantTarget in the game-level
facts; our override can read it and emit the ep move.
packages/chess/src/presets/berolina-pawns.ts:
- In
overridePieceMoves, after computing push/capture/promotion:- Read
engine.session.get(GAME_ENTITY, "EnPassantTarget"). - If non-null and this pawn can capture (orthogonally forward) to the ep square → emit the ep capture move.
- Read
- Engine's
applyMovealready updatesEnPassantTargeton every move; no engine change needed. BUT — the engine sets it based on FIDE-style pawn double-push (rank+2 orthogonal). For Berolina double-push (diagonal), we need to override this.
Engine-side extension (minor, in-scope): add a hook or expose
setEnPassantTarget so the preset can update the target on its own
double-push path. OR: handle the ep target update inside the
preset's onAfterMove — cleaner since the preset owns the
mechanics.
Recommended path: handle both emission AND target-update in the preset:
overridePieceMovesemits the ep capture whenEnPassantTargetis set.onAfterMovedetects when the preset's own pawn just did a double-diagonal-push, and callsengine.session.insert(GAME_ENTITY, "EnPassantTarget", skippedSquare).- Add a tag / metadata to the LegalMove (e.g.
isEnPassant: true, already a field on LegalMove per existing FIDE en-passant handling) so the engine's capture-resolution path removes the captured pawn.
Edge cases
- Berolina + FIDE pawn mix: impossible in practice (the preset
replaces pawn rules for all pawns of the scoped color(s)). But
with
scope: "white", black pawns follow FIDE ep rules — the engine's default ep handling covers black. Verify the hybrid case. - Promotion + ep: ep capture landing on the 1st/8th rank is theoretically possible but extremely unusual on a fresh board. Keep the ep capture non-promoting; document.
berolina-pawns-2shares the same ep rule: the sideways captures don't change ep geometry.
Tests
Add to berolina-pawns.test.ts:
- ep capture available after enemy double-diagonal-push (4 positions).
- ep capture NOT available after single-push (no ep target).
- ep capture is lost if not taken on the next turn (standard ep semantics).
- ep captured pawn is correctly removed from the board.
- Scope interaction:
scope: "white"means white gets Berolina ep, black still uses FIDE ep.
Similar additions to berolina-pawns-2.test.ts.
Execution checklist
- F3.1. Add ep-capture emission to
berolina-pawns.tsoverridePieceMoves. - F3.2. Add
onAfterMovehandler that setsEnPassantTargeton Berolina double-push. - F3.3. Copy/reuse ep logic into
berolina-pawns-2.ts. - F3.4. Add 5+ ep-specific tests to each preset's test file.
- F3.5. Update the preset docblock: remove the "deferred" language, document the authoritative rule choice.
- F3.6. Update
RULES.md— remove the en-passant deferred note from the Berolina gallery entries. - F3.7. Commit:
feat(presets): berolina-pawns en-passant (both variants)
Risk register
- Ambiguity of Berolina ep: 2-3 published variants exist. We pick the most common (Parton). Document authoritatively; other variants are their own presets.
- EnPassantTarget collision with FIDE ep: on a scope-flipped activation, both sides might set the target — but only one side can double-push per turn. No real collision.
Feature 4 — Tier 3 Royalty-Transfer
Current state
Tier 3 presets from the rule-variants design doc (explicitly deferred
in commit 3cef8f5 closing the epic). The canonical example:
Abdication / Knight-Queen Transfer — at any time, the king can
"abdicate" to a chosen friendly piece, transferring royalty to that
piece (and possibly vice versa).
Why it was deferred
- Requires a mid-game player action beyond "one move at a time".
The existing engine only processes
LegalMove; there's no "action" channel for non-move actions. - Requires UI for target selection — click a piece → "transfer royalty here".
- Multiplayer semantics: a royalty transfer is a turn-consuming action? Or free? How do both clients agree?
Design
Introduce a new category: player actions that are not LegalMove
but consume a turn (or not, configurable).
Engine surface
Add a new public method engine.performAction(action: PlayerAction): ActionResult. PlayerAction is a tagged union:
type PlayerAction =
| { kind: "transferRoyalty"; fromPieceId: EntityId; toPieceId: EntityId };
Add a new preset hook performAction(ctx: PlayerActionContext): ActionResult | undefined. First non-undefined wins. Context includes
action, mover, engine.
Engine dispatch: similar to applyMove but for actions. Fires
onBeforeAction → resolver → onAfterAction → turn advance
decision → onTurnStart / onCheckGameResult.
Turn advance semantics: same shouldAdvanceTurn hook. Presets
can veto as they do for moves.
Preset: transferable-royalty
PRESET_REGISTRY.register({
id: "transferable-royalty",
name: "Transferable Royalty",
description: "Royal pieces can transfer their royalty to another friendly piece once per game.",
// ...
performAction({ engine, mover, action }) {
if (action.kind !== "transferRoyalty") return undefined;
// Validate: fromPiece is currently royal, toPiece is friendly + not already royal.
// Mark state: `transferredFrom[mover] = fromPieceId`; future `getRoyalPieces`
// returns `toPieceId` instead of `fromPieceId`.
return { consumed: true, turnAdvances: true };
},
getRoyalPieces({ engine, color }) {
// Read preset state — if a transfer has occurred for this color,
// return the new royal set (original royals minus transferred-from,
// plus transferred-to).
},
});
UI
- Trigger: right-click on a friendly piece → context menu with "Transfer royalty to here" (requires a royal piece already selected OR a 2-click flow).
- Indicator: selected royal → hover other friendlies → candidate targets highlighted in a distinct color (e.g. gold outline).
- Confirmation: modal "Transfer royalty from King (e1) to Queen (d1)?" — prevents accidental triggers.
- Turn consumed: the action ticks the turn; show in the moveLog
as a "royalty-transfer" event (new
MoveRecordtag).
Protocol (multiplayer)
Add a new WS message: game.action with payload { token, action: PlayerAction }. Server validates and broadcasts the
resulting state via the existing state-update path.
Prerequisites
This is a bigger lift than the first three features. Before starting:
- Confirm stakeholder demand — is there a real user need for this?
- Decide turn-consumption semantics (a free action would be wildly unbalanced).
- Nail the UI interaction (2-click vs context menu vs modal).
Execution checklist (draft — to be refined)
- F4.1. Engine surface:
PlayerActiontype +performActionmethod. - F4.2. Preset hook:
performAction. - F4.3.
MoveRecordextended withactionKind?field for logging. - F4.4.
transferable-royaltypreset implementation + tests. - F4.5. Protocol:
game.actionWS message (server + client parity). - F4.6. UI: target selection + confirmation modal in GameView.
- F4.7. E2E: solo scenario + multiplayer scenario.
- F4.8. Docs: PRESET-API.md section on actions-not-moves; RULES.md gallery entry.
- F4.9. Commit:
feat(engine): player actions + transferable-royalty preset
Risk register
- Engine surface expansion: significant. Every
applyMove-adjacent assumption (move-generates-next-turn, move-log, checkGameResult timing) needs to be re-audited for the action path. Mitigation: keep theperformActionpath structurally parallel toapplyMove; reuse the same hook dispatch order. - UI interaction ambiguity: "right-click is not discoverable". Alternative: a dedicated "Actions" button in GameView that opens a menu. Safer default for touch devices too.
- Multiplayer sync: action takes effect server-side first; client receives the state update. Same pattern as moves — no new consistency issues.
- State lifecycle: the "transfer once per game" constraint lives in preset state. Ensure it's serialized in GameStatePayload (same as custom modifiers — pattern from T3 Q4.1).
Parallel Execution Map
Feature 1 (MP color choice) — 9 tasks — 1 session
F1.1-F1.8 serial (each builds on previous) → F1.9 commit
Feature 2 (Extinction-chess UI) — 6 tasks — ½ session
F2.1-F2.5 serial → F2.6 commit
Feature 3 (Berolina ep) — 7 tasks — ½ session
F3.1-F3.6 serial → F3.7 commit
Feature 4 (Royalty-transfer) — 9 tasks — 1-2 sessions
Needs stakeholder alignment FIRST.
Recommended sequencing: 1 → 2 → 3 → 4. All decisions locked as of 2026-04-21 — Feature 4 is cleared to execute after 1-3 ship.
Features 1-3 are INDEPENDENT and can be done in any order. Feature 4 should come last because its engine-surface expansion is the largest and is least likely to conflict with the others if deferred.
Verification Gate (applies to every feature)
Pattern mirrors the rule-variants epic's Final Verification Wave:
- F1 (oracle): plan compliance — every declared task checked.
- F2 (unspecified-high): code quality — 0 anti-patterns, tests ≥ 8
per new preset,
incompatibleWithgraph symmetric. - F3 (manual QA + playwright): end-to-end smoke.
- F4 (deep): scope fidelity — no drive-by refactors, no unrelated protocol changes.
Scale the wave down for Feature 2 (no new preset; just UI) and Feature 3 (no new preset; just preset extension): F2 + F3 reviewers only, F1 + F4 skipped.
Glossary
- PlayerAction: a turn-consuming event that is NOT a
LegalMove. Introduced in Feature 4 (deferred). - Royalty transfer: a PlayerAction that reassigns the "royal" flag between friendly pieces. Core of Feature 4.
- En-passant target:
GAME_ENTITY.EnPassantTargetfact tracking the square a pawn skipped on its most recent double-push; valid for exactly one following half-move. Feature 3 extends this to Berolina geometry. - Target cycling: the UI pattern Feature 2 introduces for preset-state configuration without requiring a dedicated editor.
Decision log (pre-implementation)
All 7 decisions LOCKED 2026-04-21. Below is the authoritative answer for each — planners / executors use these unless a separate signed-off change supersedes.
| Feature | Decision | LOCKED |
|---|---|---|
| 1 | Joiner-side color choice? | NO — creator picks, joiner takes the remaining color. Eliminates "both want white" deadlock class. |
| 1 | Re-pick colors mid-session? | NO — fixed at room creation. Swap-colors rematch is a separate future feature. |
| 2 | Multiplayer target-sync in v1? | NO — solo-only. Multiplayer target is whatever was set when the preset was activated (usually room-creation time). v2 can add a preset-config.update WS message if demand follows. |
| 3 | Which Berolina ep variant? | Parton 1952 — standard ep semantics reflected through Berolina's diagonal-push / orthogonal-capture geometry. Other variants ship as separate presets (berolina-pawns-3 etc.) if demand emerges. |
| 4 | Turn-consumption of royalty transfer? | YES — consumes a turn (balance). Prevents the "abdicate out of mate" degenerate case. Reflected in performAction returning turnAdvances: true. |
| 4 | UI for target selection? | Dedicated "Actions" button in GameView opens a menu (Transfer royalty…). Better discoverability than right-click; works on touch devices. |
| 4 | Stakeholder validated? | YES — demand confirmed 2026-04-21. Cleared to execute. |
Early-stop / scope-reduction triggers
If tool budget or context pressure intrudes:
- After Feature 1: ship MP color choice alone. Highest user value. Features 2-4 become their own follow-ups.
- After Feature 1 + 2: ship both polish items. Deferred gallery note updates land later.
- After Feature 1 + 2 + 3: feature-complete short of Tier 3. Royalty-transfer becomes its own epic with its own plan.
In every case: update this plan's checkboxes, append a
## Early Stop section, and do NOT declare the full plan complete.
Momus review instructions
When this plan is saved, invoke Momus via:
task(subagent_type="momus", prompt=".sisyphus/plans/post-epic-deferrals.md")
Momus will evaluate against rigorous clarity / verifiability / completeness standards. Incorporate feedback before starting any feature.