- DECISIONS.md: in-game chat (player↔player and human↔Gemma) deferred indefinitely. Blind-mode chat is a side channel that defeats the moderator-vocabulary security boundary; chat with Gemma leaks belief state mid-game. Resolvable but expensive — revisit only on demand. - Spec: same deferral noted in "Out of scope". - New plan: docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md — 13 tasks, 80 sub-steps. Phase 1 only (Casual bot end-to-end). Phase 2 (Recon) gets its own plan once Phase 1 outcomes inform Recon's target. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
89 KiB
AI/Computer Player — Phase 1 (Casual Bot) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship a Casual algorithmic bot opponent end-to-end on https://chess.sethpc.xyz — humans can play a legal blind-chess or vanilla-chess game alone, on demand, against an in-process bot that picks moves with simple heuristics.
Architecture: A Brain strategy interface + a per-game BotDriver orchestrator live under packages/server/src/bot/. The driver subscribes to game state changes by being poked from ws.ts after each state-mutating handler. CasualBrain is pure TypeScript with no I/O. Bots are virtual in-process players: their PlayerSlot is filled with no socket; they consume only buildView(game, botColor) + announcements; they dispatch moves through the same handleCommit FSM humans use. Phase 2 will swap in ReconBrain against the same driver.
Tech Stack: Node 22 + TypeScript, Fastify + ws, chess.js v1.4.0, Svelte 5 + Vite, vitest, pnpm workspace. No new runtime dependencies.
Source spec: docs/superpowers/specs/2026-04-28-ai-player-design.md
Phase 1 scope: §"Components" / Brain, CasualBrain, BotDriver, bot registry, candidate computation. §"Touches in existing code" except aiInfo and post-game thoughts log (those are Phase 2). §"Data flow / Game creation (vs Casual)". §"Testing" Casual layers + 1 integration test. §Phase-1 acceptance bars.
Phase 2 (Recon, deferred): ReconBrain, OllamaClient, ollama-endpoints, prompt, parse, GPU preflight + failover, aiInfo protocol field, post-game reasoning reveal. Will get its own plan after Phase 1 ships and self-play results inform the Recon target.
File Structure
New files (all under packages/server/src/bot/ unless noted):
| File | Responsibility |
|---|---|
brain.ts |
Brain interface, BrainInput, BrainAction, CandidateMove types. No logic. |
candidates.ts |
legalCandidates(game, color): CandidateMove[] — vanilla path uses chess.js .moves({verbose: true}), blind path uses geometricMoves over own pieces (+ promotion expansion). |
casual-brain.ts |
CasualBrain class: scoring heuristics, attemptHistory exclusion, queen-default promotion, draw auto-response. |
driver.ts |
BotDriver class: per-game, mutex, retry cap, dispose-on-end. Imports handleCommit and dispatches actions through it. |
index.ts |
Public re-exports (createBotDriver, types). |
packages/server/test/unit/bot/candidates.test.ts |
Unit tests for legalCandidates. |
packages/server/test/unit/bot/casual-brain.test.ts |
Unit tests for CasualBrain. |
packages/server/test/unit/bot/driver.test.ts |
Unit tests for BotDriver (with a StubBrain defined in the test file). |
packages/server/test/integration/ai-game-casual.test.ts |
Real-WS integration test: human + Casual bot play a scripted game end-to-end. |
scripts/selfplay.ts |
Operator CLI. NOT in CI. Runs Casual-vs-Casual N times, reports stats. |
Modified files:
| File | Why |
|---|---|
packages/shared/src/protocol.ts |
Add vsAi?: { brain: 'casual' | 'recon' } to CreateGameRequest. (Phase 1 implements only 'casual'; 'recon' accepted but rejected at runtime in Phase 1.) |
packages/server/src/validation.ts |
Validate the new vsAi field. |
packages/server/src/state.ts |
Add Game.aiOpponent?: { color: Color; brain: 'casual' | 'recon' } (informational). |
packages/server/src/games.ts |
createGame accepts vsAi; if set, fills the bot slot with a synthetic PlayerSlot (no socket). Add bot driver registry: attachBotDriver, getBotDriver, disposeBotDriver. |
packages/server/src/server.ts |
POST /api/games reads vsAi, instantiates CasualBrain + BotDriver, attaches to registry. Returns joinUrl: null when AI game (not shareable). |
packages/server/src/ws.ts |
After every state-mutating handler (onHello-activates-game, onCommit-applied, onResign, onOfferDraw, onRespondDraw, endGame), call pokeBot(game). New helper pokeBot(game) looks up the driver and fires onStateChange(). |
packages/client/src/lib/Landing.svelte |
Two-section layout: "Play with a friend" (existing) + "Play vs Computer" with two buttons (Casual = wired in Phase 1, Recon = disabled placeholder for Phase 2). |
packages/client/src/lib/Game.svelte |
(Read first to find opponent indicator.) Show "Casual bot" badge on bot's slot. Show "Casual bot is moving..." during bot turns. Source flag from a new aiOpponent field on joined/update payloads. |
packages/server/src/server.ts (response shape) and packages/shared/src/protocol.ts (server msgs) |
Add aiOpponent?: { brain: 'casual' | 'recon'; color: Color } to joined and update so client knows. (aiInfo with model/GPU details is Phase 2.) |
package.json (root) |
Add "selfplay": "tsx scripts/selfplay.ts" script. |
DECISIONS.md |
Append Phase 1 outcome. |
CLAUDE.md |
Update "Current State" line from "designed, not built" to "Phase 1 deployed". |
Pre-flight (Task 0)
- Step 0.1: Verify clean tree, on
main, MVP tests pass
Run: git status && git rev-parse --abbrev-ref HEAD && pnpm -r test
Expected: clean tree, on main, 43 passing (21 shared + 22 server).
- Step 0.2: Create implementation branch
Run: git checkout -b feat/ai-player-phase-1-casual
Expected: branch created and checked out.
- Step 0.3: Backup files that will be edited
Per global safety rule. The .backup/ directory is gitignored.
mkdir -p .backup/p1
ts=$(date +%s)
for f in packages/shared/src/protocol.ts \
packages/server/src/validation.ts \
packages/server/src/state.ts \
packages/server/src/games.ts \
packages/server/src/server.ts \
packages/server/src/ws.ts \
packages/client/src/lib/Landing.svelte \
DECISIONS.md \
CLAUDE.md \
package.json; do
cp "$f" ".backup/p1/$(basename $f).$ts"
done
ls -la .backup/p1/
Expected: 10 files copied.
Task 1: Brain interface + types
Files:
-
Create:
packages/server/src/bot/brain.ts -
Create:
packages/server/src/bot/index.ts -
Step 1.1: Write the type declarations
Create packages/server/src/bot/brain.ts:
import type {
Announcement,
BoardView,
Color,
PromotionType,
Square,
} from '@blind-chess/shared';
import type { ModeratorText } from '@blind-chess/shared';
export interface CandidateMove {
from: Square;
to: Square;
promotion?: PromotionType;
}
export interface AttemptHistoryEntry {
move: CandidateMove;
rejection: ModeratorText;
}
export interface BrainInput {
view: BoardView;
newAnnouncements: Announcement[];
legalCandidates: CandidateMove[];
attemptHistory: AttemptHistoryEntry[];
drawOfferFromOpponent: boolean;
ply: number;
}
export type BrainAction =
| { type: 'commit'; from: Square; to: Square; promotion?: PromotionType }
| { type: 'resign' }
| { type: 'offer-draw' }
| { type: 'respond-draw'; accept: boolean };
export interface BrainInitArgs {
color: Color;
mode: 'blind' | 'vanilla';
gameId: string;
}
export interface Brain {
init(args: BrainInitArgs): Promise<void>;
decide(input: BrainInput): Promise<BrainAction>;
dispose?(): Promise<void>;
}
- Step 1.2: Create the bot module index
Create packages/server/src/bot/index.ts:
export type {
Brain, BrainInput, BrainAction, BrainInitArgs,
CandidateMove, AttemptHistoryEntry,
} from './brain.js';
- Step 1.3: Typecheck
Run: pnpm --filter @blind-chess/server typecheck
Expected: no errors. (Imports are types only, no runtime code.)
- Step 1.4: Commit
git add packages/server/src/bot/brain.ts packages/server/src/bot/index.ts
git commit -m "feat(bot): scaffold Brain interface and types"
Task 2: legalCandidates — candidate move computation
Files:
- Create:
packages/server/src/bot/candidates.ts - Create:
packages/server/test/unit/bot/candidates.test.ts
The function takes a Game and a Color and returns the bot's legal-from-its-perspective candidates. In vanilla mode, that's chess.js .moves({verbose: true}) (truly legal). In blind mode, it's the union of geometricMoves over each own piece, with promotion expansion. Blind candidates may include moves the FSM later rejects with wont_help (pin / unresolved check) — that's expected; the driver will retry.
- Step 2.1: Write failing tests
Create packages/server/test/unit/bot/candidates.test.ts:
import { describe, it, expect } from 'vitest';
import { Chess } from 'chess.js';
import { legalCandidates } from '../../../src/bot/candidates.js';
import type { Game } from '../../../src/state.js';
import { RATE_LIMIT } from '../../../src/state.js';
function makeGame(mode: 'blind' | 'vanilla', fen?: string): Game {
return {
id: 'cand0001',
mode,
highlightingEnabled: false,
status: 'active',
createdAt: Date.now(),
chess: fen ? new Chess(fen) : new Chess(),
moveHistory: [],
announcements: [],
players: {
w: { token: 'w'.repeat(24), socket: null, joinedAt: 0,
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
b: { token: 'b'.repeat(24), socket: null, joinedAt: 0,
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
},
armed: null,
drawOffer: null,
disconnectAt: {},
};
}
describe('legalCandidates / vanilla', () => {
it('starting position: 20 candidates for white', () => {
const game = makeGame('vanilla');
const candidates = legalCandidates(game, 'w');
expect(candidates.length).toBe(20);
});
it('returns from/to on each candidate', () => {
const game = makeGame('vanilla');
const candidates = legalCandidates(game, 'w');
expect(candidates.every((c) => c.from && c.to)).toBe(true);
});
it('vanilla excludes pinned-piece moves (chess.js filters self-check)', () => {
// White king e1, white bishop e2, black rook e8. Bishop is pinned.
const game = makeGame('vanilla', '4r2k/8/8/8/8/8/4B3/4K3 w - - 0 1');
const candidates = legalCandidates(game, 'w');
// Bishop on e2 has zero legal moves (any move drops the king to check).
expect(candidates.find((c) => c.from === 'e2')).toBeUndefined();
});
it('vanilla expands all 4 promotion options', () => {
// White pawn on a7, ready to promote.
const game = makeGame('vanilla', '4k3/P7/8/8/8/8/8/4K3 w - - 0 1');
const candidates = legalCandidates(game, 'w').filter((c) => c.from === 'a7');
expect(candidates.map((c) => c.promotion).sort()).toEqual(['b', 'n', 'q', 'r']);
});
});
describe('legalCandidates / blind', () => {
it('starting position: 20 geometric candidates for white', () => {
// 16 pawn moves (8 single + 8 double) + 4 knight moves = 20.
const game = makeGame('blind');
const candidates = legalCandidates(game, 'w');
expect(candidates.length).toBe(20);
});
it('blind INCLUDES pinned-piece moves (geometric does not know about pins)', () => {
// Same pinned-bishop position. Geometric move-gen sees no own piece blocking;
// bishop can geometrically reach d3, c4, b5, a6, f3, etc.
const game = makeGame('blind', '4r2k/8/8/8/8/8/4B3/4K3 w - - 0 1');
const candidates = legalCandidates(game, 'w');
expect(candidates.some((c) => c.from === 'e2')).toBe(true);
});
it('blind expands all 4 promotion options for own pawn', () => {
const game = makeGame('blind', '4k3/P7/8/8/8/8/8/4K3 w - - 0 1');
const candidates = legalCandidates(game, 'w').filter((c) => c.from === 'a7');
expect(candidates.map((c) => c.promotion).sort()).toEqual(['b', 'n', 'q', 'r']);
});
it('blind ignores whose turn it is (returns moves for either color)', () => {
// Vanilla path filters by chess.js .moves() which respects toMove. Blind
// path iterates own pieces directly, so black candidates exist on move 0.
const game = makeGame('blind');
const candidates = legalCandidates(game, 'b');
expect(candidates.length).toBe(20);
});
it('zero own pieces = zero candidates (degenerate)', () => {
// FEN with only black king + pieces — but FEN must be valid, kings required.
const game = makeGame('blind', '4k3/8/8/8/8/8/8/4K3 w - - 0 1');
const black = legalCandidates(game, 'b');
// Black king on e8 has 5 geometric king moves (d8, f8, d7, e7, f7).
expect(black.length).toBe(5);
});
});
Run: pnpm --filter @blind-chess/server test -- candidates.test.ts
Expected: FAIL — Cannot find module '../../../src/bot/candidates.js'.
- Step 2.2: Implement
legalCandidates
Create packages/server/src/bot/candidates.ts:
import {
geometricMoves,
type Color,
type Piece,
type PieceType,
type PromotionType,
type Square,
} from '@blind-chess/shared';
import type { Game } from '../state.js';
import { ownSquares } from '../view.js';
import type { CandidateMove } from './brain.js';
const PROMOTION_TYPES: PromotionType[] = ['q', 'r', 'b', 'n'];
export function legalCandidates(game: Game, color: Color): CandidateMove[] {
if (game.mode === 'vanilla') return vanillaCandidates(game, color);
return blindCandidates(game, color);
}
function vanillaCandidates(game: Game, color: Color): CandidateMove[] {
// chess.js only returns moves for the side to move via `.moves()`. To get a
// hypothetical move list for the other color we'd need to rotate — but the
// bot driver only invokes legalCandidates when it's the bot's turn, so this
// is fine in practice. Tests for "wrong color" use blind mode.
if (game.chess.turn() !== color) return [];
const moves = game.chess.moves({ verbose: true }) as Array<{
from: Square; to: Square; promotion?: PromotionType;
}>;
const out: CandidateMove[] = [];
for (const m of moves) {
out.push({ from: m.from, to: m.to, promotion: m.promotion });
}
return out;
}
function blindCandidates(game: Game, color: Color): CandidateMove[] {
const own = ownSquares(game, color);
const board = game.chess.board();
const out: CandidateMove[] = [];
for (const row of board) {
if (!row) continue;
for (const cell of row) {
if (!cell) continue;
if (cell.color !== color) continue;
const piece: Piece = { color: cell.color, type: cell.type as PieceType };
const from = cell.square as Square;
const tos = geometricMoves(piece, from, own);
for (const to of tos) {
if (isPromotionSquare(piece, to)) {
for (const promo of PROMOTION_TYPES) {
out.push({ from, to, promotion: promo });
}
} else {
out.push({ from, to });
}
}
}
}
return out;
}
function isPromotionSquare(piece: Piece, to: Square): boolean {
if (piece.type !== 'p') return false;
const rank = to[1];
return (piece.color === 'w' && rank === '8') || (piece.color === 'b' && rank === '1');
}
- Step 2.3: Run tests
Run: pnpm --filter @blind-chess/server test -- candidates.test.ts
Expected: 7 tests pass.
- Step 2.4: Commit
git add packages/server/src/bot/candidates.ts packages/server/test/unit/bot/candidates.test.ts
git commit -m "feat(bot): legalCandidates for vanilla and blind modes"
Task 3: CasualBrain — algorithmic strategy
Files:
- Create:
packages/server/src/bot/casual-brain.ts - Create:
packages/server/test/unit/bot/casual-brain.test.ts
CasualBrain is pure: receives BrainInput, returns BrainAction. No I/O, deterministic when seeded. Scoring per spec:
+50if destination is reachable but not own-occupied (capture proxy in blind mode; explicit-capture in vanilla viachess.js Move.captured).+30if first 8 plies and the move develops a knight or bishop from rank 1 (white) / rank 8 (black).+25if pawn move toward center (e/d files preferred).+15for rank advancement toward opponent.-40anti-shuffling penalty on a queen/rook/minor that hasn't moved yet if a knight or bishop on its starting square is also a candidate (i.e., we'd rather develop a minor first).- Tiny seedable random tiebreak (epsilon ~0.01).
Promotion default: queen. Draw response: accept at material parity (counted from view.pieces only — biased and weak by design), decline at lead. Casual never resigns voluntarily. On attemptHistory rejection, re-score and pick a different top.
- Step 3.1: Write failing tests
Create packages/server/test/unit/bot/casual-brain.test.ts:
import { describe, it, expect } from 'vitest';
import { CasualBrain } from '../../../src/bot/casual-brain.js';
import type { BrainInput, CandidateMove } from '../../../src/bot/brain.js';
import type { BoardView } from '@blind-chess/shared';
function makeInput(overrides: Partial<BrainInput> = {}): BrainInput {
const view: BoardView = {
pieces: { e2: { color: 'w', type: 'p' } },
toMove: 'w',
inCheck: false,
};
return {
view,
newAnnouncements: [],
legalCandidates: [{ from: 'e2', to: 'e4' }],
attemptHistory: [],
drawOfferFromOpponent: false,
ply: 0,
...overrides,
};
}
describe('CasualBrain', () => {
it('init() resolves', async () => {
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'casualab' });
});
it('single candidate -> picks it', async () => {
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
const action = await brain.decide(makeInput());
expect(action.type).toBe('commit');
if (action.type === 'commit') {
expect(action.from).toBe('e2');
expect(action.to).toBe('e4');
}
});
it('zero candidates -> throws', async () => {
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
await expect(brain.decide(makeInput({ legalCandidates: [] }))).rejects.toThrow();
});
it('attemptHistory excludes the rejected move', async () => {
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
const input = makeInput({
legalCandidates: [
{ from: 'e2', to: 'e4' },
{ from: 'd2', to: 'd4' },
],
attemptHistory: [{ move: { from: 'e2', to: 'e4' }, rejection: 'wont_help' }],
});
const action = await brain.decide(input);
expect(action.type).toBe('commit');
if (action.type === 'commit') {
expect(action.from).toBe('d2');
expect(action.to).toBe('d4');
}
});
it('promotion: when multiple candidates differ only by promotion, picks queen', async () => {
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
const candidates: CandidateMove[] = [
{ from: 'a7', to: 'a8', promotion: 'q' },
{ from: 'a7', to: 'a8', promotion: 'r' },
{ from: 'a7', to: 'a8', promotion: 'b' },
{ from: 'a7', to: 'a8', promotion: 'n' },
];
const action = await brain.decide(makeInput({ legalCandidates: candidates }));
if (action.type === 'commit') expect(action.promotion).toBe('q');
});
it('draw offer at material parity -> accept', async () => {
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
// View shows white has 1 queen, 1 rook. Material counter doesn't see opponent.
// Casual heuristic: parity inferred from "I have N pieces, assume opponent has N".
// For unit test we fix a view + drawOfferFromOpponent and assert accept.
const view: BoardView = {
pieces: {
e1: { color: 'w', type: 'k' },
a1: { color: 'w', type: 'r' },
},
toMove: 'w', inCheck: false,
};
const action = await brain.decide({
view, newAnnouncements: [], legalCandidates: [{ from: 'e1', to: 'e2' }],
attemptHistory: [], drawOfferFromOpponent: true, ply: 30,
});
expect(action.type).toBe('respond-draw');
if (action.type === 'respond-draw') expect(action.accept).toBe(true);
});
it('never voluntarily offers resign', async () => {
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
// 50 random plies; assert never resigns.
for (let i = 0; i < 50; i++) {
const action = await brain.decide(makeInput({ ply: i }));
expect(action.type).not.toBe('resign');
}
});
it('seeded determinism: same seed + same input -> same move', async () => {
const candidates: CandidateMove[] = [
{ from: 'e2', to: 'e4' },
{ from: 'd2', to: 'd4' },
{ from: 'g1', to: 'f3' },
];
const a = new CasualBrain({ seed: 42 });
await a.init({ color: 'w', mode: 'blind', gameId: 'g1' });
const b = new CasualBrain({ seed: 42 });
await b.init({ color: 'w', mode: 'blind', gameId: 'g1' });
const aAct = await a.decide(makeInput({ legalCandidates: candidates }));
const bAct = await b.decide(makeInput({ legalCandidates: candidates }));
expect(aAct).toEqual(bAct);
});
it('opening moves favor center pawns over flank pawns (e/d > a/h)', async () => {
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
const candidates: CandidateMove[] = [
{ from: 'a2', to: 'a3' },
{ from: 'h2', to: 'h3' },
{ from: 'e2', to: 'e4' },
{ from: 'd2', to: 'd4' },
];
// Many seeds → assert e2 or d2 wins majority.
let centerHits = 0;
for (let s = 0; s < 20; s++) {
const b = new CasualBrain({ seed: s });
await b.init({ color: 'w', mode: 'blind', gameId: 'g1' });
const a = await b.decide(makeInput({ legalCandidates: candidates, ply: 0 }));
if (a.type === 'commit' && (a.from === 'e2' || a.from === 'd2')) centerHits++;
}
expect(centerHits).toBeGreaterThan(15);
});
});
Run: pnpm --filter @blind-chess/server test -- casual-brain.test.ts
Expected: FAIL — module missing.
- Step 3.2: Implement
CasualBrain
Create packages/server/src/bot/casual-brain.ts:
import type { BoardView, Color, PieceType, Square } from '@blind-chess/shared';
import type {
Brain,
BrainAction,
BrainInitArgs,
BrainInput,
CandidateMove,
} from './brain.js';
interface CasualOpts {
seed?: number;
}
const PIECE_VALUE: Record<PieceType, number> = {
p: 1, n: 3, b: 3, r: 5, q: 9, k: 0,
};
export class CasualBrain implements Brain {
private color: Color = 'w';
private mode: 'blind' | 'vanilla' = 'blind';
private rng: () => number;
constructor(opts: CasualOpts = {}) {
this.rng = mulberry32(opts.seed ?? Math.floor(Math.random() * 0xffffffff));
}
async init(args: BrainInitArgs): Promise<void> {
this.color = args.color;
this.mode = args.mode;
}
async decide(input: BrainInput): Promise<BrainAction> {
if (input.drawOfferFromOpponent) {
return { type: 'respond-draw', accept: this.acceptDraw(input.view) };
}
const filtered = this.excludeRejected(input.legalCandidates, input.attemptHistory);
if (filtered.length === 0) {
throw new Error('CasualBrain: zero candidates after exclusion');
}
const scored = filtered.map((c) => ({
move: c,
score: this.scoreMove(c, input.view, input.ply) + this.rng() * 0.01,
}));
scored.sort((a, b) => b.score - a.score);
const choice = scored[0]!.move;
return {
type: 'commit',
from: choice.from,
to: choice.to,
promotion: choice.promotion,
};
}
private excludeRejected(
candidates: CandidateMove[],
history: BrainInput['attemptHistory'],
): CandidateMove[] {
if (history.length === 0) return candidates;
const rejected = new Set(history.map((h) => moveKey(h.move)));
return candidates.filter((c) => !rejected.has(moveKey(c)));
}
private scoreMove(move: CandidateMove, view: BoardView, ply: number): number {
let score = 0;
// Capture proxy: destination not own-occupied. (In view, we only see own
// pieces; if dest has a piece, it's ours -> not a capture. If empty, may
// be a capture or just empty — guess.)
const destPiece = view.pieces[move.to];
if (!destPiece) score += 50;
const piece = view.pieces[move.from];
if (!piece) return score; // shouldn't happen, but safe.
const ownStartingRank = this.color === 'w' ? '1' : '8';
const ownPawnStartingRank = this.color === 'w' ? '2' : '7';
// Development bonus for first 16 plies (8 moves per side).
if (ply < 16 && (piece.type === 'n' || piece.type === 'b')
&& move.from[1] === ownStartingRank) {
score += 30;
}
// Center pawn bonus.
if (piece.type === 'p' && move.from[1] === ownPawnStartingRank) {
const file = move.from[0];
if (file === 'd' || file === 'e') score += 25;
else if (file === 'c' || file === 'f') score += 10;
}
// Rank-advance bonus toward opponent.
const fromRank = parseInt(move.from[1]!, 10);
const toRank = parseInt(move.to[1]!, 10);
const advance = this.color === 'w' ? toRank - fromRank : fromRank - toRank;
if (advance > 0) score += 15 * advance;
// Anti-shuffling: penalize moving major pieces from start before knights/bishops.
if (move.from[1] === ownStartingRank && (piece.type === 'q' || piece.type === 'r')) {
score -= 40;
}
// Promotion bias toward queen.
if (move.promotion === 'q') score += 100;
else if (move.promotion) score += 50;
return score;
}
private acceptDraw(view: BoardView): boolean {
// Crude material count from own view only. Accept if "low material"
// (assume opponent symmetric). Decline if "high material".
let own = 0;
for (const sq of Object.keys(view.pieces) as Square[]) {
const p = view.pieces[sq];
if (p) own += PIECE_VALUE[p.type];
}
// Accept if own material < 15 (rough endgame threshold).
return own < 15;
}
}
function moveKey(m: CandidateMove): string {
return `${m.from}-${m.to}${m.promotion ?? ''}`;
}
// Mulberry32 PRNG: seedable, fast, good enough for tiebreaks.
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
- Step 3.3: Run tests
Run: pnpm --filter @blind-chess/server test -- casual-brain.test.ts
Expected: 9 tests pass.
- Step 3.4: Commit
git add packages/server/src/bot/casual-brain.ts packages/server/test/unit/bot/casual-brain.test.ts
git commit -m "feat(bot): CasualBrain with capture/development/center heuristics"
Task 4: BotDriver — per-game orchestration
Files:
- Create:
packages/server/src/bot/driver.ts - Create:
packages/server/test/unit/bot/driver.test.ts
The driver wires a Brain to a Game:
- Mutex:
decideInFlight: boolean— secondonStateChangewhile one is in flight is a no-op. - Trigger: caller invokes
driver.onStateChange()after every state mutation. Driver decides whether to firedecide(). - Decision loop: computes
BrainInputfrom currentGame,await brain.decide(), dispatches the action through the samehandleCommit(andendGame/onResign-equivalents) the WS layer uses. Onwont_help/illegal_moverejection, append toattemptHistoryand calldecideagain. Bounded retry 5; on cap-hit, dispatch{type: 'resign'}. - Dispose: when
game.status === 'finished', dispose brain (callbrain.dispose?.()) and stop acceptingonStateChange.
The driver depends on small dispatch helpers from existing code paths. To avoid duplicating logic, expose tiny pure helpers in a new packages/server/src/bot/dispatch.ts. Or just have the driver call handleCommit, endGame-equivalent, and translator.announce directly — they're already pure-ish. We do that to keep modules focused.
Important: the driver must NOT call ws.ts broadcast functions directly (circular import risk and ws.ts owns socket state). Instead, it pokes the same state-mutating helpers the WS layer pokes, and the WS layer does its own broadcasting after the driver returns. Sequence: human commits → ws.ts handles → ws.ts broadcasts to humans → ws.ts pokes driver → driver runs decide() → driver calls handleCommit (mutates state, returns announcements/move) → driver records announcements onto game.announcements already (handleCommit does this) → driver returns → ws.ts broadcasts the new state to all humans (a follow-up broadcastUpdate call after pokeBot).
So the contract is:
pokeBot(game)is called by ws.ts.pokeBotreturnsPromise<void>that resolves after the driver has finished any synchronous chain of actions (e.g., bot moves, then game ends, OR bot moves, then it's bot's turn again — wait, bots only play one color, so no chained turns). The caller (ws.ts) then broadcasts the resulting state to all sockets.
For Phase 1 the chain is at most 1 deep:
- Bot moves → human's turn (ws.ts broadcasts → human moves → ws.ts pokes again).
- Bot resigns → game ends (ws.ts broadcasts the resignation announcement and end state).
For draw offers:
-
Human offers draw → ws.ts pokes bot → bot calls
respondDraw(true|false)→ game ends (accept) or drawOffer cleared (decline). -
Step 4.1: Write failing tests
Create packages/server/test/unit/bot/driver.test.ts:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Chess } from 'chess.js';
import { BotDriver } from '../../../src/bot/driver.js';
import type { Brain, BrainAction, BrainInput } from '../../../src/bot/brain.js';
import type { Game } from '../../../src/state.js';
import { RATE_LIMIT } from '../../../src/state.js';
function makeGame(opts: { mode?: 'blind' | 'vanilla'; fen?: string; status?: Game['status'] } = {}): Game {
return {
id: 'gabcd123',
mode: opts.mode ?? 'blind',
highlightingEnabled: false,
status: opts.status ?? 'active',
createdAt: Date.now(),
chess: opts.fen ? new Chess(opts.fen) : new Chess(),
moveHistory: [],
announcements: [],
players: {
w: { token: 'w'.repeat(24), socket: null, joinedAt: 0,
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
b: { token: 'b'.repeat(24), socket: null, joinedAt: 0,
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
},
armed: null,
drawOffer: null,
disconnectAt: {},
aiOpponent: { color: 'b', brain: 'casual' },
};
}
class StubBrain implements Brain {
public decideCalls = 0;
private script: BrainAction[] = [];
init = vi.fn(async () => {});
dispose = vi.fn(async () => {});
decide = vi.fn(async (_input: BrainInput): Promise<BrainAction> => {
this.decideCalls++;
if (this.script.length === 0) {
// Default: trivial commit on any legal candidate.
if (_input.legalCandidates.length === 0) throw new Error('no candidates');
const c = _input.legalCandidates[0]!;
return { type: 'commit', from: c.from, to: c.to, promotion: c.promotion };
}
return this.script.shift()!;
});
enqueue(...actions: BrainAction[]) { this.script.push(...actions); }
}
describe('BotDriver', () => {
let game: Game;
let brain: StubBrain;
let driver: BotDriver;
beforeEach(async () => {
game = makeGame();
brain = new StubBrain();
driver = new BotDriver({ game, brain, color: 'b' });
await driver.init();
});
it('init() invokes brain.init with correct args', async () => {
expect(brain.init).toHaveBeenCalledWith({
color: 'b',
mode: 'blind',
gameId: 'gabcd123',
});
});
it('onStateChange does nothing when not bot turn', async () => {
// White to move (start). Bot is black.
await driver.onStateChange();
expect(brain.decide).not.toHaveBeenCalled();
});
it('onStateChange fires decide when it is bot turn', async () => {
// Make a move so it is black's turn.
game.chess.move('e4');
await driver.onStateChange();
expect(brain.decide).toHaveBeenCalledTimes(1);
// Stub commits the first candidate; chess.js should advance to white.
expect(game.chess.turn()).toBe('w');
});
it('mutex: second onStateChange while in-flight is a no-op', async () => {
game.chess.move('e4');
let release: () => void;
const gate = new Promise<void>((r) => { release = r; });
brain.decide.mockImplementationOnce(async (input) => {
await gate;
const c = input.legalCandidates[0]!;
return { type: 'commit', from: c.from, to: c.to };
});
const p1 = driver.onStateChange();
const p2 = driver.onStateChange();
release!();
await Promise.all([p1, p2]);
expect(brain.decide).toHaveBeenCalledTimes(1);
});
it('retry on wont_help: pinned bishop scenario', async () => {
// Black-to-move version of the pinned-piece test:
// Black king h8, black bishop e7 pinned by white rook on e1.
const fen = '4k2K/4b3/8/8/8/8/8/4R3 b - - 0 1';
game = makeGame({ fen });
brain = new StubBrain();
driver = new BotDriver({ game, brain, color: 'b' });
await driver.init();
// Stub will return a pinned-bishop move first; FSM rejects with wont_help;
// driver should retry with attemptHistory and a fresh decide call returns a
// legal king move.
brain.enqueue(
{ type: 'commit', from: 'e7', to: 'd6' }, // pinned, rejected
{ type: 'commit', from: 'h8', to: 'g8' }, // king sidestep, accepted
);
await driver.onStateChange();
expect(brain.decide).toHaveBeenCalledTimes(2);
expect(game.chess.turn()).toBe('w'); // turn advanced
});
it('retry cap (5): after 5 wont_help, driver resigns the bot', async () => {
const fen = '4k2K/4b3/8/8/8/8/8/4R3 b - - 0 1';
game = makeGame({ fen });
brain = new StubBrain();
driver = new BotDriver({ game, brain, color: 'b' });
await driver.init();
// 6 pinned attempts in a row — driver should resign on the 6th instead.
for (let i = 0; i < 6; i++) {
brain.enqueue({ type: 'commit', from: 'e7', to: 'd6' });
}
await driver.onStateChange();
expect(game.status).toBe('finished');
expect(game.endReason).toBe('resign');
expect(game.winner).toBe('w');
});
it('respond-draw: when drawOffer is from opponent, driver fires decide and dispatches', async () => {
game.drawOffer = { from: 'w', at: Date.now() };
brain.enqueue({ type: 'respond-draw', accept: true });
await driver.onStateChange();
expect(brain.decide).toHaveBeenCalledTimes(1);
expect(game.status).toBe('finished');
expect(game.endReason).toBe('draw_agreed');
});
it('dispose on game finished: subsequent onStateChange is a no-op', async () => {
game.chess.move('e4');
game.status = 'finished';
await driver.onStateChange();
expect(brain.decide).not.toHaveBeenCalled();
expect(brain.dispose).toHaveBeenCalled();
});
});
Run: pnpm --filter @blind-chess/server test -- driver.test.ts
Expected: FAIL — module missing.
- Step 4.2: Implement
BotDriver
Create packages/server/src/bot/driver.ts:
import type { Color } from '@blind-chess/shared';
import type { Game } from '../state.js';
import type {
AttemptHistoryEntry,
Brain,
BrainAction,
BrainInput,
CandidateMove,
} from './brain.js';
import { legalCandidates } from './candidates.js';
import { handleCommit } from '../commit.js';
import { buildView } from '../view.js';
import { announce } from '../translator.js';
const RETRY_CAP = 5;
interface BotDriverOpts {
game: Game;
brain: Brain;
color: Color;
}
export class BotDriver {
private game: Game;
private brain: Brain;
private color: Color;
private decideInFlight = false;
private disposed = false;
private lastSeenAnnouncementCount = 0;
constructor(opts: BotDriverOpts) {
this.game = opts.game;
this.brain = opts.brain;
this.color = opts.color;
}
async init(): Promise<void> {
await this.brain.init({
color: this.color,
mode: this.game.mode,
gameId: this.game.id,
});
this.lastSeenAnnouncementCount = this.game.announcements.length;
}
async onStateChange(): Promise<void> {
if (this.disposed) return;
if (this.game.status === 'finished') {
await this.disposeBrain();
return;
}
if (this.decideInFlight) return;
if (!this.shouldDecide()) return;
this.decideInFlight = true;
try {
await this.runDecisionCycle();
} finally {
this.decideInFlight = false;
}
}
/** True if the brain should be invoked given current game state. */
private shouldDecide(): boolean {
if (this.game.status !== 'active') return false;
if (this.game.drawOffer && this.game.drawOffer.from !== this.color) return true;
if (this.game.chess.turn() === this.color) return true;
return false;
}
private async runDecisionCycle(): Promise<void> {
const attemptHistory: AttemptHistoryEntry[] = [];
for (let attempt = 0; attempt < RETRY_CAP; attempt++) {
const input = this.buildBrainInput(attemptHistory);
let action: BrainAction;
try {
action = await this.brain.decide(input);
} catch (e) {
// Brain exception => bot resigns. CasualBrain only throws on zero
// candidates (impossible if shouldDecide passed). Phase 2 ReconBrain
// has its own retry/fallback layer before reaching here.
this.botResign(`brain_error: ${(e as Error).message}`);
return;
}
const outcome = this.dispatch(action);
if (outcome.kind === 'done') return;
// outcome.kind === 'retry': record the rejection and loop.
attemptHistory.push(outcome.entry);
}
this.botResign('retry_cap');
}
private buildBrainInput(attemptHistory: AttemptHistoryEntry[]): BrainInput {
const view = buildView(this.game, this.color);
const sliceStart = this.lastSeenAnnouncementCount;
this.lastSeenAnnouncementCount = this.game.announcements.length;
const newAnnouncements = this.game.announcements
.slice(sliceStart)
.filter((a) => a.audience === 'both' || a.audience === this.color);
const candidates: CandidateMove[] = legalCandidates(this.game, this.color);
return {
view,
newAnnouncements,
legalCandidates: candidates,
attemptHistory,
drawOfferFromOpponent: !!(this.game.drawOffer && this.game.drawOffer.from !== this.color),
ply: this.game.chess.history().length,
};
}
/** Dispatch a brain action. `done` = cycle complete; `retry` = loop again. */
private dispatch(
action: BrainAction,
): { kind: 'done' } | { kind: 'retry'; entry: AttemptHistoryEntry } {
switch (action.type) {
case 'commit': {
const result = handleCommit(this.game, this.color, {
from: action.from, to: action.to, promotion: action.promotion,
});
if (result.kind === 'applied') return { kind: 'done' };
if (result.kind === 'announce') {
const text = result.announcements[0]!.text;
if (text === 'wont_help' || text === 'illegal_move'
|| text === 'no_such_piece' || text === 'no_legal_moves') {
return {
kind: 'retry',
entry: {
move: { from: action.from, to: action.to, promotion: action.promotion },
rejection: text,
},
};
}
}
if (result.kind === 'silent') {
// Bot sent `from` only (arming). CasualBrain always commits with
// `to`; treat as a logic error and resign safely.
this.botResign('bot_armed_only');
return { kind: 'done' };
}
// result.kind === 'error' (not_your_turn etc.) — bug path; resign.
this.botResign('commit_error');
return { kind: 'done' };
}
case 'resign':
this.botResign('voluntary');
return { kind: 'done' };
case 'offer-draw':
if (!this.game.drawOffer) {
this.game.drawOffer = { from: this.color, at: Date.now() };
}
return { kind: 'done' };
case 'respond-draw':
if (!this.game.drawOffer || this.game.drawOffer.from === this.color) {
return { kind: 'done' };
}
if (action.accept) {
const ply = this.game.chess.history().length;
const a = announce('draw_agreed', 'both', ply);
this.game.announcements.push(a);
this.game.drawOffer = null;
this.game.status = 'finished';
this.game.endReason = 'draw_agreed';
this.game.winner = null;
this.game.finishedAt = Date.now();
} else {
this.game.drawOffer = null;
}
return { kind: 'done' };
}
}
private botResign(_reason: string): void {
if (this.game.status !== 'active') return;
const ply = this.game.chess.history().length;
const text = this.color === 'w' ? 'white_resigned' : 'black_resigned';
const a = announce(text, 'both', ply);
this.game.announcements.push(a);
this.game.status = 'finished';
this.game.endReason = 'resign';
this.game.winner = this.color === 'w' ? 'b' : 'w';
this.game.finishedAt = Date.now();
}
private async disposeBrain(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
try {
await this.brain.dispose?.();
} catch {/* ignore */}
}
}
- Step 4.3: Run tests
Run: pnpm --filter @blind-chess/server test -- driver.test.ts
Expected: 8 tests pass.
- Step 4.4: Re-export driver from index
Edit packages/server/src/bot/index.ts:
export type {
Brain, BrainInput, BrainAction, BrainInitArgs,
CandidateMove, AttemptHistoryEntry,
} from './brain.js';
export { CasualBrain } from './casual-brain.js';
export { BotDriver } from './driver.js';
export { legalCandidates } from './candidates.js';
- Step 4.5: Commit
git add packages/server/src/bot/driver.ts packages/server/src/bot/index.ts \
packages/server/test/unit/bot/driver.test.ts
git commit -m "feat(bot): BotDriver with mutex, retry cap, and dispatch"
Task 5: Game.aiOpponent, bot-driver registry, protocol additions
Files:
-
Modify:
packages/server/src/state.ts -
Modify:
packages/server/src/games.ts -
Modify:
packages/shared/src/protocol.ts -
Modify:
packages/server/src/validation.ts -
Step 5.1: Add
aiOpponenttoGametype
Edit packages/server/src/state.ts. Insert into the Game interface (right after disconnectAt):
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
- Step 5.2: Add bot-driver registry to
games.ts
Edit packages/server/src/games.ts. After the existing games Map declaration, add:
import type { BotDriver } from './bot/driver.js';
const botDrivers = new Map<GameId, BotDriver>();
export function attachBotDriver(id: GameId, driver: BotDriver): void {
botDrivers.set(id, driver);
}
export function getBotDriver(id: GameId): BotDriver | undefined {
return botDrivers.get(id);
}
export function disposeBotDriver(id: GameId): void {
botDrivers.delete(id);
}
Also extend pruneFinished to clean orphan drivers:
export function pruneFinished(): number {
const now = Date.now();
let removed = 0;
for (const [id, g] of games) {
if (g.status === 'finished' && g.finishedAt && now - g.finishedAt > PRUNE_AFTER_FINISHED_MS) {
games.delete(id);
botDrivers.delete(id);
removed++;
}
}
return removed;
}
- Step 5.3: Extend
createGameto optionally fill bot slot
Edit packages/server/src/games.ts createGame:
export function createGame(opts: {
mode: Mode;
creatorSide: Color;
highlightingEnabled: boolean;
vsAi?: { brain: 'casual' | 'recon' };
}): { game: Game; creatorToken: PlayerToken } {
const id = newGameId();
const creatorToken = newPlayerToken();
const now = Date.now();
const botColor: Color | null = opts.vsAi
? (opts.creatorSide === 'w' ? 'b' : 'w')
: null;
const game: Game = {
id,
mode: opts.mode,
highlightingEnabled: opts.highlightingEnabled,
status: 'waiting',
createdAt: now,
chess: new Chess(),
moveHistory: [],
announcements: [],
players: {
w: opts.creatorSide === 'w' ? makeSlot(creatorToken, now)
: (botColor === 'w' ? makeBotSlot(now) : null),
b: opts.creatorSide === 'b' ? makeSlot(creatorToken, now)
: (botColor === 'b' ? makeBotSlot(now) : null),
},
armed: null,
drawOffer: null,
disconnectAt: {},
aiOpponent: opts.vsAi && botColor
? { color: botColor, brain: opts.vsAi.brain }
: undefined,
};
games.set(id, game);
return { game, creatorToken };
}
function makeBotSlot(now: number) {
return {
token: 'bot' + 'x'.repeat(21), // 24-char placeholder; never matched by real client.
socket: null,
joinedAt: now,
rateBucket: { tokens: RATE_LIMIT.capacity, last: now },
};
}
- Step 5.4: Protocol additions
Edit packages/shared/src/protocol.ts:
export interface CreateGameRequest {
mode: Mode;
side: Color | 'random';
highlightingEnabled: boolean;
vsAi?: { brain: 'casual' | 'recon' };
}
export interface CreateGameResponse {
gameId: GameId;
creatorToken: PlayerToken;
joinUrl: string | null;
}
Also extend the joined and update server messages to optionally include aiOpponent:
export type ServerMessage =
| {
type: 'joined';
you: Color | 'spectator-rejected';
token: PlayerToken;
view: BoardView;
announcements: Announcement[];
gameStatus: GameStatus;
mode: Mode;
highlightingEnabled: boolean;
opponentConnected: boolean;
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
}
| {
type: 'update';
view: BoardView;
newAnnouncements: Announcement[];
gameStatus: GameStatus;
touchedPiece?: Square;
drawOffer?: { from: Color } | null;
endReason?: EndReason;
winner?: Color | null;
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
}
// ... rest unchanged
(Note: 'ai_unavailable' is in spec but only emitted in Phase 2; deferring its addition to EndReason until Phase 2.)
- Step 5.5: Validate
vsAionPOST /api/games
Edit packages/server/src/validation.ts:
export const createGameSchema = z.object({
mode: z.union([z.literal('blind'), z.literal('vanilla')]),
side: z.union([colorSchema, z.literal('random')]),
highlightingEnabled: z.boolean(),
vsAi: z.object({
brain: z.union([z.literal('casual'), z.literal('recon')]),
}).optional(),
});
- Step 5.6: Typecheck and rebuild shared
Run: pnpm --filter @blind-chess/shared build && pnpm --filter @blind-chess/server typecheck
Expected: shared builds; server typecheck passes.
- Step 5.7: Run all tests (regression)
Run: pnpm -r test
Expected: all 43 + new tests pass. (Existing FSM/view/integration tests should still pass since Game only got an optional field.)
- Step 5.8: Commit
git add packages/shared/src/protocol.ts \
packages/server/src/state.ts \
packages/server/src/games.ts \
packages/server/src/validation.ts
git commit -m "feat(bot): protocol vsAi/aiOpponent fields, bot-slot synthesis, driver registry"
Task 6: Wire POST /api/games to instantiate the driver
Files:
-
Modify:
packages/server/src/server.ts -
Step 6.1: Read the current
POST /api/gameshandler
Already read in pre-flight — server.ts lines 42-56.
- Step 6.2: Update the handler
Edit packages/server/src/server.ts. Replace the existing POST /api/games handler with:
fastify.post('/api/games', async (req, reply) => {
const parsed = createGameSchema.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'malformed', detail: parsed.error.issues };
}
const { mode, side, highlightingEnabled, vsAi } = parsed.data;
// Phase 1: only 'casual' is implemented. 'recon' returns 503.
if (vsAi && vsAi.brain === 'recon') {
reply.code(503);
return { error: 'ai_offline', detail: 'recon bot not yet implemented' };
}
const creatorSide = chooseSide(side);
const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled, vsAi });
// For AI games, wire the bot.
if (vsAi && game.aiOpponent) {
const brain = new CasualBrain({});
const driver = new BotDriver({ game, brain, color: game.aiOpponent.color });
await driver.init();
attachBotDriver(game.id, driver);
}
const publicBase = PUBLIC_BASE
|| (req.headers.host ? `${req.protocol}://${req.headers.host}` : '');
const joinUrl = vsAi ? null : `${publicBase}/g/${game.id}`;
return { gameId: game.id, creatorToken, creatorColor: creatorSide, joinUrl };
});
Add the imports near the top of server.ts:
import { CasualBrain, BotDriver } from './bot/index.js';
import { attachBotDriver } from './games.js';
- Step 6.3: Typecheck
Run: pnpm --filter @blind-chess/server typecheck
Expected: no errors.
- Step 6.4: Commit
git add packages/server/src/server.ts
git commit -m "feat(bot): POST /api/games instantiates CasualBrain + BotDriver"
Task 7: ws.ts state-change hooks
Files:
- Modify:
packages/server/src/ws.ts
The driver needs to be poked after each state-mutating action. The simplest way: introduce a single pokeBot(game) helper that looks up the driver and awaits its onStateChange(). Call it after:
onHelloif game became active.onCommitafterapplied.onCommitafterannounceonly if it was the bot's announcement (it isn't — bots dispatch via the driver, not viaonMessage); skip.onResignafterendGame.onOfferDrawafter offer registered.onRespondDrawafter responded.maybeAbandonafterendGame.
After the bot acts, the bot's actions may have modified game state (a move applied, a draw accepted). We then need to broadcast the resulting state to all human sockets. The simplest pattern: every place we currently call broadcastNewAnnouncements or broadcastUpdate, we instead call pokeAndBroadcast(game, newAnnouncements?) which:
- Pokes the bot.
- After bot returns, broadcasts the current announcements queue (everything since the last broadcast point).
- If the game ended during bot action, broadcasts that too.
This needs care because broadcastNewAnnouncements currently takes "the new announcements just produced" as an arg. After the bot moves, the bot's announcements have already been pushed onto game.announcements by handleCommit / botResign. We need to broadcast those too.
Cleaner approach: track lastBroadcastIdx per-color. On every broadcast, send the slice from each color's lastBroadcastIdx to current end. This makes the bot's contribution naturally included.
Simpler still for Phase 1: after handler completes, after pokeBot, just call a fresh broadcastUpdate(game) (no newAnnouncements) followed by sending the full new-announcements slice. But that would re-send announcements clients already have.
Cleanest minimal change: add a lastBroadcastAt tracking on the game, slice announcements from there each time we broadcast.
Edit state.ts:
export interface Game {
// ... existing fields
lastBroadcastIdx?: { w: number; b: number };
}
In createGame, initialize: lastBroadcastIdx: { w: 0, b: 0 }.
- Step 7.1: Add
lastBroadcastIdxto Game
Edit packages/server/src/state.ts — add lastBroadcastIdx: { w: number; b: number } to the Game interface.
Edit packages/server/src/games.ts createGame to initialize:
lastBroadcastIdx: { w: 0, b: 0 },
(Insert into the Game literal.)
- Step 7.2: Refactor
ws.tsto slice-from-idx broadcasting
Edit packages/server/src/ws.ts. Replace broadcastNewAnnouncements with broadcastSinceLast:
function broadcastSinceLast(game: Game, extra?: { touchedPieceFor?: Color; touchedPiece?: string }): void {
for (const c of ['w', 'b'] as const) {
const lastIdx = game.lastBroadcastIdx?.[c] ?? 0;
const all = game.announcements;
const slice = all.slice(lastIdx).filter((a) => a.audience === 'both' || a.audience === c);
sendUpdateTo(game, c, slice, extra?.touchedPieceFor === c ? { touchedPiece: extra.touchedPiece } : undefined);
if (game.lastBroadcastIdx) game.lastBroadcastIdx[c] = all.length;
}
}
Replace existing call sites:
// onCommit applied:
finalizeIfEnded(game, result.announcements);
await pokeBot(game);
broadcastSinceLast(game);
// onCommit silent:
sendUpdateTo(game, color, [], { touchedPiece: msg.from });
// onCommit announce:
broadcastSinceLast(game);
// onResign:
endGame(game, 'resign', color === 'w' ? 'b' : 'w');
await pokeBot(game); // not strictly needed (game ended), but consistent.
broadcastSinceLast(game);
// onOfferDraw:
game.drawOffer = { from: color, at: Date.now() };
await pokeBot(game);
broadcastUpdate(game); // existing helper, sends full update with no new announcements
broadcastSinceLast(game); // pick up any draw_agreed announcement the bot may have added
// onRespondDraw:
// (after the existing logic)
await pokeBot(game);
broadcastSinceLast(game);
// onHello (when game becomes active):
if (game.status === 'active') {
await pokeBot(game);
broadcastSinceLast(game); // covers bot-as-white opening move
}
// maybeAbandon (after endGame):
broadcastSinceLast(game);
And add the helper:
async function pokeBot(game: Game): Promise<void> {
const driver = getBotDriver(game.id);
if (!driver) return;
try {
await driver.onStateChange();
} catch (err) {
fastify.log?.error?.({ err, gameId: game.id }, 'bot driver error');
}
}
Add the import at the top of ws.ts:
import { getBotDriver } from './games.js';
(There's no fastify in scope inside ws.ts — drop the log call or use console.error. Use console.error({ err, gameId: game.id }, 'bot driver error') for now; structured logging cleanup is out of scope.)
The signatures of onCommit, onResign, onOfferDraw, onRespondDraw, onHello need to become async (or void returning a Promise). The existing handlers are synchronous. Easiest path: have the message router (onMessage) void-fire them but ensure each handler awaits internally.
Replace each handler signature function onCommit(ctx, msg): void → async function onCommit(ctx, msg): Promise<void>. The router doesn't need to change — voiding a Promise is fine because the WS callbacks don't care about completion.
- Step 7.3: Run all tests
Run: pnpm -r test
Expected: all existing 43 tests still pass (the broadcast-since-last refactor is internal; per-message protocol unchanged). New driver/casual-brain/candidates tests still pass.
- Step 7.4: Commit
git add packages/server/src/state.ts \
packages/server/src/games.ts \
packages/server/src/ws.ts
git commit -m "feat(bot): ws.ts pokes BotDriver after state-mutating handlers"
Task 8: Integration test — Casual vs scripted human
Files:
- Create:
packages/server/test/integration/ai-game-casual.test.ts
The harness is the same pattern as scripted-game.test.ts (real Fastify, ephemeral port, real ws clients), but only one human client connects — the other side is the bot.
- Step 8.1: Write the integration test
Create packages/server/test/integration/ai-game-casual.test.ts:
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebSocket } from 'ws';
import Fastify from 'fastify';
import websocketPlugin from '@fastify/websocket';
import {
activeGameCount, chooseSide, createGame, attachBotDriver, getBotDriver,
} from '../../src/games.js';
import { attachSocket } from '../../src/ws.js';
import { createGameSchema } from '../../src/validation.js';
import { CasualBrain, BotDriver } from '../../src/bot/index.js';
import type { ServerMessage } from '@blind-chess/shared';
let app: ReturnType<typeof Fastify>;
let baseUrl = '';
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(websocketPlugin);
app.get('/api/health', async () => ({ ok: true, activeGames: activeGameCount() }));
app.post('/api/games', async (req, reply) => {
const parsed = createGameSchema.safeParse(req.body);
if (!parsed.success) { reply.code(400); return { error: 'malformed' }; }
const { mode, side, highlightingEnabled, vsAi } = parsed.data;
if (vsAi && vsAi.brain === 'recon') {
reply.code(503); return { error: 'ai_offline' };
}
const creatorSide = chooseSide(side);
const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled, vsAi });
if (vsAi && game.aiOpponent) {
const brain = new CasualBrain({ seed: 1 });
const driver = new BotDriver({ game, brain, color: game.aiOpponent.color });
await driver.init();
attachBotDriver(game.id, driver);
}
return { gameId: game.id, creatorToken, creatorColor: creatorSide };
});
app.get('/ws', { websocket: true }, (socket) => {
const raw = (socket as unknown as { socket?: unknown }).socket ?? socket;
attachSocket(raw as never);
});
await app.listen({ port: 0, host: '127.0.0.1' });
const addr = app.server.address();
if (typeof addr !== 'object' || !addr) throw new Error('no address');
baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterAll(async () => { await app.close(); });
interface Client {
ws: WebSocket;
msgs: ServerMessage[];
waitFor: (pred: (m: ServerMessage) => boolean, timeoutMs?: number) => Promise<ServerMessage>;
send: (m: unknown) => void;
close: () => void;
}
function makeClient(gameId: string): Promise<Client> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(baseUrl.replace('http', 'ws') + `/ws?game=${gameId}`);
const msgs: ServerMessage[] = [];
const waiters: Array<{ pred: (m: ServerMessage) => boolean;
resolve: (m: ServerMessage) => void;
reject: (e: Error) => void;
timer: NodeJS.Timeout }> = [];
ws.on('message', (data) => {
const m = JSON.parse(data.toString()) as ServerMessage;
msgs.push(m);
for (const w of [...waiters]) {
if (w.pred(m)) {
clearTimeout(w.timer);
waiters.splice(waiters.indexOf(w), 1);
w.resolve(m);
}
}
});
ws.on('open', () => resolve({
ws, msgs,
waitFor: (pred, timeoutMs = 2000) => new Promise<ServerMessage>((res, rej) => {
const existing = msgs.find(pred);
if (existing) return res(existing);
const timer = setTimeout(() => rej(new Error('waitFor timeout')), timeoutMs);
waiters.push({ pred, resolve: res, reject: rej, timer });
}),
send: (m) => ws.send(JSON.stringify(m)),
close: () => ws.close(),
}));
ws.on('error', reject);
});
}
async function createAiGame(side: 'w' | 'b'): Promise<{ gameId: string; creatorToken: string }> {
const res = await fetch(`${baseUrl}/api/games`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode: 'vanilla', side, highlightingEnabled: false, vsAi: { brain: 'casual' } }),
});
return await res.json();
}
describe('AI game / Casual', () => {
it('human as black: bot moves first as white', async () => {
const { gameId, creatorToken } = await createAiGame('b');
const human = await makeClient(gameId);
human.send({ type: 'hello', gameId, token: creatorToken });
const joined = await human.waitFor((m) => m.type === 'joined');
expect(joined.type === 'joined' && joined.you).toBe('b');
if (joined.type === 'joined') expect(joined.aiOpponent?.color).toBe('w');
// Bot's opening move should arrive as an update.
const botMoved = await human.waitFor((m) =>
m.type === 'update' && m.newAnnouncements.some((a) => a.text === 'white_moved'),
);
expect(botMoved.type).toBe('update');
human.close();
});
it('human as white: human moves first, bot replies', async () => {
const { gameId, creatorToken } = await createAiGame('w');
const human = await makeClient(gameId);
human.send({ type: 'hello', gameId, token: creatorToken });
await human.waitFor((m) => m.type === 'joined');
// Human plays e2e4 (arm + commit).
human.send({ type: 'commit', from: 'e2' });
await human.waitFor((m) => m.type === 'update' && m.touchedPiece === 'e2');
human.send({ type: 'commit', from: 'e2', to: 'e4' });
// Bot replies as black.
const botMoved = await human.waitFor((m) =>
m.type === 'update' && m.newAnnouncements.some((a) => a.text === 'black_moved'),
);
expect(botMoved.type).toBe('update');
// After bot reply, it's white's turn again.
if (botMoved.type === 'update') {
expect(botMoved.view.toMove).toBe('w');
}
human.close();
});
it('full short game: scholar\'s mate setup completes without errors', async () => {
// Vanilla. Human plays as white. Run 8 plies; bot must not crash.
const { gameId, creatorToken } = await createAiGame('w');
const human = await makeClient(gameId);
human.send({ type: 'hello', gameId, token: creatorToken });
await human.waitFor((m) => m.type === 'joined');
const playerMoves = [
['e2', 'e4'], ['f1', 'c4'], ['d1', 'h5'], ['h5', 'f7'],
];
for (const [from, to] of playerMoves) {
human.send({ type: 'commit', from });
await human.waitFor((m) => m.type === 'update' && m.touchedPiece === from);
human.send({ type: 'commit', from, to });
// Wait either for game-end or for bot's reply.
await human.waitFor((m) =>
m.type === 'update' &&
(m.gameStatus === 'finished' || m.view.toMove === 'w'),
3000,
);
}
human.close();
});
it('joinUrl is null for AI games', async () => {
const res = await fetch(`${baseUrl}/api/games`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode: 'blind', side: 'w', highlightingEnabled: false, vsAi: { brain: 'casual' } }),
});
const json = await res.json() as { joinUrl: string | null };
// Test harness server doesn't include joinUrl; assert undefined or null.
expect(json.joinUrl ?? null).toBeNull();
});
it('recon brain returns 503 in Phase 1', async () => {
const res = await fetch(`${baseUrl}/api/games`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode: 'blind', side: 'w', highlightingEnabled: false, vsAi: { brain: 'recon' } }),
});
expect(res.status).toBe(503);
});
});
- Step 8.2: Run integration tests
Run: pnpm --filter @blind-chess/server test -- ai-game-casual
Expected: 5 tests pass.
- Step 8.3: Run full suite
Run: pnpm -r test
Expected: 43 + ~26 new = 69 tests pass.
- Step 8.4: Commit
git add packages/server/test/integration/ai-game-casual.test.ts
git commit -m "test(bot): integration test Casual vs scripted human"
Task 9: Client landing two-section layout
Files:
- Modify:
packages/client/src/lib/Landing.svelte
Two sections: "Play with a friend" (the existing flow) and "Play vs Computer" (new: Casual button enabled, Recon button disabled with "coming soon" tooltip). Both sections share mode/side/highlight controls; selections are independent so user can configure differently per side.
- Step 9.1: Refactor Landing.svelte
Edit packages/client/src/lib/Landing.svelte. Full file content:
<script lang="ts">
import type { Mode, Color, CreateGameResponse } from '@blind-chess/shared';
// Friend section state.
let friendMode: Mode = $state('blind');
let friendSide: Color | 'random' = $state('random');
let friendHighlight = $state(false);
let friendCreating = $state(false);
let friendError: string | null = $state(null);
// AI section state (separate so user can configure each independently).
let aiMode: Mode = $state('blind');
let aiSide: Color | 'random' = $state('random');
let aiHighlight = $state(false);
let aiCreating = $state(false);
let aiError: string | null = $state(null);
async function createWithFriend() {
friendCreating = true; friendError = null;
try {
const res = await fetch('/api/games', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
mode: friendMode, side: friendSide, highlightingEnabled: friendHighlight,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: CreateGameResponse & { creatorColor: Color } = await res.json();
localStorage.setItem(`bc:${json.gameId}`, json.creatorToken);
location.hash = `#/g/${json.gameId}`;
} catch (e) {
friendError = e instanceof Error ? e.message : String(e);
} finally {
friendCreating = false;
}
}
async function createVsCasual() {
aiCreating = true; aiError = null;
try {
const res = await fetch('/api/games', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
mode: aiMode, side: aiSide, highlightingEnabled: aiHighlight,
vsAi: { brain: 'casual' },
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: CreateGameResponse & { creatorColor: Color } = await res.json();
localStorage.setItem(`bc:${json.gameId}`, json.creatorToken);
location.hash = `#/g/${json.gameId}`;
} catch (e) {
aiError = e instanceof Error ? e.message : String(e);
} finally {
aiCreating = false;
}
}
</script>
<div class="page">
<div class="hero">
<h1>blind <span class="accent">chess</span></h1>
<p class="tagline">A two-player chess variant where each player sees only their own pieces. The server is the moderator.</p>
</div>
<div class="card">
<h2>Play with a friend</h2>
<p class="card-sub muted">Get a shareable link, send it to someone, play together.</p>
<div class="field">
<span class="lbl">Mode</span>
<div class="opts">
<label class="opt" class:active={friendMode === 'blind'}>
<input type="radio" bind:group={friendMode} value="blind" />
<span class="opt-title">Blind</span>
<span class="opt-sub">Each player sees only their own pieces.</span>
</label>
<label class="opt" class:active={friendMode === 'vanilla'}>
<input type="radio" bind:group={friendMode} value="vanilla" />
<span class="opt-title">Vanilla</span>
<span class="opt-sub">Normal chess. Both players see everything.</span>
</label>
</div>
</div>
<div class="field">
<span class="lbl">You play as</span>
<div class="row">
<label><input type="radio" bind:group={friendSide} value="w" /> White</label>
<label><input type="radio" bind:group={friendSide} value="b" /> Black</label>
<label><input type="radio" bind:group={friendSide} value="random" /> Random</label>
</div>
</div>
<div class="field">
<label class="toggle">
<input type="checkbox" bind:checked={friendHighlight} />
<span>Highlight reachable squares</span>
{#if friendMode === 'blind'}
<span class="hint muted">(geometric only — no opponent info)</span>
{/if}
</label>
</div>
<button class="primary big" disabled={friendCreating} onclick={createWithFriend}>
{friendCreating ? 'Creating…' : 'Create game'}
</button>
{#if friendError}<p class="error">Error: {friendError}</p>{/if}
</div>
<div class="card">
<h2>Play vs computer</h2>
<p class="card-sub muted">Always-available opponent. No link to share — game starts immediately.</p>
<div class="field">
<span class="lbl">Mode</span>
<div class="opts">
<label class="opt" class:active={aiMode === 'blind'}>
<input type="radio" bind:group={aiMode} value="blind" />
<span class="opt-title">Blind</span>
<span class="opt-sub">Each player sees only their own pieces.</span>
</label>
<label class="opt" class:active={aiMode === 'vanilla'}>
<input type="radio" bind:group={aiMode} value="vanilla" />
<span class="opt-title">Vanilla</span>
<span class="opt-sub">Normal chess. Both players see everything.</span>
</label>
</div>
</div>
<div class="field">
<span class="lbl">You play as</span>
<div class="row">
<label><input type="radio" bind:group={aiSide} value="w" /> White</label>
<label><input type="radio" bind:group={aiSide} value="b" /> Black</label>
<label><input type="radio" bind:group={aiSide} value="random" /> Random</label>
</div>
</div>
<div class="field">
<label class="toggle">
<input type="checkbox" bind:checked={aiHighlight} />
<span>Highlight reachable squares</span>
{#if aiMode === 'blind'}
<span class="hint muted">(geometric only — no opponent info)</span>
{/if}
</label>
</div>
<div class="ai-buttons">
<button class="primary" disabled={aiCreating} onclick={createVsCasual}>
{aiCreating ? 'Creating…' : 'Casual bot'}
</button>
<button class="secondary" disabled title="Coming soon">
gemma4 recon (coming soon)
</button>
</div>
<p class="card-sub muted small">
Casual: fast, plays simple moves, makes mistakes. Good for a quick game.
</p>
{#if aiError}<p class="error">Error: {aiError}</p>{/if}
</div>
<footer class="muted">
<span class="mono">git.sethpc.xyz/Seth/blind_chess</span>
</footer>
</div>
<style>
.page {
max-width: 540px;
margin: 0 auto;
padding: 32px 20px 80px;
min-height: 100%;
}
.hero { text-align: center; margin-bottom: 32px; }
h1 {
font-size: 48px;
font-weight: 800;
letter-spacing: -0.02em;
margin: 0 0 12px;
}
.accent { color: var(--accent); }
.tagline {
color: var(--text-dim);
font-size: 15px;
line-height: 1.5;
max-width: 420px;
margin: 0 auto;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 22px;
margin-bottom: 20px;
}
.card-sub { font-size: 13px; margin: -10px 0 16px; }
.card-sub.small { margin-top: 12px; font-size: 12px; }
h2 { font-size: 18px; margin: 0 0 8px; }
.field { margin-bottom: 20px; }
.lbl {
display: block;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
margin-bottom: 8px;
}
.opts { display: grid; gap: 8px; }
.opt {
display: flex;
flex-direction: column;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: border 0.15s, background 0.15s;
}
.opt:hover { border-color: var(--accent-dim); }
.opt.active { border-color: var(--accent); background: rgba(211,84,0,0.07); }
.opt input { display: none; }
.opt-title { font-weight: 600; }
.opt-sub { color: var(--text-dim); font-size: 13px; margin-top: 2px; }
.row { display: flex; gap: 16px; flex-wrap: wrap; }
.row label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
.toggle { display: flex; align-items: center; gap: 8px; cursor: pointer; flex-wrap: wrap; }
.hint { font-size: 13px; }
button.big {
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 600;
margin-top: 8px;
}
.ai-buttons { display: grid; gap: 8px; grid-template-columns: 1fr 1fr; }
@media (max-width: 480px) { .ai-buttons { grid-template-columns: 1fr; } }
.secondary {
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
}
.secondary:disabled { cursor: not-allowed; opacity: 0.6; }
.error { color: #f87171; margin-top: 12px; }
footer { text-align: center; margin-top: 24px; font-size: 12px; }
</style>
- Step 9.2: Build and visually verify
Run: pnpm --filter @blind-chess/client build
Expected: build succeeds.
- Step 9.3: Run dev server and check landing page
Run: pnpm --filter @blind-chess/client dev (and in another shell pnpm --filter @blind-chess/server dev).
Visit http://localhost:5173 (or whatever Vite reports).
Expected: two cards visible, "Casual bot" button creates an AI game, navigates to game URL, bot plays first if user picked black. The "gemma4 recon (coming soon)" button is disabled.
If UI works, kill the dev servers.
- Step 9.4: Commit
git add packages/client/src/lib/Landing.svelte
git commit -m "feat(client): two-section landing — friend vs Casual bot"
Task 10: Client AI badge + thinking indicator
Files:
- Modify:
packages/client/src/lib/Game.svelte - Modify:
packages/client/src/lib/stores/game.svelte.ts
The store needs to track aiOpponent from server messages. The Game component needs to show:
-
"Casual bot" badge under the opponent's slot.
-
"Casual bot is moving..." indicator when
view.toMove === aiOpponent.colorand game is active. -
Step 10.1: Read
Game.svelteto find opponent indicator location
Run: wc -l packages/client/src/lib/Game.svelte
Read the file. Identify where the opponent indicator / status lives. The exact placement of the badge is a small UX call; aim for: under the opponent's name/status row (top of the board on mobile).
- Step 10.2: Update store to track
aiOpponent
Edit packages/client/src/lib/stores/game.svelte.ts. In the GameStateValue interface add:
aiOpponent: { color: Color; brain: 'casual' | 'recon' } | null;
In the initial state add: aiOpponent: null,.
In onServerMessage for both 'joined' and 'update' cases, propagate:
if ('aiOpponent' in m && m.aiOpponent) state.aiOpponent = m.aiOpponent;
(Add to both case 'joined': and case 'update': blocks.)
- Step 10.3: Surface in
Game.svelte
Edit packages/client/src/lib/Game.svelte to add the AI badge and thinking indicator. Find the existing opponent-status block (likely near the top of the board, alongside opponentConnected) and add adjacent:
{#if game.state.aiOpponent}
<div class="ai-badge" class:thinking={isBotTurn()}>
{#if game.state.aiOpponent.brain === 'casual'}
<span class="badge-name">Casual bot</span>
{:else}
<span class="badge-name">gemma4 recon</span>
{/if}
{#if isBotTurn()}
<span class="thinking-text">moving<span class="dots">…</span></span>
{/if}
</div>
{/if}
with the helper function in the script:
function isBotTurn(): boolean {
const ai = game.state.aiOpponent;
if (!ai) return false;
if (game.state.gameStatus !== 'active') return false;
return game.state.view?.toMove === ai.color;
}
Add styles within the existing <style> block:
.ai-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px;
background: var(--panel);
color: var(--text-dim);
}
.ai-badge.thinking { color: var(--accent); border-color: var(--accent-dim); }
.badge-name { font-weight: 600; }
.thinking-text { font-style: italic; }
.dots { animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse {
0%, 80%, 100% { opacity: 0.3; }
40% { opacity: 1; }
}
- Step 10.4: Build and visually verify
Run: pnpm --filter @blind-chess/client build
Expected: succeeds.
Run dev servers; create an AI game; observe the badge + thinking indicator switches when it's the bot's turn.
- Step 10.5: Commit
git add packages/client/src/lib/Game.svelte \
packages/client/src/lib/stores/game.svelte.ts
git commit -m "feat(client): AI badge and thinking indicator"
Task 11: Self-play harness
Files:
- Create:
scripts/selfplay.ts - Modify:
package.json(root)
CLI tool that runs N Casual-vs-Casual games in-process (no HTTP, no WS) and prints summary stats: wins/losses/draws by color, average move count, average per-move time, top reasons for game end.
- Step 11.1: Create the script
Create scripts/selfplay.ts:
#!/usr/bin/env tsx
/**
* Self-play harness for the Casual bot.
*
* Runs N games of Casual vs Casual in-process (no HTTP). Reports stats and
* (optionally) writes a transcript per game to tmp/selfplay-runs/<ts>/.
*
* Usage:
* pnpm selfplay --white casual --black casual --games 100
* pnpm selfplay --games 50 --mode blind
* pnpm selfplay --games 10 --mode vanilla --seed 42 --transcripts
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { Chess } from 'chess.js';
import { CasualBrain, BotDriver } from '../packages/server/src/bot/index.js';
import { createGame } from '../packages/server/src/games.js';
import type { Game } from '../packages/server/src/state.js';
interface Args {
white: 'casual';
black: 'casual';
games: number;
mode: 'blind' | 'vanilla';
seed: number;
transcripts: boolean;
maxPly: number;
}
function parseArgs(): Args {
const args: Args = {
white: 'casual', black: 'casual',
games: 10, mode: 'blind', seed: 1, transcripts: false, maxPly: 400,
};
const a = process.argv.slice(2);
for (let i = 0; i < a.length; i++) {
const k = a[i]!;
const v = a[i + 1]!;
if (k === '--white') { args.white = v as 'casual'; i++; }
else if (k === '--black') { args.black = v as 'casual'; i++; }
else if (k === '--games') { args.games = parseInt(v, 10); i++; }
else if (k === '--mode') { args.mode = v as 'blind' | 'vanilla'; i++; }
else if (k === '--seed') { args.seed = parseInt(v, 10); i++; }
else if (k === '--max-ply') { args.maxPly = parseInt(v, 10); i++; }
else if (k === '--transcripts') { args.transcripts = true; }
else if (k === '--help' || k === '-h') {
console.log('Usage: pnpm selfplay [--games N] [--mode blind|vanilla] [--seed N] [--transcripts]');
process.exit(0);
}
}
return args;
}
async function runOneGame(args: Args, gameIdx: number): Promise<{
result: 'w' | 'b' | 'draw' | 'maxply' | 'error';
endReason: string;
ply: number;
ms: number;
transcript: string[];
}> {
const startMs = Date.now();
const transcript: string[] = [];
const { game } = createGame({
mode: args.mode, creatorSide: 'w', highlightingEnabled: false,
vsAi: { brain: 'casual' },
});
// createGame already filled both slots when vsAi is set. We just need to
// flip the game out of `waiting` (no hello will arrive in self-play) and
// clear the aiOpponent tag so neither side is treated as "the human's bot".
game.aiOpponent = undefined;
game.status = 'active';
const wBrain = new CasualBrain({ seed: args.seed + gameIdx * 2 });
const bBrain = new CasualBrain({ seed: args.seed + gameIdx * 2 + 1 });
const wDriver = new BotDriver({ game, brain: wBrain, color: 'w' });
const bDriver = new BotDriver({ game, brain: bBrain, color: 'b' });
await wDriver.init();
await bDriver.init();
let ply = 0;
while (game.status === 'active' && ply < args.maxPly) {
const turn = game.chess.turn() as 'w' | 'b';
const driver = turn === 'w' ? wDriver : bDriver;
try {
await driver.onStateChange();
} catch (err) {
transcript.push(`!! error at ply ${ply}: ${(err as Error).message}`);
return { result: 'error', endReason: (err as Error).message, ply, ms: Date.now() - startMs, transcript };
}
const newPly = game.chess.history().length;
if (newPly === ply && game.status === 'active') {
// Driver didn't move and game didn't end — defensive break.
transcript.push(`!! stuck at ply ${ply} (${turn} to move)`);
return { result: 'error', endReason: 'stuck', ply, ms: Date.now() - startMs, transcript };
}
if (newPly > ply) {
const lastSan = game.chess.history()[newPly - 1];
transcript.push(`${newPly}. ${turn === 'w' ? 'W' : 'B'}: ${lastSan}`);
}
ply = newPly;
}
const ms = Date.now() - startMs;
if (game.status !== 'finished') {
return { result: 'maxply', endReason: 'max_ply', ply, ms, transcript };
}
const result: 'w' | 'b' | 'draw' = game.winner ?? 'draw';
return { result, endReason: game.endReason ?? 'unknown', ply, ms, transcript };
}
async function main(): Promise<void> {
const args = parseArgs();
console.log(`selfplay: ${args.games} game(s), mode=${args.mode}, seed=${args.seed}`);
const results: Awaited<ReturnType<typeof runOneGame>>[] = [];
let outDir: string | null = null;
if (args.transcripts) {
outDir = resolve('tmp', 'selfplay-runs', String(Date.now()));
mkdirSync(outDir, { recursive: true });
console.log(`transcripts -> ${outDir}`);
}
for (let i = 0; i < args.games; i++) {
const r = await runOneGame(args, i);
results.push(r);
if (outDir) {
writeFileSync(
resolve(outDir, `game-${String(i + 1).padStart(4, '0')}.txt`),
`result=${r.result} reason=${r.endReason} ply=${r.ply} ms=${r.ms}\n${r.transcript.join('\n')}\n`,
);
}
if ((i + 1) % 10 === 0 || i === args.games - 1) {
const summary = summarize(results);
console.log(`[${i + 1}/${args.games}] ${summary}`);
}
}
// Final summary.
console.log('\n=== summary ===');
console.log(summarize(results));
const reasons = new Map<string, number>();
for (const r of results) reasons.set(r.endReason, (reasons.get(r.endReason) ?? 0) + 1);
console.log('end reasons:');
for (const [k, v] of [...reasons.entries()].sort((a, b) => b[1] - a[1])) {
console.log(` ${k}: ${v}`);
}
console.log('errors: ' + results.filter((r) => r.result === 'error').length);
}
function summarize(rs: { result: 'w' | 'b' | 'draw' | 'maxply' | 'error'; ply: number; ms: number }[]): string {
const w = rs.filter((r) => r.result === 'w').length;
const b = rs.filter((r) => r.result === 'b').length;
const d = rs.filter((r) => r.result === 'draw').length;
const mp = rs.filter((r) => r.result === 'maxply').length;
const er = rs.filter((r) => r.result === 'error').length;
const avgPly = rs.reduce((s, r) => s + r.ply, 0) / Math.max(rs.length, 1);
const avgMs = rs.reduce((s, r) => s + r.ms, 0) / Math.max(rs.length, 1);
return `W=${w} B=${b} D=${d} MaxPly=${mp} Err=${er} avgPly=${avgPly.toFixed(0)} avgMs=${avgMs.toFixed(0)}`;
}
main().catch((err) => { console.error(err); process.exit(1); });
- Step 11.2: Add npm script
Edit package.json (root). In scripts:
"selfplay": "tsx scripts/selfplay.ts"
Add tsx to root devDependencies if not already present (it's in packages/server/devDependencies; root may also need it, OR run via pnpm --filter @blind-chess/server tsx ../../../scripts/selfplay.ts):
pnpm add -Dw tsx
- Step 11.3: Run a 100-game self-play
Run: pnpm selfplay --games 100 --mode vanilla --seed 1
Expected:
- All 100 games complete (no
Errcount). - Median ply count between 20 and 200.
- W + B + D + MaxPly = 100.
- Per-move time on the order of milliseconds.
Run: pnpm selfplay --games 100 --mode blind --seed 1
Expected:
-
All 100 complete.
-
Blind games may have higher MaxPly count (Casual is rejection-noisy in blind mode); that's OK as long as Err=0.
-
Step 11.4: Run 100 vs random-mover baseline
For the Phase 1 acceptance bar "Casual reliably beats a random-mover ≥80%", we need a RandomBrain. Add a small one to the harness file (NOT to the production bot module):
In scripts/selfplay.ts, just below the imports, add an inline helper:
import type { Brain, BrainAction, BrainInitArgs, BrainInput } from '../packages/server/src/bot/brain.js';
class RandomBrain implements Brain {
private rng: () => number;
constructor(seed: number) {
let a = seed >>> 0;
this.rng = () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
async init(_a: BrainInitArgs): Promise<void> {}
async decide(input: BrainInput): Promise<BrainAction> {
const cs = input.legalCandidates;
if (cs.length === 0) throw new Error('no candidates');
const i = Math.floor(this.rng() * cs.length);
const c = cs[i]!;
return { type: 'commit', from: c.from, to: c.to, promotion: c.promotion };
}
}
Extend Args.white / Args.black to accept 'random':
white: 'casual' | 'random';
black: 'casual' | 'random';
In runOneGame, pick brain by name:
function makeBrain(kind: 'casual' | 'random', seed: number): Brain {
return kind === 'casual' ? new CasualBrain({ seed }) : new RandomBrain(seed);
}
// ...
const wBrain = makeBrain(args.white, args.seed + gameIdx * 2);
const bBrain = makeBrain(args.black, args.seed + gameIdx * 2 + 1);
Run:
pnpm selfplay --white casual --black random --games 100 --mode vanilla
pnpm selfplay --white random --black casual --games 100 --mode vanilla
Expected: Casual wins ≥80% in each color.
If under 80%: tune casual-brain.ts scoring (likely the rank-advance weight or capture bonus). Re-run.
- Step 11.5: Commit
git add scripts/selfplay.ts package.json pnpm-lock.yaml
git commit -m "feat(bot): self-play harness with Casual and random baselines"
Task 12: Build, full test suite, deploy
Files:
-
None modified by this task; deploy artifacts go to CT 690.
-
Step 12.1: Full clean build
Run: pnpm -r build
Expected: clean build of all three packages.
- Step 12.2: Full test run
Run: pnpm -r test
Expected: all tests pass (43 pre-existing + new shared tests if any + ~26 new server tests = ~69 total).
- Step 12.3: Typecheck
Run: pnpm -r typecheck
Expected: no errors.
- Step 12.4: Deploy bundle
Use the same flow as the MVP deploy (per CLAUDE.md "Operations" section).
# Build server-only deploy bundle:
pnpm --filter @blind-chess/server deploy --prod --legacy .deploy-server
# Rsync to CT 690.
rsync -avz --delete .deploy-server/ root@192.168.0.245:/opt/blind-chess/server/
rsync -avz --delete packages/client/dist/ root@192.168.0.245:/opt/blind-chess/client/dist/
ssh root@192.168.0.245 'chown -R blindchess:blindchess /opt/blind-chess && systemctl restart blind-chess'
- Step 12.5: Health check
Run: curl https://chess.sethpc.xyz/api/health
Expected: {"ok":true,"activeGames":0,"uptime":<small>}
- Step 12.6: Live smoke checklist (Casual)
Manually:
-
Open https://chess.sethpc.xyz on a phone. See two cards (friend / vs computer).
-
Click "Casual bot" with
mode=blind, side=white. Game opens with you as white. -
See "Casual bot" badge under the opponent's slot.
-
Make a move (e2-e4). Bot replies within 1 second; "moving..." indicator appears briefly.
-
Play to completion (resign or 30+ plies). No errors in console; game ends cleanly.
-
Repeat with
mode=vanilla, side=black— bot moves first. -
Check journald:
ssh root@192.168.0.245 'journalctl -u blind-chess --since "5 min ago" | grep -i error'— expect no errors. -
Step 12.7: Commit any lockfile changes that surfaced from deploy build
git status
# If pnpm-lock.yaml changed from `pnpm deploy`, commit:
git add pnpm-lock.yaml
git commit -m "chore: lockfile after deploy build" || echo "no lockfile changes"
Task 13: Update DECISIONS.md, CLAUDE.md, write handoff
Files:
-
Modify:
DECISIONS.md -
Modify:
CLAUDE.md -
Create:
.claude/handoffs/YYYY-MM-DD-HHMMSS-ai-phase-1-shipped.md -
Step 13.1: Append Phase 1 outcome to DECISIONS.md
In the "AI / computer player" section, append:
- 2026-MM-DD: **Phase 1 (Casual bot) shipped to https://chess.sethpc.xyz.** Outcome metrics from self-play: <X>% Casual-vs-random win rate (target ≥80%), <Y> avg ply per game, <Z>ms avg per-move latency, 0 crashes over 100 games. Implementation surface: ~<N> LoC in `packages/server/src/bot/`, ~<M> LoC in test, no new runtime dependencies. Phase 2 (Recon) has its own plan and starts when Phase 1 has soaked for at least a few days of real play.
(Fill in real numbers.)
- Step 13.2: Update CLAUDE.md
In "Current State", change:
AI player (designed, not built): ...
to:
AI player Phase 1 (Casual) deployed. Live at https://chess.sethpc.xyz "Play vs computer" → Casual bot. Phase 2 (gemma4 recon) has its own plan; not yet started.
In "Key files", add:
packages/server/src/bot/— bot module:Braininterface,BotDriver,CasualBrain, candidates.
- Step 13.3: Write handoff
Use /session-handoff skill or write .claude/handoffs/YYYY-MM-DD-HHMMSS-ai-phase-1-shipped.md with:
-
Session metadata (branch, commits)
-
Phase 1 outcome stats
-
Files added / modified
-
Next steps: write Phase 2 plan
-
Anything surprising encountered during implementation (a Future Self should know)
-
Step 13.4: Commit and merge
git add DECISIONS.md CLAUDE.md .claude/handoffs/
git commit -m "docs: AI Phase 1 shipped — context, decisions, handoff"
git push -u origin feat/ai-player-phase-1-casual
# Merge to main once smoke passes:
git checkout main
git merge --ff-only feat/ai-player-phase-1-casual || git merge --no-ff feat/ai-player-phase-1-casual
git push origin main
Acceptance gate
Phase 1 is done when:
- All unit + integration tests pass (
pnpm -r test). pnpm selfplay --games 100 --mode vanillaand--mode blindcomplete withErr=0.pnpm selfplay --white casual --black random --games 100 --mode vanillashows ≥80% Casual win rate (and the same with colors swapped).- Median game length 20–200 plies in self-play.
- Live smoke checklist for Casual passes on https://chess.sethpc.xyz.
- No errors in journald for the test sessions.
- DECISIONS.md, CLAUDE.md, and handoff document updated.
- Branch merged to
mainand pushed.
If any of these fail, fix in branch before merging. Don't ship a broken Phase 1 — Phase 2 will inherit the same infrastructure.