Chess
Claude haiku 5.5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · 5abd3590fa
# Task: Build a Chess Game with AI Opponent
Build fully rule-complete chess against a computer opponent (vanilla JS, no external assets, no CDN, no libraries). The top priority is **rules correctness** — every legal move must be accepted, every illegal move rejected, including all the edge cases.
## Core Specification
**Rules Engine (the heart of the app — no shortcuts)**
- Standard 8×8 board, correct initial position, White moves first
- Full move generation for all pieces, including **all special rules**:
- Castling (both sides), forbidden while in check, through check, or after king/rook has moved
- En passant — only on the immediately following move
- Pawn promotion (to Q/R/B/N, via an under-promotion picker UI)
- A move that leaves your own king in check is illegal and must be rejected
- Check, checkmate, and stalemate detection; also draw by insufficient material (K vs K, K+B vs K, K+N vs K) and 50-move rule
- Move validation is **generator-based**: the UI only offers moves produced by the legal-move generator, so illegal states are unreachable
**Interaction**
- Click-to-move (select piece → legal destination squares highlighted with dots; captures ringed) **and** drag-and-drop — both must work
- Last-move highlight; check shown on the king's square (red glow)
- Flip-board button; captured-pieces trays; move list in algebraic notation (e.g. `Nf3`, `O-O`, `exd6 e.p.`, `Qxd8+`, `#` on mate), auto-scrolling
- Undo (takes back both the player and AI move) and New Game
- Sound effects via Web Audio (move, capture, check) with a mute toggle
**AI Opponent**
- Minimax with alpha-beta pruning, iterative-friendly fixed depth chosen for responsiveness (target ≤ ~1s per move), plus quiescence-lite or move ordering (captures first) to avoid tactical blindness
- Evaluation: material + piece-square tables at minimum
- Player chooses White or Black (and optionally plays both sides / analysis mode); the AI must never make an illegal move or freeze the UI — run the search chunked or in small time slices if needed
- Difficulty selector (depth 1 / 2 / 3)
**Persistence**
- Game state (board, turn, castling rights, en-passant square, move list) serializes to `localStorage` and restores on reload mid-game
## Visual Style
- Elegant, classic chess aesthetic: warm wood or slate board palette, crisp Unicode-piece glyphs (♞♟) or clean inline-SVG pieces, subtle square shading
- Board centered; side panel with move list, captured pieces, controls
- Promotion picker as a small overlay on the promotion square
- Clear turn indicator and game-over banner (checkmate/stalemate/draw, with the result stated)
## Technical Requirements
- Vanilla JS, with clear section comments
- Board representation and move generation cleanly separated from rendering (0x88, mailbox, or bitboards — your choice, but make it readable)
- Tunable constants block at the top (colors, AI depths, eval weights)
- Expose a minimal debug interface on `window` (e.g. `game.fen()`, `game.move('e2e4')`, `game.legalMoves()`) so the engine can be probed headlessly
## Quality Bar (must hit all)
- Castling, en passant, and promotion all work from the UI
- You cannot move into check, and pinned pieces cannot abandon their pin
- Checkmate and stalemate are correctly announced (Fool's mate and a simple back-rank mate both detected)
- The AI at depth 3 does not hang pieces trivially and never returns an illegal move
- A mid-game reload restores the exact position, side to move, castling rights, and en-passant state
## Self-Check Before Finishing
Verify with the browser tools, using the debug interface:
- Fool's mate: `f3 e5 g4 Qh4#` → checkmate announced, no further moves accepted
- Set up an en-passant opportunity and confirm the capture is generated exactly once (only on the immediately following move)
- Attempt a move that exposes the king → rejected by `legalMoves()`
- Play ~10 moves against the AI, reload, confirm identical state
- Position count sanity check: from the start position, `legalMoves()` returns 20
Inspect original source 45,737 bytes · SHA-256 59452f9ff207
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chess vs Computer</title>
<style>
:root{
--light:#ecdab4; --dark:#9d6b43; --frame:#3b2616;
--hl-last:rgba(255,226,90,.50); --hl-sel:rgba(90,170,255,.60);
--dot:rgba(25,25,25,.38); --ring:rgba(25,25,25,.42); --check:#ff3b30;
--panel:#27211d; --panel2:#332b25; --text:#f3e9d6; --muted:#b9a88c; --accent:#e0a94a;
--wp:#fffbf2; --bp:#1b1410; --bs:min(82vh,580px);
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:14px;
background:radial-gradient(ellipse at center,#4a3a2e 0%,#1d1612 75%);color:var(--text);
font-family:Georgia,"Palatino Linotype",Palatino,serif}
.app{display:flex;gap:18px;align-items:flex-start;flex-wrap:wrap;justify-content:center}
.boardArea{position:relative;width:var(--bs);height:var(--bs);padding:0;background:var(--frame);
border-radius:6px;box-shadow:0 12px 40px rgba(0,0,0,.6),inset 0 0 0 2px #5a3b22}
.board{position:absolute;inset:10px;display:grid;grid-template-columns:repeat(8,1fr);grid-template-rows:repeat(8,1fr);
user-select:none;touch-action:none;border-radius:2px;overflow:hidden}
.sq{position:relative;display:flex;align-items:center;justify-content:center;cursor:default}
.sq.light{background:var(--light)} .sq.dark{background:var(--dark)}
.sq.last{background-image:linear-gradient(var(--hl-last),var(--hl-last))}
.sq.sel{background-image:linear-gradient(var(--hl-sel),var(--hl-sel))}
.sq.check{background:radial-gradient(circle,#ff5a4a 0%,#c4150c 45%,transparent 75%)!important}
.sq .coord{position:absolute;font-size:calc(var(--bs)*.024);font-weight:bold;opacity:.7;pointer-events:none;font-family:Arial,sans-serif}
.sq .coord.f{right:3px;bottom:1px} .sq .coord.r{left:3px;top:2px}
.sq.light .coord{color:var(--dark)} .sq.dark .coord{color:var(--light)}
.sq.dot::after{content:"";position:absolute;width:28%;height:28%;border-radius:50%;background:var(--dot);pointer-events:none}
.sq.ring::after{content:"";position:absolute;inset:6%;border-radius:50%;border:calc(var(--bs)*.012) solid var(--ring);pointer-events:none;box-sizing:border-box}
.pc{font-size:calc(var(--bs)*.088);line-height:1;position:relative;z-index:2;pointer-events:none;font-family:"Segoe UI Symbol","Noto Sans Symbols 2","DejaVu Sans",serif}
.pc.w{color:var(--wp);text-shadow:0 0 2px #000,0 0 2px #000,1px 1px 1px #000,-1px -1px 1px #000}
.pc.b{color:var(--bp);text-shadow:0 0 1px rgba(255,255,255,.35)}
.pc.dragging{opacity:.25}
.ghost{position:fixed;z-index:1000;pointer-events:none;transform:translate(-50%,-50%);font-size:calc(var(--bs)*.1);line-height:1;font-family:"Segoe UI Symbol","Noto Sans Symbols 2","DejaVu Sans",serif;filter:drop-shadow(0 6px 6px rgba(0,0,0,.5))}
.ghost.w{color:var(--wp);text-shadow:0 0 2px #000,1px 1px 1px #000,-1px -1px 1px #000}
.ghost.b{color:var(--bp)}
.promo{position:absolute;z-index:50;display:flex;flex-direction:column;background:var(--panel);border:2px solid var(--accent);border-radius:8px;padding:3px;box-shadow:0 8px 24px rgba(0,0,0,.7)}
.promo button{width:calc(var(--bs)/8);height:calc(var(--bs)/8);border:0;background:var(--light);cursor:pointer;border-radius:4px;margin:1px}
.promo button:hover{background:#fff3cc}
.promo button .pc{pointer-events:auto}
.promo button.b-ish{background:var(--dark)}
.promo-cancel{font-size:11px;color:var(--muted);background:none;border:0;cursor:pointer;padding:2px;font-family:Arial,sans-serif}
.banner{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:60;background:rgba(20,15,12,.92);border:2px solid var(--accent);
padding:18px 26px;border-radius:10px;text-align:center;font-size:20px;display:none;box-shadow:0 10px 30px rgba(0,0,0,.7);max-width:80%}
.banner.show{display:block}
.banner small{display:block;font-size:14px;color:var(--muted);margin-top:6px}
.panel{width:310px;max-width:100%;background:linear-gradient(#2e2722,#221c18);border:1px solid #4b3a2c;border-radius:10px;padding:14px;
display:flex;flex-direction:column;gap:12px;box-shadow:0 10px 30px rgba(0,0,0,.45);height:var(--bs);min-height:420px}
.title{font-size:20px;letter-spacing:.5px;color:var(--accent);margin:0}
.status{font-size:15px;padding:8px 10px;background:var(--panel2);border-radius:6px;display:flex;align-items:center;gap:8px;min-height:38px}
.dot-ind{width:14px;height:14px;border-radius:50%;border:1px solid #888;display:inline-block;flex:none}
.dot-ind.w{background:#fff}.dot-ind.b{background:#111}
.thinking{font-style:italic;color:var(--muted)}
.trays{display:flex;flex-direction:column;gap:4px;font-size:18px;min-height:28px}
.tray{display:flex;align-items:center;gap:2px;flex-wrap:wrap;min-height:24px}
.tray .lbl{font-size:12px;color:var(--muted);font-family:Arial,sans-serif;margin-right:6px;width:70px}
.tray .gl{line-height:1}
.tray .adv{font-size:12px;color:var(--accent);margin-left:6px;font-family:Arial,sans-serif}
.moves{flex:1;min-height:120px;overflow-y:auto;background:var(--panel2);border-radius:6px;padding:6px 8px;font-family:Consolas,"Courier New",monospace;font-size:14px}
.moves table{width:100%;border-collapse:collapse}
.moves td{padding:2px 4px}
.moves td.n{color:var(--muted);width:34px}
.moves td.m{cursor:default}
.moves tr.cur td.m{background:rgba(224,169,74,.25);border-radius:3px}
.controls{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.controls button,.controls select{background:#3f342b;color:var(--text);border:1px solid #5a4835;border-radius:6px;padding:7px 8px;font-size:14px;cursor:pointer;font-family:inherit}
.controls button:hover:not(:disabled),.controls select:hover{background:#524437}
.controls button:disabled{opacity:.4;cursor:not-allowed}
.controls label{font-size:13px;color:var(--muted);display:flex;flex-direction:column;gap:3px;font-family:Arial,sans-serif}
.controls .full{grid-column:1/-1}
.foot{font-size:12px;color:var(--muted);font-family:Arial,sans-serif;line-height:1.4}
</style>
</head>
<body>
<div class="app">
<div class="boardArea" id="boardArea">
<div class="board" id="board"></div>
<div class="banner" id="banner"></div>
<div class="promo" id="promo" style="display:none"></div>
</div>
<div class="panel">
<h1 class="title">Chess vs Computer</h1>
<div class="status" id="status"><span class="dot-ind w" id="turnDot"></span><span id="statusText">White to move</span></div>
<div class="trays">
<div class="tray" id="trayBlack"></div>
<div class="tray" id="trayWhite"></div>
</div>
<div class="moves" id="moves"><table id="movesTable"></table></div>
<div class="controls">
<button id="btnNew">New game</button>
<button id="btnUndo">Undo</button>
<button id="btnFlip">Flip board</button>
<button id="btnMute">Sound: on</button>
<label>Play as
<select id="selMode">
<option value="white">White (vs AI)</option>
<option value="black">Black (vs AI)</option>
<option value="both">Two players / analysis</option>
</select>
</label>
<label>AI depth
<select id="selDepth">
<option value="1">1 - Easy</option>
<option value="2">2 - Medium</option>
<option value="3">3 - Hard</option>
</select>
</label>
</div>
<div class="foot">Click or drag pieces. Legal destinations show as dots; captures are ringed. Game state autosaves to this browser and restores on reload.</div>
</div>
</div>
<script>
'use strict';
/* ==========================================================================
CONFIG - tunable constants
========================================================================== */
const CONFIG = {
colors: { light: '#ecdab4', dark: '#9d6b43', frame: '#3b2616', lastMove: 'rgba(255,226,90,.50)', selected: 'rgba(90,170,255,.60)', check: '#ff3b30' },
ai: {
depthByLevel: { 1: 1, 2: 2, 3: 3 }, // difficulty -> fixed max search depth (plies)
timeLimitMs: 1500, // soft budget: stop deepening if exceeded
aiDelayMs: 220, // delay before AI starts (lets UI paint)
qsMaxPly: 6, // quiescence safety limit
randomness: 0, // 0 = deterministic-ish (random tie-break only)
},
eval: {
material: { p: 100, n: 320, b: 330, r: 500, q: 900, k: 0 },
pstWeight: 1.0, // scale for piece-square tables
mateScore: 100000,
},
storageKey: 'chess-vs-computer-v1',
rules: { fiftyMoveHalfmoves: 100, threefoldCount: 3 },
};
/* ==========================================================================
CORE CONSTANTS
Board: mailbox of 64 squares, sq = rank*8 + file, rank 0 = rank "1", file 0 = "a".
Piece chars: uppercase = White, lowercase = Black, '' = empty.
========================================================================== */
const FILES = 'abcdefgh';
const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
const KNIGHT_OFF = [[1,2],[2,1],[-1,2],[-2,1],[1,-2],[2,-1],[-1,-2],[-2,-1]];
const KING_OFF = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
const DIAG_DIRS = [[1,1],[1,-1],[-1,1],[-1,-1]];
const ORTH_DIRS = [[1,0],[-1,0],[0,1],[0,-1]];
const sqOf = (r, f) => r * 8 + f;
const rankOf = sq => sq >> 3;
const fileOf = sq => sq & 7;
const sqName = sq => FILES[fileOf(sq)] + (rankOf(sq) + 1);
const nameToSq = s => (FILES.indexOf(s[0])) + (parseInt(s[1], 10) - 1) * 8;
const colorOf = p => (p === '' ? null : (p === p.toUpperCase() ? 'w' : 'b'));
const typeOf = p => p.toUpperCase();
const inBoard = (r, f) => r >= 0 && r < 8 && f >= 0 && f < 8;
const oppColor = c => (c === 'w' ? 'b' : 'w');
/* ==========================================================================
PIECE-SQUARE TABLES (White's perspective, index 0 = a8 ... 63 = h1)
Black uses the mirrored index.
========================================================================== */
const PST = {
P: [0,0,0,0,0,0,0,0, 50,50,50,50,50,50,50,50, 10,10,20,30,30,20,10,10, 5,5,10,25,25,10,5,5,
0,0,0,20,20,0,0,0, 5,-5,-10,0,0,-10,-5,5, 5,10,10,-20,-20,10,10,5, 0,0,0,0,0,0,0,0],
N: [-50,-40,-30,-30,-30,-30,-40,-50, -40,-20,0,0,0,0,-20,-40, -30,0,10,15,15,10,0,-30, -30,5,15,20,20,15,5,-30,
-30,0,15,20,20,15,0,-30, -30,5,10,15,15,10,5,-30, -40,-20,0,5,5,0,-20,-40, -50,-40,-30,-30,-30,-30,-40,-50],
B: [-20,-10,-10,-10,-10,-10,-10,-20, -10,0,0,0,0,0,0,-10, -10,0,5,10,10,5,0,-10, -10,5,5,10,10,5,5,-10,
-10,0,10,10,10,10,0,-10, -10,10,10,10,10,10,10,-10, -10,5,0,0,0,0,5,-10, -20,-10,-10,-10,-10,-10,-10,-20],
R: [0,0,0,0,0,0,0,0, 5,10,10,10,10,10,10,5, -5,0,0,0,0,0,0,-5, -5,0,0,0,0,0,0,-5, -5,0,0,0,0,0,0,-5,
-5,0,0,0,0,0,0,-5, -5,0,0,0,0,0,0,-5, 0,0,0,5,5,0,0,0],
Q: [-20,-10,-10,-5,-5,-10,-10,-20, -10,0,0,0,0,0,0,-10, -10,0,5,5,5,5,0,-10, -5,0,5,5,5,5,0,-5,
0,0,5,5,5,5,0,-5, -10,5,5,5,5,5,0,-10, -10,0,5,0,0,0,0,-10, -20,-10,-10,-5,-5,-10,-10,-20],
K: [-30,-40,-40,-50,-50,-40,-40,-30, -30,-40,-40,-50,-50,-40,-40,-30, -30,-40,-40,-50,-50,-40,-40,-30, -30,-40,-40,-50,-50,-40,-40,-30,
-20,-30,-30,-40,-40,-30,-30,-20, -10,-20,-20,-20,-20,-20,-20,-10, 20,20,0,0,0,0,20,20, 20,30,10,0,0,10,30,20],
};
const GLYPH = { w: { K:'\u2654', Q:'\u2655', R:'\u2656', B:'\u2657', N:'\u2658', P:'\u2659' },
b: { K:'\u265A', Q:'\u265B', R:'\u265C', B:'\u265D', N:'\u265E', P:'\u265F' } };
const glyph = p => GLYPH[colorOf(p)][typeOf(p)];
/* ==========================================================================
STATE
state = { board: string[64], turn: 'w'|'b', castle: 'KQkq' subset, ep: sq|-1, half: int, full: int }
========================================================================== */
function parseFen(fen) {
const parts = fen.split(' ');
const board = new Array(64).fill('');
const rows = parts[0].split('/');
for (let i = 0; i < 8; i++) {
let f = 0;
for (const ch of rows[i]) {
if (/\d/.test(ch)) f += parseInt(ch, 10);
else { board[sqOf(7 - i, f)] = ch; f++; }
}
}
return {
board,
turn: parts[1] || 'w',
castle: parts[2] && parts[2] !== '-' ? parts[2] : '',
ep: parts[3] && parts[3] !== '-' ? nameToSq(parts[3]) : -1,
half: parseInt(parts[4] || '0', 10),
full: parseInt(parts[5] || '1', 10),
};
}
function toFen(s) {
const rows = [];
for (let r = 7; r >= 0; r--) {
let row = '', empty = 0;
for (let f = 0; f < 8; f++) {
const p = s.board[sqOf(r, f)];
if (!p) empty++;
else { if (empty) { row += empty; empty = 0; } row += p; }
}
if (empty) row += empty;
rows.push(row);
}
return [rows.join('/'), s.turn, s.castle || '-', s.ep >= 0 ? sqName(s.ep) : '-', s.half, s.full].join(' ');
}
const startState = () => parseFen(START_FEN);
const cloneState = s => ({ board: s.board.slice(), turn: s.turn, castle: s.castle, ep: s.ep, half: s.half, full: s.full });
/* ==========================================================================
ATTACK DETECTION
========================================================================== */
function findKing(board, c) {
const k = c === 'w' ? 'K' : 'k';
for (let i = 0; i < 64; i++) if (board[i] === k) return i;
return -1;
}
// Is square sq attacked by any piece of color byColor?
function isAttacked(board, sq, byColor) {
const r = rankOf(sq), f = fileOf(sq);
const P = byColor === 'w' ? 'P' : 'p', N = byColor === 'w' ? 'N' : 'n', K = byColor === 'w' ? 'K' : 'k';
const B = byColor === 'w' ? 'B' : 'b', R = byColor === 'w' ? 'R' : 'r', Q = byColor === 'w' ? 'Q' : 'q';
// pawns: a white pawn attacks upward, so it sits one rank below the target
const pr = byColor === 'w' ? r - 1 : r + 1;
if (inBoard(pr, f - 1) && board[sqOf(pr, f - 1)] === P) return true;
if (inBoard(pr, f + 1) && board[sqOf(pr, f + 1)] === P) return true;
for (const [dr, df] of KNIGHT_OFF) { const rr = r + dr, ff = f + df; if (inBoard(rr, ff) && board[sqOf(rr, ff)] === N) return true; }
for (const [dr, df] of KING_OFF) { const rr = r + dr, ff = f + df; if (inBoard(rr, ff) && board[sqOf(rr, ff)] === K) return true; }
for (const [dr, df] of ORTH_DIRS) {
let rr = r + dr, ff = f + df;
while (inBoard(rr, ff)) {
const p = board[sqOf(rr, ff)];
if (p) { if (p === R || p === Q) return true; break; }
rr += dr; ff += df;
}
}
for (const [dr, df] of DIAG_DIRS) {
let rr = r + dr, ff = f + df;
while (inBoard(rr, ff)) {
const p = board[sqOf(rr, ff)];
if (p) { if (p === B || p === Q) return true; break; }
rr += dr; ff += df;
}
}
return false;
}
function inCheck(s, c = s.turn) {
const k = findKing(s.board, c);
return k >= 0 && isAttacked(s.board, k, oppColor(c));
}
/* ==========================================================================
MOVE APPLICATION
Move object: { f, t, p (piece char), c (captured char or ''), pr (promo char or ''),
ep (bool), cs (0 none | 1 kingside | 2 queenside), dbl (bool) }
========================================================================== */
function applyMove(s, m) {
const ns = cloneState(s);
const b = ns.board;
const piece = b[m.f];
const color = s.turn;
const type = typeOf(piece);
const captured = m.c || '';
b[m.t] = piece;
b[m.f] = '';
if (m.ep) b[m.t - (color === 'w' ? 8 : -8)] = ''; // remove the passed pawn
if (m.pr) b[m.t] = color === 'w' ? m.pr.toUpperCase() : m.pr.toLowerCase();
if (m.cs === 1) { b[m.t - 1] = b[m.t + 1]; b[m.t + 1] = ''; } // rook h->f
if (m.cs === 2) { b[m.t + 1] = b[m.t - 2]; b[m.t - 2] = ''; } // rook a->d
// castling rights
let cr = ns.castle;
if (type === 'K') cr = color === 'w' ? cr.replace(/[KQ]/g, '') : cr.replace(/[kq]/g, '');
const rookSquares = { 0: 'Q', 7: 'K', 56: 'q', 63: 'k' };
for (const sq of [m.f, m.t]) if (rookSquares[sq]) cr = cr.replace(rookSquares[sq], '');
ns.castle = cr;
ns.ep = m.dbl ? (m.f + m.t) >> 1 : -1;
ns.half = (type === 'P' || captured || m.ep) ? 0 : s.half + 1;
ns.full = color === 'b' ? s.full + 1 : s.full;
ns.turn = oppColor(color);
return ns;
}
/* ==========================================================================
MOVE GENERATION
pseudo-legal generation, then filtered by legality (king not left in check)
========================================================================== */
function pseudoMoves(s) {
const moves = [];
const c = s.turn, b = s.board, opp = oppColor(c);
for (let sq = 0; sq < 64; sq++) {
const p = b[sq];
if (!p || colorOf(p) !== c) continue;
const r = rankOf(sq), f = fileOf(sq), t = typeOf(p);
const add = (to, extra) => {
const cap = b[to];
moves.push(Object.assign({ f: sq, t: to, p, c: cap || '', pr: '', ep: false, cs: 0, dbl: false }, extra || {}));
};
if (t === 'P') {
const dir = c === 'w' ? 1 : -1, startR = c === 'w' ? 1 : 6, promoR = c === 'w' ? 7 : 0;
const r1 = r + dir;
if (inBoard(r1, f) && !b[sqOf(r1, f)]) {
if (r1 === promoR) for (const pr of ['q', 'r', 'b', 'n']) add(sqOf(r1, f), { pr });
else {
add(sqOf(r1, f));
const r2 = r + 2 * dir;
if (r === startR && !b[sqOf(r2, f)]) add(sqOf(r2, f), { dbl: true });
}
}
for (const df of [-1, 1]) {
const ff = f + df, rr = r + dir;
if (!inBoard(rr, ff)) continue;
const to = sqOf(rr, ff);
if (b[to] && colorOf(b[to]) === opp) {
if (rr === promoR) for (const pr of ['q', 'r', 'b', 'n']) add(to, { pr });
else add(to);
} else if (to === s.ep && !b[to]) {
add(to, { ep: true, c: b[sqOf(r, ff)] });
}
}
} else if (t === 'N' || t === 'K') {
const offs = t === 'N' ? KNIGHT_OFF : KING_OFF;
for (const [dr, df] of offs) {
const rr = r + dr, ff = f + df;
if (!inBoard(rr, ff)) continue;
const to = sqOf(rr, ff);
if (!b[to] || colorOf(b[to]) === opp) add(to);
}
if (t === 'K') addCastling(s, sq, moves);
} else {
const dirs = t === 'B' ? DIAG_DIRS : t === 'R' ? ORTH_DIRS : DIAG_DIRS.concat(ORTH_DIRS);
for (const [dr, df] of dirs) {
let rr = r + dr, ff = f + df;
while (inBoard(rr, ff)) {
const to = sqOf(rr, ff);
if (!b[to]) add(to);
else { if (colorOf(b[to]) === opp) add(to); break; }
rr += dr; ff += df;
}
}
}
}
return moves;
}
// Castling: rights present, king & rook on home squares, path empty, king not in/through/into check.
function addCastling(s, sq, moves) {
const c = s.turn, b = s.board, opp = oppColor(c);
const home = c === 'w' ? 4 : 60;
if (sq !== home) return;
if (inCheck(s, c)) return;
const kingSide = c === 'w' ? 'K' : 'k', queenSide = c === 'w' ? 'Q' : 'q';
const K = c === 'w' ? 'K' : 'k', R = c === 'w' ? 'R' : 'r';
if (s.castle.includes(kingSide) && b[home + 3] === R && !b[home + 1] && !b[home + 2] &&
!isAttacked(b, home + 1, opp) && !isAttacked(b, home + 2, opp)) {
moves.push({ f: home, t: home + 2, p: K, c: '', pr: '', ep: false, cs: 1, dbl: false });
}
if (s.castle.includes(queenSide) && b[home - 4] === R && !b[home - 1] && !b[home - 2] && !b[home - 3] &&
!isAttacked(b, home - 1, opp) && !isAttacked(b, home - 2, opp)) {
moves.push({ f: home, t: home - 2, p: K, c: '', pr: '', ep: false, cs: 2, dbl: false });
}
}
function legalMoves(s) {
const out = [];
for (const m of pseudoMoves(s)) {
if (m.cs && !castlePathOk(s, m)) continue;
const ns = applyMove(s, m);
if (!inCheck(ns, s.turn)) out.push(m);
}
return out;
}
function castlePathOk(s, m) { return true; } // path/attack checks already done in addCastling
// Legal captures only (for quiescence search)
function legalCaptures(s) {
return legalMoves(s).filter(m => m.c || m.ep || m.pr === 'q');
}
const uciOf = m => sqName(m.f) + sqName(m.t) + (m.pr || '');
/* ==========================================================================
STATUS / DRAW DETECTION
========================================================================== */
function insufficientMaterial(s) {
const minors = { w: [], b: [] };
let anyPawnOrMajor = false;
for (let sq = 0; sq < 64; sq++) {
const p = s.board[sq];
if (!p) continue;
const t = typeOf(p);
if (t === 'K') continue;
if (t === 'P' || t === 'R' || t === 'Q') { anyPawnOrMajor = true; break; }
minors[colorOf(p)].push({ t, sq });
}
if (anyPawnOrMajor) return false;
const all = minors.w.concat(minors.b);
if (all.length === 0) return true; // K vs K
if (all.length === 1) return true; // K+B vs K, K+N vs K
// K+B vs K+B with bishops all on the same color is a dead draw
if (all.every(x => x.t === 'B')) {
const colors = all.map(x => (rankOf(x.sq) + fileOf(x.sq)) % 2);
return colors.every(c => c === colors[0]);
}
return false;
}
function positionKey(s) {
return s.board.map(x => x || '.').join('') + s.turn + (s.castle || '-') + (s.ep >= 0 ? s.ep : '-');
}
/* ==========================================================================
NOTATION (SAN)
========================================================================== */
function toSAN(s, m, legal) {
let str;
if (m.cs === 1) str = 'O-O';
else if (m.cs === 2) str = 'O-O-O';
else {
const t = typeOf(m.p);
const capture = !!(m.c || m.ep);
const dest = sqName(m.t);
if (t === 'P') {
str = (capture ? FILES[fileOf(m.f)] + 'x' : '') + dest;
if (m.pr) str += '=' + m.pr.toUpperCase();
} else {
const others = legal.filter(o => o.p === m.p && o.t === m.t && o.f !== m.f);
let dis = '';
if (others.length) {
const sameFile = others.some(o => fileOf(o.f) === fileOf(m.f));
const sameRank = others.some(o => rankOf(o.f) === rankOf(m.f));
if (!sameFile) dis = FILES[fileOf(m.f)];
else if (!sameRank) dis = String(rankOf(m.f) + 1);
else dis = sqName(m.f);
}
str = t + dis + (capture ? 'x' : '') + dest;
}
}
const ns = applyMove(s, m);
if (m.ep) str += ' e.p.';
if (inCheck(ns, ns.turn)) str += legalMoves(ns).length ? '+' : '#';
return str;
}
/* ==========================================================================
EVALUATION
========================================================================== */
const MATERIAL = CONFIG.eval.material;
// Evaluation from White's point of view (positive = White better)
function evalWhite(s) {
let v = 0;
const b = s.board;
for (let sq = 0; sq < 64; sq++) {
const p = b[sq];
if (!p) continue;
const c = colorOf(p), t = typeOf(p);
const tbl = PST[t];
const pstIdx = c === 'w' ? (sq ^ 56) : sq;
const val = MATERIAL[t.toLowerCase()] + CONFIG.eval.pstWeight * tbl[pstIdx];
v += c === 'w' ? val : -val;
}
return v;
}
const evalFor = s => (s.turn === 'w' ? evalWhite(s) : -evalWhite(s));
/* ==========================================================================
SEARCH - negamax + alpha-beta, captures-first ordering, quiescence-lite,
iterative deepening with a soft time budget (search yields to UI between moves)
========================================================================== */
const INF = 1e9;
const SEARCH = { nodes: 0, deadline: 0, abort: false };
function orderMoves(moves) {
const score = m => {
let sc = 0;
if (m.c || m.ep) sc += 10000 + MATERIAL[typeOf(m.c || 'P').toLowerCase()] * 10 - MATERIAL[typeOf(m.p).toLowerCase()] / 10;
if (m.pr) sc += 8000;
return sc;
};
return moves.map(m => [score(m), m]).sort((a, b) => b[0] - a[0]).map(x => x[1]);
}
function quiesce(s, alpha, beta, ply, qd) {
if ((++SEARCH.nodes & 1023) === 0 && performance.now() > SEARCH.deadline) SEARCH.abort = true;
if (SEARCH.abort) return 0;
const stand = evalFor(s);
if (stand >= beta) return stand;
if (stand > alpha) alpha = stand;
if (qd >= CONFIG.ai.qsMaxPly) return stand;
const caps = orderMoves(pseudoMoves(s).filter(m => m.c || m.ep || m.pr));
for (const m of caps) {
const ns = applyMove(s, m);
if (inCheck(ns, s.turn)) continue;
const sc = -quiesce(ns, -beta, -alpha, ply + 1, qd + 1);
if (SEARCH.abort) return 0;
if (sc >= beta) return sc;
if (sc > alpha) alpha = sc;
}
return alpha;
}
function negamax(s, depth, alpha, beta, ply) {
if ((++SEARCH.nodes & 1023) === 0 && performance.now() > SEARCH.deadline) SEARCH.abort = true;
if (SEARCH.abort) return 0;
const moves = legalMoves(s);
if (!moves.length) return inCheck(s, s.turn) ? -(CONFIG.eval.mateScore - ply) : 0;
if (s.half >= CONFIG.rules.fiftyMoveHalfmoves || insufficientMaterial(s)) return 0;
if (depth <= 0) return quiesce(s, alpha, beta, ply, 0);
let best = -INF;
for (const m of orderMoves(moves)) {
const ns = applyMove(s, m);
const sc = -negamax(ns, depth - 1, -beta, -alpha, ply + 1);
if (SEARCH.abort) return 0;
if (sc > best) best = sc;
if (sc > alpha) alpha = sc;
if (alpha >= beta) break;
}
return best;
}
// Returns the chosen legal move (from legalMoves(s)) or null.
function chooseMove(s, maxDepth) {
let moves = legalMoves(s);
if (!moves.length) return null;
if (moves.length === 1) return moves[0];
moves = orderMoves(moves);
let best = moves[0];
SEARCH.nodes = 0; SEARCH.abort = false;
SEARCH.deadline = performance.now() + CONFIG.ai.timeLimitMs;
for (let d = 1; d <= maxDepth; d++) {
let alpha = -INF, bestD = null, bestScore = -INF;
for (const m of moves) {
const ns = applyMove(s, m);
const sc = -negamax(ns, d - 1, -INF, -alpha, 1);
if (SEARCH.abort) break;
if (sc > bestScore || bestD === null) { bestScore = sc; bestD = m; }
if (sc > alpha) alpha = sc;
}
if (SEARCH.abort) break; // keep the last fully completed iteration
best = bestD;
// move the best to the front for the next iteration
moves = [bestD].concat(moves.filter(m => m !== bestD));
if (bestScore > CONFIG.eval.mateScore - 100) break;
}
return best;
}
/* ==========================================================================
GAME OUTCOME
========================================================================== */
function computeOutcome(s, hist) {
const legal = legalMoves(s);
const chk = inCheck(s, s.turn);
if (!legal.length) {
if (chk) {
const winner = s.turn === 'w' ? 'Black' : 'White';
return { over: true, result: s.turn === 'w' ? '0-1' : '1-0', title: 'Checkmate', msg: `${winner} wins by checkmate.` };
}
return { over: true, result: '1/2-1/2', title: 'Stalemate', msg: 'No legal moves and not in check. Draw.' };
}
if (insufficientMaterial(s)) return { over: true, result: '1/2-1/2', title: 'Draw', msg: 'Insufficient material to checkmate.' };
if (s.half >= CONFIG.rules.fiftyMoveHalfmoves) return { over: true, result: '1/2-1/2', title: 'Draw', msg: 'Fifty-move rule.' };
const key = fen => fen.split(' ').slice(0, 4).join(' ');
const cur = key(toFen(s));
let reps = 1;
for (const e of hist) if (key(e.fen) === cur) reps++;
if (reps >= CONFIG.rules.threefoldCount) return { over: true, result: '1/2-1/2', title: 'Draw', msg: 'Threefold repetition.' };
return null;
}
/* ==========================================================================
AUDIO (Web Audio, synthesized - no assets)
========================================================================== */
let AC = null;
let muted = (localStorage.getItem('chess-muted') === '1');
function tone(freq, dur, type, vol, delay, slideTo) {
const t0 = AC.currentTime + (delay || 0);
const o = AC.createOscillator(), g = AC.createGain();
o.type = type; o.frequency.setValueAtTime(freq, t0);
if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, t0 + dur);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol, t0 + 0.005);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o.connect(g); g.connect(AC.destination);
o.start(t0); o.stop(t0 + dur + 0.02);
}
function playSound(kind) {
if (muted) return;
try {
if (!AC) AC = new (window.AudioContext || window.webkitAudioContext)();
if (AC.state === 'suspended') AC.resume();
if (kind === 'capture') { tone(220, 0.14, 'sawtooth', 0.12, 0, 90); tone(150, 0.08, 'square', 0.06); }
else if (kind === 'check') { tone(880, 0.1, 'sine', 0.14); tone(1175, 0.16, 'sine', 0.12, 0.09); }
else tone(600, 0.06, 'triangle', 0.16, 0, 420);
} catch (e) { /* audio unavailable - ignore */ }
}
/* ==========================================================================
CONTROLLER STATE
G.state: current position. G.hist: [{fen (before move), san, f, t}].
G.legal: legal moves of G.state. G.outcome: null or result object.
========================================================================== */
const G = {
state: null, hist: [], legal: [], outcome: null,
mode: 'white', depth: 2, flipped: false,
sel: -1, targets: [], drag: null, ghost: null,
aiToken: 0, aiBusy: false, bannerHidden: false, promo: null,
};
const $ = id => document.getElementById(id);
const boardEl = $('board'), boardArea = $('boardArea');
function humanColor() { return G.mode === 'black' ? 'b' : 'w'; }
function aiColor() { return G.mode === 'white' ? 'b' : (G.mode === 'black' ? 'w' : null); }
function humanCanMove() {
if (G.outcome || G.aiBusy || G.promo) return false;
return G.mode === 'both' || G.state.turn === humanColor();
}
/* Persistence -------------------------------------------------------- */
const SAVE_KEY = CONFIG.storageKey;
function saveGame() {
try {
localStorage.setItem(SAVE_KEY, JSON.stringify({
v: 1, mode: G.mode, depth: G.depth, flipped: G.flipped,
cur: toFen(G.state), hist: G.hist,
}));
} catch (e) { /* storage unavailable */ }
}
function loadGame() {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return false;
const d = JSON.parse(raw);
if (!d || d.v !== 1 || typeof d.cur !== 'string') return false;
G.state = parseFen(d.cur);
G.hist = Array.isArray(d.hist) ? d.hist : [];
G.mode = ['white', 'black', 'both'].includes(d.mode) ? d.mode : 'white';
G.depth = [1, 2, 3].includes(d.depth) ? d.depth : 2;
G.flipped = !!d.flipped;
return true;
} catch (e) { return false; }
}
/* Core flow ----------------------------------------------------------- */
function refresh() {
G.aiToken++; // any pending AI search is now stale
G.legal = legalMoves(G.state);
G.outcome = computeOutcome(G.state, G.hist);
G.sel = -1; G.targets = []; G.bannerHidden = false;
render();
saveGame();
scheduleAI();
}
function newGame() {
G.state = startState(); G.hist = [];
G.flipped = (G.mode === 'black');
G.promo = null; hidePromo();
refresh();
}
function commitMove(m) {
const san = toSAN(G.state, m, G.legal);
const captured = !!(m.c || m.ep);
G.hist.push({ fen: toFen(G.state), san, f: m.f, t: m.t });
G.state = applyMove(G.state, m);
G.promo = null; hidePromo();
const chk = inCheck(G.state, G.state.turn);
playSound(chk ? 'check' : captured ? 'capture' : 'move');
refresh();
}
function tryHumanMove(from, to) {
if (!humanCanMove()) return false;
const cands = G.legal.filter(m => m.f === from && m.t === to);
if (!cands.length) return false;
if (cands.length > 1 && cands[0].pr) { showPromo(cands); return true; }
commitMove(cands[0]);
return true;
}
function scheduleAI() {
const ac = aiColor();
if (G.outcome || !ac || G.state.turn !== ac) return render2();
const token = G.aiToken;
G.aiBusy = true;
render2();
setTimeout(() => {
if (token !== G.aiToken) return; // superseded by undo/new game/etc.
const m = chooseMove(G.state, G.depth);
G.aiBusy = false;
if (!m) return render2();
const legal = G.legal.find(x => x.f === m.f && x.t === m.t && x.pr === m.pr);
if (!legal) return render2(); // should never happen
commitMove(legal);
}, CONFIG.ai.aiDelayMs);
}
function undo() {
G.aiToken++; G.aiBusy = false;
if (!G.hist.length) return;
const ac = aiColor();
let e = G.hist.pop();
// undo whole turn cycle: step back until it is the human's turn again
while (ac && G.state && G.hist.length && (G.state.turn === ac || parseFen(e.fen).turn === ac)) {
G.state = parseFen(e.fen);
if (parseFen(e.fen).turn !== ac) break;
e = G.hist.pop();
}
G.state = parseFen(e.fen);
G.promo = null; hidePromo();
refresh();
}
/* ==========================================================================
RENDERING
========================================================================== */
function sqToDisplay(sq) {
const r = rankOf(sq), f = fileOf(sq);
return { dr: G.flipped ? r : 7 - r, dc: G.flipped ? 7 - f : f };
}
function sqAtPoint(x, y) {
const rect = boardEl.getBoundingClientRect();
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) return -1;
const cell = rect.width / 8;
const dr = Math.floor((y - rect.top) / cell), dc = Math.floor((x - rect.left) / cell);
const r = G.flipped ? dr : 7 - dr, f = G.flipped ? 7 - dc : dc;
return sqOf(r, f);
}
function kingInCheckSq() {
if (!inCheck(G.state, G.state.turn)) return -1;
return findKing(G.state.board, G.state.turn);
}
function renderBoard() {
boardEl.innerHTML = '';
const last = G.hist.length ? G.hist[G.hist.length - 1] : null;
const chkSq = kingInCheckSq();
const dragFrom = G.drag && G.drag.started ? G.drag.from : -1;
for (let dr = 0; dr < 8; dr++) for (let dc = 0; dc < 8; dc++) {
const r = G.flipped ? dr : 7 - dr, f = G.flipped ? 7 - dc : dc;
const sq = sqOf(r, f);
const div = document.createElement('div');
const light = (r + f) % 2 === 1;
div.className = 'sq ' + (light ? 'light' : 'dark');
div.dataset.sq = sq;
if (last && (sq === last.f || sq === last.t)) div.classList.add('last');
if (G.sel === sq) div.classList.add('sel');
if (sq === chkSq) div.classList.add('check');
const tgt = G.targets.find(m => m.t === sq);
if (tgt) div.classList.add((G.state.board[sq] || tgt.ep) ? 'ring' : 'dot');
if (dc === 0) { const c = document.createElement('span'); c.className = 'coord r'; c.textContent = r + 1; div.appendChild(c); }
if (dr === 7) { const c = document.createElement('span'); c.className = 'coord f'; c.textContent = FILES[f]; div.appendChild(c); }
const p = G.state.board[sq];
if (p) {
const s = document.createElement('span');
s.className = 'pc ' + colorOf(p) + (sq === dragFrom ? ' dragging' : '');
s.textContent = glyph(p);
div.appendChild(s);
}
boardEl.appendChild(div);
}
}
function setTargets() {
G.targets = G.sel >= 0 ? G.legal.filter(m => m.f === G.sel) : [];
}
function sideName(c) { return c === 'w' ? 'White' : 'Black'; }
function renderStatus() {
const s = G.state;
const dot = $('turnDot');
dot.className = 'dot-ind ' + (G.outcome && G.outcome.over ? '' : s.turn);
dot.style.background = G.outcome ? '#888' : '';
let text;
if (G.outcome) text = `${G.outcome.title} - ${G.outcome.result === '1/2-1/2' ? 'draw' : (G.outcome.result === '1-0' ? 'White wins' : 'Black wins')}`;
else if (G.aiBusy) text = `${sideName(s.turn)} (computer) is thinking\u2026`;
else {
text = `${sideName(s.turn)} to move`;
if (G.mode !== 'both') text += s.turn === humanColor() ? ' (you)' : ' (computer)';
if (inCheck(s, s.turn)) text += ' - check!';
}
$('statusText').innerHTML = '';
const st = document.createElement('span');
st.textContent = text;
if (G.aiBusy) st.className = 'thinking';
$('statusText').appendChild(st);
$('statusText').className = '';
}
function renderTrays() {
const counts = { w: { P: 0, N: 0, B: 0, R: 0, Q: 0 }, b: { P: 0, N: 0, B: 0, R: 0, Q: 0 } };
for (const p of G.state.board) if (p) counts[colorOf(p)][typeOf(p)]++;
const init = { P: 8, N: 2, B: 2, R: 2, Q: 1 };
const val = { P: 1, N: 3, B: 3, R: 5, Q: 9 };
const fill = (color) => { // pieces of `color` that were captured
const out = []; let sum = 0;
for (const t of ['Q', 'R', 'B', 'N', 'P']) {
const n = init[t] - counts[color][t];
for (let i = 0; i < n; i++) { out.push(GLYPH[color][t]); }
sum += n * val[t];
}
return { out, sum };
};
const cw = fill('b'), cb = fill('w'); // cw: black pieces taken by White
const adv = cw.sum - cb.sum;
$('trayBlack').innerHTML = `<span class="lbl">Taken by White</span><span class="gl">${cw.out.join('')}</span>${adv > 0 ? `<span class="adv">+${adv}</span>` : ''}`;
$('trayWhite').innerHTML = `<span class="lbl">Taken by Black</span><span class="gl">${cb.out.join('')}</span>${adv < 0 ? `<span class="adv">+${-adv}</span>` : ''}`;
}
function renderMoves() {
const tbl = $('movesTable');
let html = '';
for (let i = 0; i < G.hist.length; i += 2) {
const w = G.hist[i], b = G.hist[i + 1];
const cur = (i === G.hist.length - 1) ? ' cur' : (b && i + 1 === G.hist.length - 1 ? ' cur' : '');
html += `<tr class="${cur.trim()}"><td class="n">${i / 2 + 1}.</td>` +
`<td class="m">${w ? escapeHtml(w.san) : ''}</td>` +
`<td class="m">${b ? escapeHtml(b.san) : ''}</td></tr>`;
}
if (G.outcome && G.outcome.over) html += `<tr><td class="n"></td><td class="m" colspan="2" style="color:#e0a94a">${G.outcome.result}</td></tr>`;
tbl.innerHTML = html;
const box = $('moves');
box.scrollTop = box.scrollHeight;
}
function escapeHtml(s) { return s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
function renderBanner() {
const b = $('banner');
if (G.outcome && G.outcome.over && !G.bannerHidden) {
b.innerHTML = `<strong>${G.outcome.title}</strong><br>${G.outcome.result === '1/2-1/2' ? 'Result: draw (1/2-1/2)' : 'Result: ' + G.outcome.result}` +
`<small>${G.outcome.msg}</small><small>Click to dismiss, or press New game.</small>`;
b.classList.add('show');
} else b.classList.remove('show');
}
function renderButtons() {
$('btnUndo').disabled = G.hist.length === 0;
$('btnMute').textContent = 'Sound: ' + (muted ? 'off' : 'on');
$('selMode').value = G.mode;
$('selDepth').value = String(G.depth);
}
function render2() { render(); } // alias used by AI scheduling
function render() {
setTargets();
renderBoard();
renderStatus();
renderTrays();
renderMoves();
renderBanner();
renderButtons();
}
/* Promotion picker --------------------------------------------------- */
function showPromo(cands) {
const cell = boardEl.getBoundingClientRect().width / 8;
const to = cands[0].t;
const { dr, dc } = sqToDisplay(to);
const pop = $('promo');
pop.innerHTML = '';
const color = G.state.turn;
for (const m of cands) {
const b = document.createElement('button');
b.innerHTML = `<span class="pc ${color}">${GLYPH[color][m.pr.toUpperCase()]}</span>`;
b.title = { q: 'Queen', r: 'Rook', b: 'Bishop', n: 'Knight' }[m.pr];
b.onclick = (e) => { e.stopPropagation(); G.promo = null; hidePromo(); commitMove(m); };
pop.appendChild(b);
}
const cancel = document.createElement('button');
cancel.className = 'promo-cancel'; cancel.textContent = 'cancel';
cancel.style.width = '100%'; cancel.style.height = 'auto'; cancel.style.background = 'none';
cancel.onclick = (e) => { e.stopPropagation(); G.promo = null; hidePromo(); render(); };
pop.appendChild(cancel);
const left = dc * cell + 10 + 1, belowHalf = dr >= 4;
let top = belowHalf ? dr * cell + 10 - (cands.length * cell) + cell : dr * cell + 10;
top = Math.max(10, Math.min(top, boardArea.clientHeight - 10 - cands.length * cell - 28));
pop.style.left = left + 'px';
pop.style.top = top + 'px';
pop.style.display = 'flex';
G.promo = cands;
}
function hidePromo() { const p = $('promo'); if (p) p.style.display = 'none'; }
/* Pointer input: click-to-move and drag-and-drop --------------------- */
boardEl.addEventListener('pointerdown', e => {
if (e.button !== 0 && e.pointerType === 'mouse') return;
const sq = sqAtPoint(e.clientX, e.clientY);
if (sq < 0 || G.promo) return;
e.preventDefault();
const p = G.state.board[sq];
const own = p && colorOf(p) === G.state.turn;
if (own && humanCanMove()) {
G.sel = sq; setTargets();
G.drag = { from: sq, x0: e.clientX, y0: e.clientY, started: false, ptr: e.pointerId };
render();
} else {
G.drag = { from: -1, x0: e.clientX, y0: e.clientY, started: false, ptr: e.pointerId };
}
});
window.addEventListener('pointermove', e => {
const d = G.drag;
if (!d || e.pointerId !== d.ptr) return;
if (!d.started && d.from >= 0 && Math.hypot(e.clientX - d.x0, e.clientY - d.y0) > 5) {
d.started = true;
const p = G.state.board[d.from];
const g = document.createElement('div');
g.className = 'ghost ' + colorOf(p);
g.textContent = glyph(p);
document.body.appendChild(g);
G.ghost = g;
renderBoard(); // hide the source piece
}
if (G.ghost) { G.ghost.style.left = e.clientX + 'px'; G.ghost.style.top = e.clientY + 'px'; }
});
window.addEventListener('pointerup', e => {
const d = G.drag;
if (!d || e.pointerId !== d.ptr) return;
G.drag = null;
if (G.ghost) { G.ghost.remove(); G.ghost = null; }
const sq = sqAtPoint(e.clientX, e.clientY);
if (d.started) {
if (sq >= 0 && sq !== d.from && G.legal.some(m => m.f === d.from && m.t === sq)) {
G.sel = d.from;
tryHumanMove(d.from, sq);
return;
}
render(); // dropped illegally: snap back, keep selection
return;
}
// click handling
if (sq < 0) return;
if (G.sel >= 0 && sq !== G.sel && G.targets.some(m => m.t === sq)) {
tryHumanMove(G.sel, sq);
return;
}
if (G.sel >= 0 && sq === G.sel) { G.sel = -1; setTargets(); render(); return; }
if (!(G.state.board[sq] && colorOf(G.state.board[sq]) === G.state.turn)) {
G.sel = -1; setTargets(); render();
}
});
// Cancel drag if pointer leaves window in a weird way
window.addEventListener('pointercancel', () => { if (G.ghost) G.ghost.remove(); G.ghost = null; G.drag = null; render(); });
$('banner').addEventListener('click', () => { G.bannerHidden = true; renderBanner(); });
$('btnNew').onclick = () => newGame();
$('btnUndo').onclick = () => undo();
$('btnFlip').onclick = () => { G.flipped = !G.flipped; render(); saveGame(); };
$('btnMute').onclick = () => { muted = !muted; localStorage.setItem('chess-muted', muted ? '1' : '0'); renderButtons(); };
$('selMode').onchange = e => {
G.mode = e.target.value;
if (G.mode === 'black') G.flipped = true; else if (G.mode === 'white') G.flipped = false;
refresh();
};
$('selDepth').onchange = e => { G.depth = parseInt(e.target.value, 10); saveGame(); renderButtons(); };
document.addEventListener('keydown', e => { if (e.key === 'Escape' && G.promo) { G.promo = null; hidePromo(); render(); } });
/* ==========================================================================
DEBUG INTERFACE (window.game) - for headless probing
========================================================================== */
function resolveMove(str) {
const s = String(str).trim();
const uciRe = /^[a-h][1-8][a-h][1-8][qrbn]?$/i;
if (uciRe.test(s)) {
const f = nameToSq(s.slice(0, 2).toLowerCase()), t = nameToSq(s.slice(2, 4).toLowerCase());
const pr = s.length === 5 ? s[4].toLowerCase() : null;
const c = G.legal.filter(m => m.f === f && m.t === t);
if (!c.length) return null;
return (pr ? c.find(m => m.pr === pr) : (c.find(m => m.pr === 'q') || c[0])) || null;
}
const norm = x => x.replace(/[x+#=!?]/g, '').replace(/e\.p\./, '').trim();
const want = norm(s);
return G.legal.find(m => norm(toSAN(G.state, m, G.legal)) === want) || null;
}
window.game = {
fen: () => toFen(G.state),
turn: () => G.state.turn,
legalMoves: () => G.legal.map(m => uciOf(m)),
legalSAN: () => G.legal.map(m => toSAN(G.state, m, G.legal)),
move(str) {
if (G.outcome) return { ok: false, reason: 'game over: ' + G.outcome.title };
if (G.aiBusy) return { ok: false, reason: 'AI thinking' };
const m = resolveMove(str);
if (!m) return { ok: false, reason: 'illegal or unknown' };
const san = toSAN(G.state, m, G.legal);
commitMove(m);
return { ok: true, san };
},
history: () => G.hist.map(h => h.san),
outcome: () => G.outcome,
undo: () => { undo(); return window.game.fen(); },
newGame: (mode) => { if (mode) { G.mode = mode; } newGame(); return window.game.fen(); },
setMode: (m) => { G.mode = m; $('selMode').value = m; refresh(); },
setDepth: (d) => { G.depth = d; saveGame(); renderButtons(); },
aiBusy: () => G.aiBusy,
perft: (fen, d) => perft(parseFen(fen || toFen(G.state)), d),
};
function perft(s, d) {
if (d === 0) return 1;
let n = 0;
for (const m of legalMoves(s)) n += perft(applyMove(s, m), d - 1);
return n;
}
/* ==========================================================================
INIT
========================================================================== */
(function init() {
if (!loadGame()) { G.state = startState(); G.hist = []; }
G.legal = legalMoves(G.state);
G.outcome = computeOutcome(G.state, G.hist);
$('selMode').value = G.mode;
renderBanner();
render();
scheduleAI();
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"bbf5c7b0-ee23-4bb4-9595-920068a8a563","tokensIn":389148,"tokensOut":240620,"tokensTotal":629768,"cost":0.12969976499999997,"turns":14,"toolCalls":16,"failedToolCalls":2,"timestamp":"2026-10-08T01:40:20.750Z"} -->