Chess
hy4 preview · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 68,560 bytes · SHA-256 d14da51280c5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Chess — Grandmaster in a Box</title>
<style>
/* ============================================================
TUNABLE CONSTANTS — visual palette & metrics
============================================================ */
:root{
--sq: min(74px, 11.2vw, 8.6vh); /* square size */
--light: #eddbb8; /* light square (warm) */
--dark: #a9784f; /* dark square (walnut) */
--light-sel: #f2e08a; /* selected square */
--dark-sel: #cb9a3f;
--last-light:#e8dda6; /* last-move highlight */
--last-dark: #c9a86a;
--check: #e0483a; /* check glow */
--frame: #4a3524;
--frame2:#33241a;
--bg: #1d1a17;
--bg2:#272320;
--panel:#2a2521;
--panel2:#211d1a;
--ink: #f0e6d8;
--ink-dim:#a99a86;
--accent:#d8a24a;
--accent2:#8f6a2f;
--line: rgba(255,255,255,.08);
--white-piece:#fdf6e6;
--black-piece:#20160f;
--radius: 12px;
--font: "Segoe UI", "Helvetica Neue", Helvetica, Arial, system-ui, sans-serif;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0; background:
radial-gradient(1200px 700px at 50% -10%, #352c25 0%, var(--bg) 55%, #14110f 100%);
color:var(--ink); font-family:var(--font);
display:flex; align-items:center; justify-content:center;
padding:18px; -webkit-font-smoothing:antialiased;
-webkit-tap-highlight-color:transparent;
}
.app{display:flex; flex-direction:column; gap:14px; align-items:center; max-width:1100px; width:100%}
/* ---------- header ---------- */
header{display:flex; align-items:baseline; gap:14px; width:100%; padding:0 4px}
h1{
margin:0; font-size:20px; font-weight:600; letter-spacing:.14em; text-transform:uppercase;
color:var(--ink);
}
h1 span{color:var(--accent)}
header .sub{font-size:12px; color:var(--ink-dim); letter-spacing:.06em}
/* ---------- layout ---------- */
main{display:flex; gap:26px; align-items:flex-start; justify-content:center; width:100%; flex-wrap:wrap}
/* ---------- board ---------- */
.board-frame{
background:linear-gradient(160deg, var(--frame) 0%, var(--frame2) 100%);
padding:12px; border-radius:var(--radius);
box-shadow:0 26px 50px -18px rgba(0,0,0,.85), inset 0 1px 0 rgba(255,255,255,.09);
display:flex; gap:6px;
}
.ranks,.files{display:flex; color:var(--ink-dim); font-size:11px; font-weight:600; letter-spacing:.05em}
.ranks{flex-direction:column; width:14px}
.ranks div,.files div{flex:1; display:flex; align-items:center; justify-content:center}
.board-col{display:flex; flex-direction:column; gap:6px}
.files{height:14px}
#board{
display:grid; grid-template-columns:repeat(8, var(--sq)); grid-template-rows:repeat(8, var(--sq));
border-radius:4px; overflow:hidden; position:relative;
box-shadow:inset 0 0 0 1px rgba(0,0,0,.45);
touch-action:none; user-select:none; -webkit-user-select:none;
}
.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.light{background:var(--last-light)}
.sq.last.dark{background:var(--last-dark)}
.sq.sel.light{background:var(--light-sel)}
.sq.sel.dark{background:var(--dark-sel)}
.sq.hint::after{
content:''; position:absolute; width:26%; height:26%; border-radius:50%;
background:rgba(20,14,8,.34); box-shadow:inset 0 0 0 1px rgba(0,0,0,.25);
}
.sq.hint.cap::after{
width:88%; height:88%; background:transparent; border-radius:50%;
box-shadow:inset 0 0 0 5px rgba(30,18,8,.42);
}
.sq.hint{cursor:pointer}
.sq.movable{cursor:pointer}
.sq.check{background:radial-gradient(circle at 50% 50%, #f4796a 0%, #d43a2a 62%, #b92c1e 100%)}
.sq.check .piece{filter:drop-shadow(0 0 6px rgba(0,0,0,.6))}
.piece{
font-size:calc(var(--sq) * .80); line-height:1; position:relative; z-index:2;
pointer-events:none; transition:transform .12s ease;
font-family:"DejaVu Sans","Segoe UI Symbol","Noto Sans Symbols2","Apple Symbols",serif;
}
.piece.w{color:var(--white-piece);
text-shadow:0 1px 0 #6b563a, 0 2px 3px rgba(0,0,0,.45), 0 0 1px #4a3a26;
-webkit-text-stroke:.02em #5a4527;
}
.piece.b{color:var(--black-piece);
text-shadow:0 1px 1px rgba(255,255,255,.20), 0 2px 3px rgba(0,0,0,.4);
}
.sq.drag-src .piece{opacity:.28}
.piece.ghost{
position:fixed; z-index:999; pointer-events:none; font-size:calc(var(--sq) * .92);
transform:translate(-50%,-50%) scale(1.06);
filter:drop-shadow(0 8px 12px rgba(0,0,0,.55));
}
/* ---------- side panel ---------- */
.panel{
width:320px; min-width:280px; display:flex; flex-direction:column; gap:12px;
}
.card{
background:linear-gradient(180deg, var(--panel) 0%, var(--panel2) 100%);
border:1px solid var(--line); border-radius:var(--radius); padding:12px 14px;
box-shadow:0 12px 26px -14px rgba(0,0,0,.8);
}
.card h2{
margin:0 0 9px; font-size:10px; letter-spacing:.18em; text-transform:uppercase;
color:var(--ink-dim); font-weight:700; display:flex; justify-content:space-between; align-items:center;
}
/* turn indicator */
.turn{display:flex; align-items:center; gap:11px}
.turn .disc{
width:22px; height:22px; border-radius:50%; flex:0 0 auto;
background:radial-gradient(circle at 34% 30%, #fffdf6 0%, #e2d6bd 55%, #b6a68a 100%);
box-shadow:0 2px 5px rgba(0,0,0,.5), inset 0 0 0 1px rgba(0,0,0,.25);
}
.turn .disc.b{background:radial-gradient(circle at 34% 30%, #4a3c30 0%, #241a12 60%, #120c08 100%)}
.turn .txt{font-size:15px; font-weight:600}
.turn .txt small{display:block; font-size:11px; font-weight:400; color:var(--ink-dim); letter-spacing:.04em}
.think{color:var(--accent); font-size:11px; letter-spacing:.06em}
/* trays */
.tray{display:flex; align-items:center; gap:4px; min-height:30px; flex-wrap:wrap}
.tray .lbl{font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--ink-dim); width:52px; flex:0 0 auto}
.tray .p{font-size:22px; line-height:1}
.tray .p.w{color:var(--white-piece); -webkit-text-stroke:.02em #5a4527; text-shadow:0 1px 2px rgba(0,0,0,.5)}
.tray .p.b{color:#120c08; text-shadow:0 1px 0 rgba(255,255,255,.13)}
.tray .score{margin-left:auto; font-size:12px; color:var(--accent); font-variant-numeric:tabular-nums}
/* move list */
#moves{
height:212px; overflow-y:auto; font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;
font-size:12.5px; line-height:1.75; padding-right:6px; scrollbar-width:thin;
scrollbar-color:var(--accent2) transparent;
}
#moves::-webkit-scrollbar{width:7px}
#moves::-webkit-scrollbar-thumb{background:var(--accent2); border-radius:4px}
#moves table{width:100%; border-collapse:collapse}
#moves td{padding:1px 0}
#moves td.n{color:var(--ink-dim); width:30px; text-align:right; padding-right:10px; user-select:none}
#moves td.m{width:44%}
#moves td.m span{
display:inline-block; padding:0 6px; border-radius:4px; cursor:default;
}
#moves tr:last-child td.m span.cur{background:rgba(216,162,74,.22); color:#ffe9bd}
#moves .empty{color:var(--ink-dim); font-style:italic; font-family:var(--font)}
/* controls */
.row{display:flex; gap:8px; flex-wrap:wrap}
button{
font-family:var(--font); font-size:12px; font-weight:600; letter-spacing:.05em;
color:var(--ink); background:linear-gradient(180deg,#3a332c,#2b251f);
border:1px solid rgba(255,255,255,.12); border-radius:8px; padding:8px 12px; cursor:pointer;
transition:transform .08s, background .15s, border-color .15s;
}
button:hover{background:linear-gradient(180deg,#463d34,#332c25); border-color:rgba(216,162,74,.5)}
button:active{transform:translateY(1px)}
button:disabled{opacity:.38; cursor:not-allowed; transform:none}
button.primary{background:linear-gradient(180deg,#c9963f,#9c6f28); color:#241a0c; border-color:#e0b566}
button.primary:hover{background:linear-gradient(180deg,#dcaa4c,#ac7c2d)}
.seg{display:inline-flex; border:1px solid rgba(255,255,255,.12); border-radius:8px; overflow:hidden}
.seg button{border:0; border-radius:0; background:transparent; padding:7px 11px}
.seg button + button{border-left:1px solid rgba(255,255,255,.12)}
.seg button.on{background:linear-gradient(180deg,#c9963f,#9c6f28); color:#241a0c}
.field{display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:9px}
.field:last-child{margin-bottom:0}
.field > span{font-size:12px; color:var(--ink-dim); letter-spacing:.05em}
.hintline{font-size:11px; color:var(--ink-dim); line-height:1.6; margin-top:4px}
kbd{background:#1b1714; border:1px solid rgba(255,255,255,.14); border-bottom-width:2px; border-radius:4px; padding:0 4px; font-size:10px; font-family:inherit}
/* ---------- promotion picker ---------- */
#promo{
position:fixed; z-index:500; display:flex; gap:2px; padding:4px; border-radius:8px;
background:linear-gradient(180deg,#3b332b,#241d18); border:1px solid rgba(255,255,255,.16);
box-shadow:0 14px 30px -8px rgba(0,0,0,.8);
}
#promo.vert{flex-direction:column}
#promo button{
width:calc(var(--sq) * .92); height:calc(var(--sq) * .92); padding:0; border:0; border-radius:6px;
background:var(--light); font-size:calc(var(--sq) * .62); line-height:1; display:flex;
align-items:center; justify-content:center; color:var(--white-piece);
-webkit-text-stroke:.02em #5a4527; text-shadow:0 1px 2px rgba(0,0,0,.4);
}
#promo button:hover{background:var(--light-sel); transform:none}
#promo button .pb{color:var(--black-piece); -webkit-text-stroke:0; text-shadow:0 1px 0 rgba(255,255,255,.15)}
/* ---------- game over banner ---------- */
#banner{
position:fixed; inset:0; z-index:600; display:flex; align-items:center; justify-content:center;
background:rgba(12,9,7,.62); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px);
animation:fade .25s ease;
}
@keyframes fade{from{opacity:0}to{opacity:1}}
#banner .box{
background:linear-gradient(180deg,#332b24,#221c17); border:1px solid rgba(216,162,74,.35);
border-radius:16px; padding:26px 34px; text-align:center; min-width:280px;
box-shadow:0 30px 60px -20px #000; animation:pop .28s cubic-bezier(.2,1.4,.4,1);
}
@keyframes pop{from{transform:scale(.9); opacity:0}to{transform:scale(1); opacity:1}}
#banner .res{font-size:24px; font-weight:700; letter-spacing:.04em; margin-bottom:6px}
#banner .why{font-size:13px; color:var(--ink-dim); margin-bottom:18px}
.hidden{display:none !important}
@media (max-width: 860px){
main{gap:16px}
.panel{width:100%; max-width:520px}
#moves{height:150px}
body{padding:10px; align-items:flex-start}
}
</style>
</head>
<body>
<div class="app">
<header>
<h1>Chess <span>·</span> Grandmaster in a Box</h1>
<div class="sub">full rules · alpha-beta engine</div>
</header>
<main>
<!-- ================= BOARD ================= -->
<div class="board-frame">
<div class="ranks" id="ranks"></div>
<div class="board-col">
<div id="board"></div>
<div class="files" id="files"></div>
</div>
</div>
<!-- ================= SIDE PANEL ================= -->
<aside class="panel">
<div class="card">
<div class="turn">
<div class="disc" id="turnDisc"></div>
<div class="txt" id="turnTxt">White to move<small> </small></div>
</div>
</div>
<div class="card">
<h2>Captured</h2>
<div class="tray"><span class="lbl">White</span><span id="trayW"></span><span class="score" id="scoreW"></span></div>
<div class="tray" style="margin-top:6px"><span class="lbl">Black</span><span id="trayB"></span><span class="score" id="scoreB"></span></div>
</div>
<div class="card">
<h2>Moves <span id="plyCount" style="letter-spacing:.04em"></span></h2>
<div id="moves"><div class="empty">No moves yet.</div></div>
</div>
<div class="card">
<h2>Game</h2>
<div class="field">
<span>Play as</span>
<span class="seg" id="segColor">
<button data-v="w" class="on">White</button>
<button data-v="b">Black</button>
<button data-v="both">Both</button>
</span>
</div>
<div class="field">
<span>Strength</span>
<span class="seg" id="segDepth">
<button data-v="1">Depth 1</button>
<button data-v="2">Depth 2</button>
<button data-v="3" class="on">Depth 3</button>
</span>
</div>
<div class="row" style="margin-top:12px">
<button id="btnNew" class="primary">New Game</button>
<button id="btnUndo">Undo</button>
<button id="btnFlip">Flip</button>
<button id="btnMute">Sound: on</button>
</div>
<div class="hintline">Click a piece then a square, or drag & drop. <kbd>U</kbd> undo · <kbd>F</kbd> flip · <kbd>N</kbd> new</div>
</div>
</aside>
</main>
</div>
<div id="promo" class="hidden"></div>
<div id="banner" class="hidden">
<div class="box">
<div class="res" id="bannerRes">Checkmate</div>
<div class="why" id="bannerWhy"></div>
<div class="row" style="justify-content:center">
<button class="primary" id="bannerNew">New Game</button>
<button id="bannerClose">Review board</button>
</div>
</div>
</div>
<script>
"use strict";
/* ==========================================================================
CHESS — single file, no dependencies.
Sections:
1. TUNABLES
2. BOARD REPRESENTATION (0x88) + FEN
3. MOVE GENERATION (all special rules)
4. MAKE / UNMAKE
5. GAME STATUS (check / mate / draws)
6. SAN + NOTATION
7. EVALUATION (material + piece-square tables)
8. SEARCH (negamax + alpha-beta + quiescence, iterative deepening)
9. AUDIO
10. PERSISTENCE
11. RENDERING + INTERACTION
12. DEBUG INTERFACE (window.game)
========================================================================== */
/* ==========================================================================
1. TUNABLES
========================================================================== */
const CONFIG = {
/* --- AI --- */
AI_DEPTHS: [1, 2, 3], // difficulty selector options
AI_DEFAULT: 3, // default depth
AI_TIME_BUDGET: 1100, // ms — hard-ish cap for a whole AI move
AI_YIELD_MS: 45, // ms between UI yields while searching
QUIESCE_MAX: 6, // quiescence extra plies
/* --- evaluation weights --- */
MATERIAL: { p:100, n:320, b:330, r:500, q:900, k:20000 },
PST_WEIGHT: 1.0,
BISHOP_PAIR: 30,
MOBILITY_W: 0, // 0 = off (kept for tuning)
MATE: 100000,
/* --- rules --- */
FIFTY_MOVE_DRAW: 100, // half-moves (50 full moves)
/* --- misc --- */
STORAGE_KEY: 'chess.grandmaster.v1',
AI_DELAY: 220 // ms pause before AI "starts thinking"
};
/* ==========================================================================
2. BOARD REPRESENTATION (0x88)
========================================================================== */
/* index = rank*16 + file (rank 0 = rank "1", file 0 = file "a")
off-board detection: (index & 0x88) !== 0 */
const FILES = ['a','b','c','d','e','f','g','h'];
const RANKS = ['1','2','3','4','5','6','7','8'];
/** SQUARES['e4'] -> 0x88 index ; ALGEBRAIC[0x88] -> 'e4' (or undefined) */
const SQUARES = {};
const ALGEBRAIC = new Array(128);
for (let r = 0; r < 8; r++) {
for (let f = 0; f < 8; f++) {
const idx = r * 16 + f;
const name = FILES[f] + RANKS[r];
SQUARES[name] = idx;
ALGEBRAIC[idx] = name;
}
}
const OFF = (i) => (i & 0x88) !== 0;
const fileOf = (i) => i & 15;
const rankOf = (i) => i >> 4;
/* piece codes: uppercase = white, lowercase = black */
const PAWN='p', KNIGHT='n', BISHOP='b', ROOK='r', QUEEN='q', KING='k';
const colorOf = (p) => (p && p === p.toUpperCase()) ? 'w' : 'b';
const typeOf = (p) => (p ? p.toLowerCase() : null);
const swapColor = (c) => (c === 'w' ? 'b' : 'w');
const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
/* --- move flags (bitmask) --- */
const FLAGS = {
NORMAL:1, CAPTURE:2, BIG_PAWN:4, EP_CAPTURE:8,
PROMOTION:16, KSIDE_CASTLE:32, QSIDE_CASTLE:64
};
/* --- piece offsets --- */
const PAWN_DIRS = { w: 16, b: -16 }; // single push
const PAWN_ATTACKS = { w: [15, 17], b: [-15, -17] }; // (0x88: +15 = up-left, +17 = up-right)
const PIECE_DIRS = {
n: [ 33, 18, -14, -31, -33, -18, 14, 31],
b: [ 17, 15, -17, -15],
r: [ 16, -16, 1, -1],
q: [ 17, 16, 15, 1, -1, -15, -16, -17],
k: [ 17, 16, 15, 1, -1, -15, -16, -17]
};
const SLIDING = { b:true, r:true, q:true };
/* --- castling bookkeeping --- */
const CASTLE_INFO = {
w: { k:{ king:SQUARES.e1, rook:SQUARES.h1, kingTo:SQUARES.g1, rookTo:SQUARES.f1,
empties:[SQUARES.f1, SQUARES.g1], safe:[SQUARES.e1, SQUARES.f1, SQUARES.g1], piece:'R' },
q:{ king:SQUARES.e1, rook:SQUARES.a1, kingTo:SQUARES.c1, rookTo:SQUARES.d1,
empties:[SQUARES.d1, SQUARES.c1, SQUARES.b1], safe:[SQUARES.e1, SQUARES.d1, SQUARES.c1], piece:'R' } },
b: { k:{ king:SQUARES.e8, rook:SQUARES.h8, kingTo:SQUARES.g8, rookTo:SQUARES.f8,
empties:[SQUARES.f8, SQUARES.g8], safe:[SQUARES.e8, SQUARES.f8, SQUARES.g8], piece:'r' },
q:{ king:SQUARES.e8, rook:SQUARES.a8, kingTo:SQUARES.c8, rookTo:SQUARES.d8,
empties:[SQUARES.d8, SQUARES.c8, SQUARES.b8], safe:[SQUARES.e8, SQUARES.d8, SQUARES.c8], piece:'r' } }
};
/* ------------------------- state ------------------------- */
/** Creates an empty game state object. */
function emptyState() {
return {
board: new Array(128).fill(null),
turn: 'w',
castling: { w:{ k:false, q:false }, b:{ k:false, q:false } },
ep: -1,
halfmoves: 0,
fullmove: 1,
kings: { w:-1, b:-1 }
};
}
/** Parses a FEN string into a state object. */
function loadFen(fen) {
const st = emptyState();
const parts = fen.trim().split(/\s+/);
if (parts.length < 4) throw new Error('bad FEN: ' + fen);
const rows = parts[0].split('/');
if (rows.length !== 8) throw new Error('bad FEN board: ' + fen);
for (let i = 0; i < 8; i++) {
const rank = 7 - i; // rows[0] is rank 8
let f = 0;
for (const ch of rows[i]) {
if (/[1-8]/.test(ch)) { f += +ch; }
else {
if (f > 7) throw new Error('bad FEN row: ' + rows[i]);
const idx = rank * 16 + f;
st.board[idx] = ch;
if (ch === 'K') st.kings.w = idx;
if (ch === 'k') st.kings.b = idx;
f++;
}
}
}
st.turn = (parts[1] === 'b') ? 'b' : 'w';
const c = parts[2];
st.castling.w.k = c.includes('K');
st.castling.w.q = c.includes('Q');
st.castling.b.k = c.includes('k');
st.castling.b.q = c.includes('q');
st.ep = (parts[3] && parts[3] !== '-' && SQUARES[parts[3]] !== undefined) ? SQUARES[parts[3]] : -1;
st.halfmoves = parts[4] !== undefined ? (+parts[4] || 0) : 0;
st.fullmove = parts[5] !== undefined ? (+parts[5] || 1) : 1;
return st;
}
/** Serializes a state object to FEN. */
function toFen(st) {
let out = '';
for (let rank = 7; rank >= 0; rank--) {
let empty = 0;
for (let f = 0; f < 8; f++) {
const p = st.board[rank * 16 + f];
if (!p) { empty++; }
else {
if (empty) { out += empty; empty = 0; }
out += p;
}
}
if (empty) out += empty;
if (rank) out += '/';
}
let c = '';
if (st.castling.w.k) c += 'K';
if (st.castling.w.q) c += 'Q';
if (st.castling.b.k) c += 'k';
if (st.castling.b.q) c += 'q';
if (!c) c = '-';
return [out, st.turn, c, (st.ep >= 0 ? ALGEBRAIC[st.ep] : '-'), st.halfmoves, st.fullmove].join(' ');
}
/** Deep-ish copy of a state (board array + scalars). */
function cloneState(st) {
return {
board: st.board.slice(),
turn: st.turn,
castling: { w:{ k:st.castling.w.k, q:st.castling.w.q }, b:{ k:st.castling.b.k, q:st.castling.b.q } },
ep: st.ep,
halfmoves: st.halfmoves,
fullmove: st.fullmove,
kings: { w:st.kings.w, b:st.kings.b }
};
}
/* ==========================================================================
3. MOVE GENERATION
========================================================================== */
/** Is `square` attacked by any piece of colour `byColor`? */
function isAttacked(board, square, byColor) {
const up = byColor === 'w';
/* pawns: a pawn on `square` attacks diagonally "forward"; so look backwards */
for (const d of PAWN_ATTACKS[byColor]) {
const from = square - d; // inverse direction
if (OFF(from)) continue;
const p = board[from];
if (p && p.toLowerCase() === PAWN && colorOf(p) === byColor) return true;
}
/* knights */
for (const d of PIECE_DIRS.n) {
const from = square + d;
if (OFF(from)) continue;
const p = board[from];
if (p && p.toLowerCase() === KNIGHT && colorOf(p) === byColor) return true;
}
/* king */
for (const d of PIECE_DIRS.k) {
const from = square + d;
if (OFF(from)) continue;
const p = board[from];
if (p && p.toLowerCase() === KING && colorOf(p) === byColor) return true;
}
/* sliding: rook/queen */
for (const d of PIECE_DIRS.r) {
let s = square + d;
while (!OFF(s)) {
const p = board[s];
if (p) {
if (colorOf(p) === byColor) {
const t = p.toLowerCase();
if (t === ROOK || t === QUEEN) return true;
}
break;
}
s += d;
}
}
/* sliding: bishop/queen */
for (const d of PIECE_DIRS.b) {
let s = square + d;
while (!OFF(s)) {
const p = board[s];
if (p) {
if (colorOf(p) === byColor) {
const t = p.toLowerCase();
if (t === BISHOP || t === QUEEN) return true;
}
break;
}
s += d;
}
}
return false;
}
/** Is the side-to-move's king currently in check? */
function inCheck(st) {
const k = st.kings[st.turn];
if (k < 0) return false;
return isAttacked(st.board, k, swapColor(st.turn));
}
/** Pushes a pawn move (handles double push, promotion, en passant). */
function addPawnMove(st, moves, from, to, flags) {
const board = st.board, us = st.turn;
const lastRank = us === 'w' ? 7 : 0;
if (rankOf(to) === lastRank) {
for (const promo of [QUEEN, ROOK, BISHOP, KNIGHT]) {
const m = { from, to, piece: board[from], capture: null, promotion: promo,
flags: flags | FLAGS.PROMOTION };
if (flags & FLAGS.CAPTURE) m.capture = board[to];
moves.push(m);
}
} else {
const m = { from, to, piece: board[from], capture: null, promotion: null, flags };
if (flags & FLAGS.CAPTURE) m.capture = board[to];
moves.push(m);
}
}
/**
* Pseudo-legal move generation.
* @param {object} st state
* @param {object} [opts] { square: only generate from this 0x88 index,
* capturesOnly: only captures/promotions }
*/
function generateMoves(st, opts) {
opts = opts || {};
const moves = [];
const board = st.board, us = st.turn, them = swapColor(us);
const onlySq = (opts.square !== undefined && opts.square !== null) ? opts.square : -1;
const capsOnly = !!opts.capturesOnly;
const start = (onlySq >= 0) ? onlySq : 0;
const end = (onlySq >= 0) ? onlySq : 119;
for (let from = start; from <= end; from++) {
if (OFF(from)) continue;
const piece = board[from];
if (!piece || colorOf(piece) !== us) continue;
const type = piece.toLowerCase();
if (type === PAWN) {
const dir = PAWN_DIRS[us];
const startRank = us === 'w' ? 1 : 6;
const one = from + dir;
if (!OFF(one) && !board[one] && !capsOnly) {
addPawnMove(st, moves, from, one, FLAGS.NORMAL);
const two = from + 2 * dir;
if (rankOf(from) === startRank && !board[two]) {
moves.push({ from, to: two, piece, capture: null, promotion: null,
flags: FLAGS.BIG_PAWN });
}
}
for (const d of PAWN_ATTACKS[us]) {
const to = from + d;
if (OFF(to)) continue;
const target = board[to];
if (target && colorOf(target) === them) {
addPawnMove(st, moves, from, to, FLAGS.CAPTURE);
} else if (!target && to === st.ep) {
moves.push({ from, to, piece, capture: us === 'w' ? 'p' : 'P', promotion: null,
flags: FLAGS.EP_CAPTURE | FLAGS.CAPTURE });
}
}
continue;
}
if (type === KING) {
for (const d of PIECE_DIRS.k) {
const to = from + d;
if (OFF(to)) continue;
const target = board[to];
if (target && colorOf(target) === us) continue;
if (capsOnly && !target) continue;
moves.push({ from, to, piece, capture: target || null, promotion: null,
flags: target ? FLAGS.CAPTURE : FLAGS.NORMAL });
}
/* castling */
if (!capsOnly && from === CASTLE_INFO[us].k.king && !isAttacked(board, from, them)) {
for (const side of ['k','q']) {
const info = CASTLE_INFO[us][side];
if (!st.castling[us][side]) continue;
if (board[info.rook] !== info.piece) continue;
let ok = true;
for (const s of info.empties) { if (board[s]) { ok = false; break; } }
if (!ok) continue;
for (const s of info.safe) { if (isAttacked(board, s, them)) { ok = false; break; } }
if (!ok) continue;
moves.push({ from, to: info.kingTo, piece, capture: null, promotion: null,
flags: (side === 'k' ? FLAGS.KSIDE_CASTLE : FLAGS.QSIDE_CASTLE) });
}
}
continue;
}
/* knights, bishops, rooks, queens */
const dirs = PIECE_DIRS[type];
if (!dirs) continue;
for (const d of dirs) {
let to = from + d;
while (!OFF(to)) {
const target = board[to];
if (!target) {
if (!capsOnly) moves.push({ from, to, piece, capture: null, promotion: null, flags: FLAGS.NORMAL });
} else {
if (colorOf(target) === them) {
moves.push({ from, to, piece, capture: target, promotion: null, flags: FLAGS.CAPTURE });
}
break;
}
if (!SLIDING[type]) break;
to += d;
}
}
}
return moves;
}
/**
* Legal move generation — pseudo-legal filtered by "does my king end up safe?".
* This is the single source of truth for the UI: illegal states are unreachable.
*/
function legalMoves(st, opts) {
const pseudo = generateMoves(st, opts);
const out = [];
const us = st.turn;
for (const m of pseudo) {
const undo = makeMove(st, m);
if (!isAttacked(st.board, st.kings[us], swapColor(us))) out.push(m);
unmakeMove(st, undo);
}
return out;
}
/* ==========================================================================
4. MAKE / UNMAKE
========================================================================== */
/** Applies `move` to `st`, returning an undo record. Mutating (fast for search). */
function makeMove(st, m) {
const board = st.board;
const us = st.turn, them = swapColor(us);
const undo = {
m,
castling: { w:{ k:st.castling.w.k, q:st.castling.w.q }, b:{ k:st.castling.b.k, q:st.castling.b.q } },
ep: st.ep,
halfmoves: st.halfmoves,
fullmove: st.fullmove,
kings: { w:st.kings.w, b:st.kings.b }
};
board[m.to] = board[m.from];
board[m.from] = null;
/* en-passant capture removes the pawn *behind* the target square */
if (m.flags & FLAGS.EP_CAPTURE) {
board[m.to + (us === 'w' ? -16 : 16)] = null;
}
/* promotion */
if (m.flags & FLAGS.PROMOTION) {
board[m.to] = (us === 'w') ? m.promotion.toUpperCase() : m.promotion;
}
/* king tracking */
if (m.piece.toLowerCase() === KING) st.kings[us] = m.to;
/* castling rights */
if (m.from === SQUARES.e1 || m.to === SQUARES.e1) { st.castling.w.k = false; st.castling.w.q = false; }
if (m.from === SQUARES.e8 || m.to === SQUARES.e8) { st.castling.b.k = false; st.castling.b.q = false; }
if (m.from === SQUARES.h1 || m.to === SQUARES.h1) st.castling.w.k = false;
if (m.from === SQUARES.a1 || m.to === SQUARES.a1) st.castling.w.q = false;
if (m.from === SQUARES.h8 || m.to === SQUARES.h8) st.castling.b.k = false;
if (m.from === SQUARES.a8 || m.to === SQUARES.a8) st.castling.b.q = false;
/* rook hop for castling */
if (m.flags & FLAGS.KSIDE_CASTLE) {
const info = CASTLE_INFO[us].k;
board[info.rookTo] = board[info.rook];
board[info.rook] = null;
} else if (m.flags & FLAGS.QSIDE_CASTLE) {
const info = CASTLE_INFO[us].q;
board[info.rookTo] = board[info.rook];
board[info.rook] = null;
}
/* en-passant target square (only after a double push) */
st.ep = (m.flags & FLAGS.BIG_PAWN) ? (m.from + PAWN_DIRS[us]) : -1;
/* clocks */
if ((m.flags & FLAGS.CAPTURE) || m.piece.toLowerCase() === PAWN) st.halfmoves = 0;
else st.halfmoves++;
if (us === 'b') st.fullmove++;
st.turn = them;
return undo;
}
/** Reverses `makeMove`. */
function unmakeMove(st, undo) {
const board = st.board;
const m = undo.m;
const us = swapColor(st.turn); // side that made the move
st.turn = us;
st.castling = undo.castling;
st.ep = undo.ep;
st.halfmoves = undo.halfmoves;
st.fullmove = undo.fullmove;
st.kings = undo.kings;
board[m.from] = m.piece;
board[m.to] = null;
if (m.flags & FLAGS.EP_CAPTURE) {
board[m.to + (us === 'w' ? -16 : 16)] = (us === 'w' ? 'p' : 'P');
}
if (m.flags & FLAGS.CAPTURE && !(m.flags & FLAGS.EP_CAPTURE)) {
board[m.to] = m.capture;
}
if (m.flags & FLAGS.KSIDE_CASTLE) {
const info = CASTLE_INFO[us].k;
board[info.rook] = board[info.rookTo];
board[info.rookTo] = null;
} else if (m.flags & FLAGS.QSIDE_CASTLE) {
const info = CASTLE_INFO[us].q;
board[info.rook] = board[info.rookTo];
board[info.rookTo] = null;
}
}
/** UCI string for a move, e.g. "e2e4", "e7e8q". */
function moveToUci(m) {
return ALGEBRAIC[m.from] + ALGEBRAIC[m.to] + (m.promotion ? m.promotion : '');
}
/** Finds the legal move matching a UCI string, or null. */
function findMoveByUci(st, uci) {
if (typeof uci !== 'string' || uci.length < 4) return null;
const from = SQUARES[uci.slice(0, 2)];
const to = SQUARES[uci.slice(2, 4)];
const promo = uci.length > 4 ? uci[4].toLowerCase() : null;
if (from === undefined || to === undefined) return null;
for (const m of legalMoves(st)) {
if (m.from === from && m.to === to) {
if (m.promotion) { if (m.promotion === promo) return m; }
else if (!promo) return m;
}
}
return null;
}
/* ==========================================================================
5. GAME STATUS — check, mate, stalemate, draws
========================================================================== */
const squareColor = (sq) => (fileOf(sq) + rankOf(sq)) & 1;
/** K vs K, K+B vs K, K+N vs K (and same-coloured bishops) — no one can mate. */
function insufficientMaterial(st) {
const counts = { p:0, n:0, b:0, r:0, q:0 };
const bishopSquares = [];
for (let r = 0; r < 8; r++) {
for (let f = 0; f < 8; f++) {
const p = st.board[r * 16 + f];
if (!p) continue;
const t = p.toLowerCase();
if (t === KING) continue;
counts[t]++;
if (t === BISHOP) bishopSquares.push(r * 16 + f);
}
}
if (counts.p || counts.r || counts.q) return false; // mating material exists
const minors = counts.n + counts.b;
if (minors === 0) return true; // K vs K
if (minors === 1) return true; // K+minor vs K
if (counts.b === 2 && counts.n === 0 &&
squareColor(bishopSquares[0]) === squareColor(bishopSquares[1])) return true;
return false;
}
/**
* Full status of a position.
* @returns {{over:boolean, result:string|null, winner:string|null,
* check:boolean, moves:Array, reason:string}}
*/
function gameStatus(st) {
const check = inCheck(st);
const moves = legalMoves(st);
if (moves.length === 0) {
return { over:true, check, moves,
result: check ? 'checkmate' : 'stalemate',
winner: check ? swapColor(st.turn) : null,
reason: check ? 'Checkmate' : 'Stalemate' };
}
if (insufficientMaterial(st)) {
return { over:true, check, moves, result:'draw', winner:null,
reason:'Draw — insufficient material' };
}
if (st.halfmoves >= CONFIG.FIFTY_MOVE_DRAW) {
return { over:true, check, moves, result:'draw', winner:null,
reason:'Draw — fifty-move rule' };
}
return { over:false, check, moves, result:null, winner:null, reason:'' };
}
/* ==========================================================================
6. SAN / NOTATION
========================================================================== */
/** Builds the disambiguation prefix ("" | "e" | "4" | "e4") for a piece move. */
function disambiguate(st, m, legal) {
if (!legal) return '';
const rivals = legal.filter(o =>
o !== m && o.to === m.to && o.piece === m.piece && o.from !== m.from);
if (!rivals.length) return '';
const sameFile = rivals.some(o => fileOf(o.from) === fileOf(m.from));
const sameRank = rivals.some(o => rankOf(o.from) === rankOf(m.from));
if (!sameFile) return FILES[fileOf(m.from)];
if (!sameRank) return RANKS[rankOf(m.from)];
return FILES[fileOf(m.from)] + RANKS[rankOf(m.from)];
}
/**
* Standard Algebraic Notation for `m` played in `st`.
* `legal` (optional) is the legal-move list of the position, for disambiguation.
*/
function moveToSan(st, m, legal) {
let san;
if (m.flags & FLAGS.KSIDE_CASTLE) san = 'O-O';
else if (m.flags & FLAGS.QSIDE_CASTLE) san = 'O-O-O';
else {
const type = m.piece.toLowerCase();
const to = ALGEBRAIC[m.to];
if (type === PAWN) {
if (m.flags & FLAGS.CAPTURE) san = FILES[fileOf(m.from)] + 'x' + to;
else san = to;
if (m.flags & FLAGS.PROMOTION) san += '=' + m.promotion.toUpperCase();
if (m.flags & FLAGS.EP_CAPTURE) san += ' e.p.';
} else {
san = type.toUpperCase() + disambiguate(st, m, legal) +
((m.flags & FLAGS.CAPTURE) ? 'x' : '') + to;
}
}
/* suffix: + or # */
const undo = makeMove(st, m);
let suffix = '';
if (inCheck(st)) suffix = legalMoves(st).length === 0 ? '#' : '+';
unmakeMove(st, undo);
return san + suffix;
}
/* ==========================================================================
7. EVALUATION — material + piece-square tables
========================================================================== */
/* Tables are written rank 8 → rank 1, file a → h (index 0 = a8). */
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 ],
kMid: [
-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 ],
kEnd: [
-50,-40,-30,-20,-20,-30,-40,-50,
-30,-20,-10, 0, 0,-10,-20,-30,
-30,-10, 20, 30, 30, 20,-10,-30,
-30,-10, 30, 40, 40, 30,-10,-30,
-30,-10, 30, 40, 40, 30,-10,-30,
-30,-10, 20, 30, 30, 20,-10,-30,
-30,-30, 0, 0, 0, 0,-30,-30,
-50,-30,-30,-30,-30,-30,-30,-50 ]
};
/* table index for a 0x88 square, from white's / black's point of view */
const pstIndex = (sq, white) => white ? ((7 - rankOf(sq)) * 8 + fileOf(sq))
: (rankOf(sq) * 8 + fileOf(sq));
/**
* Static evaluation, from the point of view of the side to move.
* Positive = good for the side to move.
*/
function evaluate(st) {
const board = st.board;
const M = CONFIG.MATERIAL;
let score = 0, nonPawn = 0, bishops = { w:0, b:0 };
for (let r = 0; r < 8; r++) {
for (let f = 0; f < 8; f++) {
const sq = r * 16 + f;
const p = board[sq];
if (!p) continue;
const white = (p === p.toUpperCase());
const t = p.toLowerCase();
const sign = white ? 1 : -1;
const idx = pstIndex(sq, white);
let v = M[t];
if (t === KING) {
/* king table chosen lazily below — use mid for now, patched after phase */
v += PST.kMid[idx];
} else {
v += PST[t][idx] * CONFIG.PST_WEIGHT;
if (t !== PAWN) nonPawn += M[t];
if (t === BISHOP) bishops[white ? 'w' : 'b']++;
}
score += sign * v;
}
}
/* endgame king table swap */
const endgame = nonPawn < 1300;
if (endgame) {
for (const c of ['w','b']) {
const ksq = st.kings[c];
if (ksq < 0) continue;
const white = (c === 'w');
score += (white ? 1 : -1) * (PST.kEnd[pstIndex(ksq, white)] - PST.kMid[pstIndex(ksq, white)]);
}
}
if (bishops.w >= 2) score += CONFIG.BISHOP_PAIR;
if (bishops.b >= 2) score -= CONFIG.BISHOP_PAIR;
/* tiny nudge so the engine prefers something over shuffling when equal */
score += (st.turn === 'w' ? 6 : -6);
return st.turn === 'w' ? score : -score;
}
/* ==========================================================================
8. SEARCH — negamax + alpha-beta + quiescence + iterative deepening
========================================================================== */
const INF = 1e9;
const searchCtx = { deadline: 0, aborted: false, nodes: 0 };
/** Most-valuable-victim / least-valuable-attacker ordering, promotions next. */
function orderMoves(moves) {
const M = CONFIG.MATERIAL;
for (const m of moves) {
let s = 0;
if (m.capture) s += 1000000 + 10 * M[typeOf(m.capture)] - M[typeOf(m.piece)];
if (m.promotion) s += 900000 + M[m.promotion];
if (m.flags & FLAGS.KSIDE_CASTLE || m.flags & FLAGS.QSIDE_CASTLE) s += 500;
m._o = s;
}
moves.sort((a, b) => b._o - a._o);
return moves;
}
const timeUp = () => {
if ((searchCtx.nodes & 1023) === 0 && performance.now() > searchCtx.deadline) {
searchCtx.aborted = true;
}
return searchCtx.aborted;
};
/** Quiescence search — captures only (plus check evasions), kills the horizon effect. */
function quiesce(st, alpha, beta, qd) {
searchCtx.nodes++;
if (timeUp()) return evaluate(st);
if (inCheck(st)) {
if (qd >= CONFIG.QUIESCE_MAX) return evaluate(st);
const moves = orderMoves(legalMoves(st));
if (!moves.length) return -CONFIG.MATE + 100; // mate on the board
let best = -INF;
for (const m of moves) {
const u = makeMove(st, m);
const s = -quiesce(st, -beta, -alpha, qd + 1);
unmakeMove(st, u);
if (s > best) best = s;
if (best > alpha) alpha = best;
if (alpha >= beta) break;
if (searchCtx.aborted) break;
}
return best;
}
let stand = evaluate(st);
if (stand >= beta) return stand;
if (stand > alpha) alpha = stand;
if (qd >= CONFIG.QUIESCE_MAX) return stand;
const caps = orderMoves(legalMoves(st, { capturesOnly: true }));
let best = stand;
for (const m of caps) {
const u = makeMove(st, m);
const s = -quiesce(st, -beta, -alpha, qd + 1);
unmakeMove(st, u);
if (s > best) best = s;
if (best > alpha) alpha = best;
if (alpha >= beta) break;
if (searchCtx.aborted) break;
}
return best;
}
/** Plain negamax with alpha-beta. Returns score from the side-to-move's view. */
function negamax(st, depth, alpha, beta, ply) {
searchCtx.nodes++;
if (timeUp()) return evaluate(st);
if (st.halfmoves >= CONFIG.FIFTY_MOVE_DRAW || insufficientMaterial(st)) return 0;
if (depth <= 0) return quiesce(st, alpha, beta, 0);
const moves = orderMoves(legalMoves(st));
if (!moves.length) {
return inCheck(st) ? (-CONFIG.MATE + ply) : 0; // mate / stalemate
}
let best = -INF;
for (const m of moves) {
const u = makeMove(st, m);
const s = -negamax(st, depth - 1, -beta, -alpha, ply + 1);
unmakeMove(st, u);
if (s > best) best = s;
if (best > alpha) alpha = best;
if (alpha >= beta) break; // beta cut-off
if (searchCtx.aborted) break;
}
return best;
}
const yieldToUI = () => new Promise(res => setTimeout(res, 0));
/**
* Iterative-deepening root search. Yields to the event loop so the UI never
* freezes, and honours a wall-clock budget.
* @returns {Promise<{move:object, score:number, depth:number, nodes:number}>}
*/
async function searchBestMove(st, maxDepth, budgetMs) {
const root = legalMoves(st);
if (!root.length) return null;
searchCtx.nodes = 0;
searchCtx.aborted = false;
searchCtx.deadline = performance.now() + (budgetMs || CONFIG.AI_TIME_BUDGET);
let best = orderMoves(root.slice())[0];
let bestScore = 0, reachedDepth = 0;
let lastYield = performance.now();
for (let depth = 1; depth <= maxDepth; depth++) {
let alpha = -INF, localBest = null, localScore = -INF, completed = 0;
/* search the previous best move first — better pruning */
const ordered = orderMoves(root.slice()).sort((a, b) => (a === best ? -1 : b === best ? 1 : 0));
for (const m of ordered) {
const u = makeMove(st, m);
const s = -negamax(st, depth - 1, -INF, -alpha, 1);
unmakeMove(st, u);
if (searchCtx.aborted) break;
completed++;
if (s > localScore) { localScore = s; localBest = m; }
if (s > alpha) alpha = s;
if (performance.now() - lastYield > CONFIG.AI_YIELD_MS) {
lastYield = performance.now();
await yieldToUI();
}
}
/* keep partial results: a fully searched root move is better than nothing */
if (localBest && completed > 0) {
best = localBest; bestScore = localScore; reachedDepth = depth;
}
if (searchCtx.aborted) break;
if (bestScore > CONFIG.MATE - 200) break; // mate found, stop early
if (performance.now() > searchCtx.deadline) break;
}
return { move: best, score: bestScore, depth: reachedDepth, nodes: searchCtx.nodes };
}
/* ==========================================================================
9. AUDIO — tiny Web Audio synth (no assets)
========================================================================== */
const Sfx = {
ctx: null,
muted: false,
ensure() {
if (!this.ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
try { this.ctx = new AC(); } catch (e) { return null; }
}
if (this.ctx.state === 'suspended') this.ctx.resume();
return this.ctx;
},
/** simple enveloped oscillator */
tone(freq, dur, type, vol, slideTo) {
if (this.muted) return;
const ctx = this.ensure(); if (!ctx) return;
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = type || 'sine';
o.frequency.setValueAtTime(freq, ctx.currentTime);
if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(30, slideTo), ctx.currentTime + dur);
g.gain.setValueAtTime(0.0001, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(vol || 0.14, ctx.currentTime + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur);
o.connect(g); g.connect(ctx.destination);
o.start(); o.stop(ctx.currentTime + dur + 0.02);
},
/** filtered noise burst — the "clack" of a piece hitting wood */
noise(dur, vol, freq) {
if (this.muted) return;
const ctx = this.ensure(); if (!ctx) return;
const len = Math.floor(ctx.sampleRate * dur);
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len);
const src = ctx.createBufferSource(); src.buffer = buf;
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq || 1400; bp.Q.value = 1.1;
const g = ctx.createGain(); g.gain.value = vol || 0.16;
src.connect(bp); bp.connect(g); g.connect(ctx.destination);
src.start();
},
move() { this.noise(0.05, 0.13, 1100); this.tone(196, 0.07, 'triangle', 0.05, 150); },
capture() { this.noise(0.11, 0.22, 700); this.tone(120, 0.16, 'square', 0.09, 70); },
check() { this.tone(880, 0.10, 'square', 0.08); setTimeout(() => this.tone(1320, 0.16, 'square', 0.08), 95); },
castle() { this.move(); setTimeout(() => this.move(), 90); },
promote() { this.tone(523, 0.09, 'triangle', 0.08); setTimeout(() => this.tone(784, 0.12, 'triangle', 0.08), 80); },
end() { this.tone(392, 0.16, 'sine', 0.10); setTimeout(() => this.tone(294, 0.30, 'sine', 0.10), 150); }
};
/* ==========================================================================
10. PERSISTENCE
========================================================================== */
const Store = {
save(g) {
try {
localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify({
v: 1,
startFen: g.startFen,
moves: g.history.map(h => moveToUci(h.move)),
playerColor: g.playerColor,
depth: g.depth,
flipped: g.flipped,
muted: Sfx.muted
}));
} catch (e) { /* private mode / quota — ignore */ }
},
load() {
try {
const raw = localStorage.getItem(CONFIG.STORAGE_KEY);
if (!raw) return null;
const d = JSON.parse(raw);
if (!d || d.v !== 1 || typeof d.startFen !== 'string') return null;
return d;
} catch (e) { return null; }
},
clear() { try { localStorage.removeItem(CONFIG.STORAGE_KEY); } catch (e) {} }
};
/* ==========================================================================
11. GAME CONTROLLER
========================================================================== */
const G = {
st: null,
history: [], // [{ move, san, undo, captured }]
startFen: START_FEN,
playerColor: 'w', // 'w' | 'b' | 'both'
depth: CONFIG.AI_DEFAULT,
flipped: false,
thinking: false,
epoch: 0, // bumped on new game / undo — stale AI results are dropped
selected: -1,
targets: new Map(), // to-square -> [legal moves]
status: null,
bannerDismissed: false
};
const GLYPH = { k:'\u265A', q:'\u265B', r:'\u265C', b:'\u265D', n:'\u265E', p:'\u265F' };
const PROMO_ORDER = [QUEEN, ROOK, BISHOP, KNIGHT];
/* ---------- DOM refs ---------- */
const el = {
board: document.getElementById('board'),
ranks: document.getElementById('ranks'),
files: document.getElementById('files'),
moves: document.getElementById('moves'),
trayW: document.getElementById('trayW'),
trayB: document.getElementById('trayB'),
scoreW: document.getElementById('scoreW'),
scoreB: document.getElementById('scoreB'),
turnDisc: document.getElementById('turnDisc'),
turnTxt: document.getElementById('turnTxt'),
promo: document.getElementById('promo'),
banner: document.getElementById('banner'),
bannerRes: document.getElementById('bannerRes'),
bannerWhy: document.getElementById('bannerWhy'),
plyCount: document.getElementById('plyCount'),
btnNew: document.getElementById('btnNew'),
btnUndo: document.getElementById('btnUndo'),
btnFlip: document.getElementById('btnFlip'),
btnMute: document.getElementById('btnMute'),
segColor: document.getElementById('segColor'),
segDepth: document.getElementById('segDepth')
};
const sqEls = new Map(); // 0x88 index -> element
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const isLight = (sq) => ((fileOf(sq) + rankOf(sq)) & 1) === 1;
/** display order 0..63 for a square (0 = top-left when not flipped) */
const displayOrder = (sq) => (7 - rankOf(sq)) * 8 + fileOf(sq);
/* ---------------- new game / setup ---------------- */
function newGame(opts) {
opts = opts || {};
G.epoch++;
G.thinking = false;
G.history = [];
G.startFen = opts.fen || START_FEN;
G.st = loadFen(G.startFen);
G.selected = -1;
G.targets = new Map();
G.bannerDismissed = false;
if (opts.playerColor) G.playerColor = opts.playerColor;
G.status = gameStatus(G.st);
hidePromo(); hideBanner();
renderAll();
Store.save(G);
maybeAI();
}
/** Rebuilds a game from a saved record (start FEN + UCI move list). */
function restoreGame(data) {
G.epoch++;
G.thinking = false;
G.startFen = data.startFen;
G.st = loadFen(data.startFen);
G.history = [];
G.selected = -1;
G.targets = new Map();
G.bannerDismissed = false;
G.playerColor = (data.playerColor === 'b' || data.playerColor === 'both') ? data.playerColor : 'w';
G.depth = CONFIG.AI_DEPTHS.includes(+data.depth) ? +data.depth : CONFIG.AI_DEFAULT;
G.flipped = !!data.flipped;
Sfx.muted = !!data.muted;
for (const uci of (data.moves || [])) {
const m = findMoveByUci(G.st, uci);
if (!m) break; // corrupt save — keep what replays cleanly
const legal = legalMoves(G.st);
const san = moveToSan(G.st, m, legal);
const undo = makeMove(G.st, m);
G.history.push({ move: m, san, undo, captured: m.capture });
}
G.status = gameStatus(G.st);
hidePromo(); hideBanner();
renderAll();
if (G.status.over) showBanner();
else maybeAI();
}
/* ---------------- making moves ---------------- */
/** Applies a legal move, updates history/UI/sound/persistence. */
function doMove(m) {
const legal = legalMoves(G.st);
const san = moveToSan(G.st, m, legal);
const undo = makeMove(G.st, m);
G.history.push({ move: m, san, undo, captured: m.capture });
G.selected = -1;
G.targets = new Map();
hidePromo();
/* sound */
if (m.flags & FLAGS.EP_CAPTURE) Sfx.capture();
else if (m.flags & FLAGS.CAPTURE) Sfx.capture();
else if (m.flags & (FLAGS.KSIDE_CASTLE | FLAGS.QSIDE_CASTLE)) Sfx.castle();
else if (m.flags & FLAGS.PROMOTION) Sfx.promote();
else Sfx.move();
G.status = gameStatus(G.st);
renderAll();
Store.save(G);
if (G.status.over) {
Sfx.end();
showBanner();
return;
}
if (G.status.check) Sfx.check();
maybeAI();
}
/** Is it the human's turn right now? */
function humanTurn() {
return !G.thinking && !G.status.over &&
(G.playerColor === 'both' || G.st.turn === G.playerColor);
}
/** Starts the AI if it is the engine's move. */
function maybeAI() {
if (G.status.over) return;
if (G.playerColor === 'both') return;
if (G.st.turn === G.playerColor) return;
think();
}
async function think() {
if (G.thinking) return;
G.thinking = true;
const myEpoch = G.epoch;
renderTurn();
await sleep(CONFIG.AI_DELAY);
if (myEpoch !== G.epoch || G.status.over) { G.thinking = false; renderTurn(); return; }
const t0 = performance.now();
const res = await searchBestMove(G.st, G.depth, CONFIG.AI_TIME_BUDGET);
const elapsed = Math.round(performance.now() - t0);
G.thinking = false;
if (myEpoch !== G.epoch || !res) { renderTurn(); return; }
if (G.status.over) { renderTurn(); return; }
/* paranoia: never play an illegal move */
const legalNow = legalMoves(G.st);
if (!legalNow.some(m => m.from === res.move.from && m.to === res.move.to &&
m.promotion === res.move.promotion)) {
doMove(legalNow[0]);
return;
}
G.lastThink = { depth: res.depth, score: res.score, nodes: res.nodes, ms: elapsed };
doMove(res.move);
}
/* ---------------- undo ---------------- */
function undoMove() {
if (G.thinking || !G.history.length) return;
G.epoch++;
const n = (G.playerColor === 'both' || G.history.length < 2) ? 1 : 2;
for (let i = 0; i < n && G.history.length; i++) {
const h = G.history.pop();
unmakeMove(G.st, h.undo);
}
G.selected = -1;
G.targets = new Map();
G.bannerDismissed = false;
hidePromo(); hideBanner();
G.status = gameStatus(G.st);
renderAll();
Store.save(G);
}
/* ---------------- selection / move attempt ---------------- */
function selectSquare(sq) {
const piece = G.st.board[sq];
if (!piece || colorOf(piece) !== G.st.turn) { clearSelection(); return; }
const moves = legalMoves(G.st, { square: sq });
if (!moves.length) { clearSelection(); return; }
G.selected = sq;
G.targets = new Map();
for (const m of moves) {
if (!G.targets.has(m.to)) G.targets.set(m.to, []);
G.targets.get(m.to).push(m);
}
renderBoard();
}
function clearSelection() {
if (G.selected === -1 && G.targets.size === 0) return;
G.selected = -1; G.targets = new Map(); renderBoard();
}
/** Human tried to go from → to. Returns true if something happened. */
function attemptMove(from, to) {
const list = G.targets.get(to);
if (!list) return false;
if (list.length > 1) { showPromo(from, to, list); return true; }
doMove(list[0]);
return true;
}
function onSquareClick(sq) {
if (!humanTurn()) return;
if (G.selected !== -1 && G.targets.has(sq)) { attemptMove(G.selected, sq); return; }
if (G.selected === sq) { clearSelection(); return; }
selectSquare(sq);
}
/* ==========================================================================
12. RENDERING
========================================================================== */
function buildBoard() {
el.board.innerHTML = '';
sqEls.clear();
for (let r = 0; r < 8; r++) {
for (let f = 0; f < 8; f++) {
const sq = r * 16 + f;
const d = document.createElement('div');
d.className = 'sq ' + (isLight(sq) ? 'light' : 'dark');
d.dataset.sq = String(sq);
sqEls.set(sq, d);
el.board.appendChild(d);
}
}
applyFlip();
}
function applyFlip() {
for (const [sq, d] of sqEls) {
const o = displayOrder(sq);
d.style.order = String(G.flipped ? (63 - o) : o);
}
el.ranks.innerHTML = ''; el.files.innerHTML = '';
const ranks = G.flipped ? RANKS.slice() : RANKS.slice().reverse();
const files = G.flipped ? FILES.slice().reverse() : FILES.slice();
for (const x of ranks) { const d = document.createElement('div'); d.textContent = x; el.ranks.appendChild(d); }
for (const x of files) { const d = document.createElement('div'); d.textContent = x; el.files.appendChild(d); }
}
function renderBoard() {
const st = G.st, stt = G.status || gameStatus(st);
const last = G.history.length ? G.history[G.history.length - 1].move : null;
const checkSq = stt.check ? st.kings[st.turn] : -1;
const canPick = humanTurn();
for (const [sq, d] of sqEls) {
let cls = 'sq ' + (isLight(sq) ? 'light' : 'dark');
if (last && (sq === last.from || sq === last.to)) cls += ' last';
if (sq === G.selected) cls += ' sel';
const tgt = G.targets.get(sq);
if (tgt) {
cls += ' hint';
if (tgt[0].flags & FLAGS.CAPTURE) cls += ' cap';
} else if (canPick) {
const p = st.board[sq];
if (p && colorOf(p) === st.turn) cls += ' movable';
}
if (sq === checkSq) cls += ' check';
d.className = cls;
const p = st.board[sq];
d.innerHTML = p ? '<span class="piece ' + colorOf(p) + '">' + GLYPH[typeOf(p)] + '</span>' : '';
}
}
function renderTurn() {
const t = G.st.turn;
const stt = G.status || gameStatus(G.st);
el.turnDisc.className = 'disc ' + (t === 'w' ? '' : 'b');
let sub;
if (stt.over) sub = stt.reason;
else if (G.thinking) sub = 'engine thinking…';
else if (G.playerColor === 'both') sub = 'analysis mode — you play both sides';
else sub = (t === G.playerColor) ? 'your move' : 'engine to move';
const name = t === 'w' ? 'White' : 'Black';
el.turnTxt.innerHTML = name + ' to move<small' +
(G.thinking ? ' class="think"' : '') + '>' + sub + '</small>';
el.btnUndo.disabled = G.thinking || G.history.length === 0;
}
function renderMoves() {
const h = G.history;
if (!h.length) {
el.moves.innerHTML = '<div class="empty">No moves yet.</div>';
el.plyCount.textContent = '';
return;
}
let html = '<table>';
for (let i = 0; i < h.length; i += 2) {
const curW = (i === h.length - 1) ? ' class="cur"' : '';
const curB = (i + 1 === h.length - 1) ? ' class="cur"' : '';
html += '<tr><td class="n">' + (i / 2 + 1) + '.</td>' +
'<td class="m"><span' + curW + '>' + h[i].san + '</span></td>' +
'<td class="m">' + (h[i + 1] ? '<span' + curB + '>' + h[i + 1].san + '</span>' : '') + '</td></tr>';
}
el.moves.innerHTML = html + '</table>';
el.moves.scrollTop = el.moves.scrollHeight;
el.plyCount.textContent = h.length + (h.length === 1 ? ' ply' : ' plies');
}
function renderTrays() {
const lost = { w: [], b: [] };
for (const h of G.history) if (h.captured) lost[colorOf(h.captured)].push(h.captured);
const val = (a) => a.reduce((s, p) => s + CONFIG.MATERIAL[typeOf(p)], 0);
el.trayW.innerHTML = lost.w.map(p => '<span class="p w">' + GLYPH[typeOf(p)] + '</span>').join('');
el.trayB.innerHTML = lost.b.map(p => '<span class="p b">' + GLYPH[typeOf(p)] + '</span>').join('');
const diff = val(lost.b) - val(lost.w);
el.scoreW.textContent = diff > 0 ? '+' + diff : '';
el.scoreB.textContent = diff < 0 ? '+' + (-diff) : '';
}
function renderAll() {
renderBoard(); renderTurn(); renderMoves(); renderTrays();
}
/* ==========================================================================
13. OVERLAYS — promotion picker & game-over banner
========================================================================== */
function hidePromo() { el.promo.classList.add('hidden'); }
function showPromo(from, to, list) {
const white = colorOf(G.st.board[from]) === 'w';
el.promo.innerHTML = '';
el.promo.className = 'vert';
const names = { q:'Queen', r:'Rook', b:'Bishop', n:'Knight' };
for (const p of PROMO_ORDER) {
const m = list.find(mm => mm.promotion === p);
if (!m) continue;
const b = document.createElement('button');
b.innerHTML = '<span class="' + (white ? '' : 'pb') + '">' + GLYPH[p] + '</span>';
b.title = names[p];
b.addEventListener('click', (ev) => {
ev.stopPropagation();
hidePromo();
doMove(m);
});
el.promo.appendChild(b);
}
el.promo.classList.remove('hidden');
const r = sqEls.get(to).getBoundingClientRect();
const pw = el.promo.offsetWidth, ph = el.promo.offsetHeight;
let left = r.left + r.width / 2 - pw / 2;
let top = white ? (r.top - ph - 4) : (r.bottom + 4);
left = Math.max(8, Math.min(window.innerWidth - pw - 8, left));
top = Math.max(8, Math.min(window.innerHeight - ph - 8, top));
el.promo.style.left = left + 'px';
el.promo.style.top = top + 'px';
}
function hideBanner() { el.banner.classList.add('hidden'); }
function showBanner() {
const s = G.status;
let res, why;
if (s.result === 'checkmate') {
const w = s.winner === 'w' ? 'White' : 'Black';
res = w + ' wins';
why = 'Checkmate';
if (G.playerColor !== 'both') {
why += (s.winner === G.playerColor) ? ' — you beat the engine.' : ' — the engine beat you.';
}
} else if (s.result === 'stalemate') {
res = 'Draw'; why = 'Stalemate — ' + (G.st.turn === 'w' ? 'White' : 'Black') +
' has no legal move but is not in check.';
} else {
res = 'Draw'; why = s.reason || 'Draw';
}
el.bannerRes.textContent = res;
el.bannerWhy.textContent = why;
el.banner.classList.remove('hidden');
}
/* ==========================================================================
14. INTERACTION — click-to-move + drag & drop
========================================================================== */
let drag = null; // { sq, x0, y0, active, ghost }
function squareFromPoint(x, y) {
const node = document.elementFromPoint(x, y);
const d = node && node.closest ? node.closest('.sq') : null;
return d ? +d.dataset.sq : -1;
}
el.board.addEventListener('pointerdown', (e) => {
if (!humanTurn()) return;
const d = e.target.closest ? e.target.closest('.sq') : null;
if (!d) return;
const sq = +d.dataset.sq;
/* a click on an already-offered target square is handled on pointerup */
if (G.selected !== -1 && G.targets.has(sq)) { drag = { sq, x0:e.clientX, y0:e.clientY, active:false, ghost:null, noDrag:true }; return; }
const p = G.st.board[sq];
if (!p || colorOf(p) !== G.st.turn) { drag = { sq, x0:e.clientX, y0:e.clientY, active:false, ghost:null, noDrag:true }; return; }
drag = { sq, x0:e.clientX, y0:e.clientY, active:false, ghost:null, noDrag:false };
});
window.addEventListener('pointermove', (e) => {
if (!drag || drag.active || drag.noDrag) return;
if (Math.abs(e.clientX - drag.x0) + Math.abs(e.clientY - drag.y0) < 6) return;
if (!humanTurn()) { drag = null; return; }
drag.active = true;
selectSquare(drag.sq); // shows the legal-destination dots
const src = sqEls.get(drag.sq);
if (src) src.classList.add('drag-src');
const p = G.st.board[drag.sq];
const g = document.createElement('span');
g.className = 'piece ghost ' + colorOf(p);
g.textContent = GLYPH[typeOf(p)];
g.style.left = e.clientX + 'px';
g.style.top = e.clientY + 'px';
document.body.appendChild(g);
drag.ghost = g;
});
window.addEventListener('pointermove', (e) => {
if (drag && drag.ghost) { drag.ghost.style.left = e.clientX + 'px'; drag.ghost.style.top = e.clientY + 'px'; }
});
window.addEventListener('pointerup', (e) => {
if (!drag) return;
const d = drag; drag = null;
if (d.ghost) d.ghost.remove();
const src = sqEls.get(d.sq);
if (src) src.classList.remove('drag-src');
if (!d.active) { onSquareClick(d.sq); return; }
const to = squareFromPoint(e.clientX, e.clientY);
if (to === d.sq) { onSquareClick(d.sq); return; } // tap = select
if (to >= 0 && G.targets.has(to)) { attemptMove(d.sq, to); return; }
clearSelection();
});
window.addEventListener('pointercancel', () => {
if (!drag) return;
if (drag.ghost) drag.ghost.remove();
const src = sqEls.get(drag.sq);
if (src) src.classList.remove('drag-src');
drag = null;
});
/* ==========================================================================
15. CONTROLS
========================================================================== */
function syncControls() {
for (const b of el.segColor.querySelectorAll('button'))
b.classList.toggle('on', b.dataset.v === G.playerColor);
for (const b of el.segDepth.querySelectorAll('button'))
b.classList.toggle('on', +b.dataset.v === G.depth);
el.btnMute.textContent = 'Sound: ' + (Sfx.muted ? 'off' : 'on');
}
el.segColor.addEventListener('click', (e) => {
const b = e.target.closest('button'); if (!b) return;
G.playerColor = b.dataset.v;
G.epoch++; // drop any in-flight engine result
syncControls(); Store.save(G);
clearSelection(); renderAll();
maybeAI();
});
el.segDepth.addEventListener('click', (e) => {
const b = e.target.closest('button'); if (!b) return;
G.depth = +b.dataset.v;
syncControls(); Store.save(G);
});
el.btnNew.addEventListener('click', () => newGame());
el.btnUndo.addEventListener('click', () => undoMove());
el.btnFlip.addEventListener('click', () => { G.flipped = !G.flipped; applyFlip(); Store.save(G); });
el.btnMute.addEventListener('click', () => { Sfx.muted = !Sfx.muted; syncControls(); Store.save(G); });
document.getElementById('bannerNew').addEventListener('click', () => newGame());
document.getElementById('bannerClose').addEventListener('click', () => {
G.bannerDismissed = true; hideBanner();
});
window.addEventListener('keydown', (e) => {
if (e.target && /input|textarea/i.test(e.target.tagName)) return;
const k = e.key.toLowerCase();
if (k === 'u' || k === 'arrowleft') { undoMove(); }
else if (k === 'f') { G.flipped = !G.flipped; applyFlip(); Store.save(G); }
else if (k === 'n') { newGame(); }
else if (k === 'm') { Sfx.muted = !Sfx.muted; syncControls(); Store.save(G); }
else if (k === 'escape') { clearSelection(); hidePromo(); }
});
/* ==========================================================================
16. PERFT (debug helper — counts leaf nodes of the legal-move tree)
========================================================================== */
function perft(st, depth) {
if (depth <= 0) return 1;
let nodes = 0;
const moves = legalMoves(st);
if (depth === 1) return moves.length;
for (const m of moves) {
const u = makeMove(st, m);
nodes += perft(st, depth - 1);
unmakeMove(st, u);
}
return nodes;
}
/* ==========================================================================
17. DEBUG INTERFACE (window.game) — probe the engine headlessly
========================================================================== */
window.game = {
/* --- position --- */
fen: () => toFen(G.st),
setFen: (fen, color) => { newGame({ fen, playerColor: color || 'both' }); return toFen(G.st); },
ascii: () => {
let out = '';
for (let r = 7; r >= 0; r--) {
out += (r + 1) + ' ';
for (let f = 0; f < 8; f++) { const p = G.st.board[r * 16 + f]; out += (p ? p : '.') + ' '; }
out += '\n';
}
return out + ' a b c d e f g h';
},
/* --- moves --- */
legalMoves: (square) => legalMoves(G.st, {
square: square === undefined ? null : (typeof square === 'string' ? SQUARES[square] : square)
}).map(moveToUci),
legalMoveObjects: (square) => legalMoves(G.st, {
square: square === undefined ? null : (typeof square === 'string' ? SQUARES[square] : square)
}),
/** Engine-level move: applies it, does NOT trigger the AI. Returns SAN or null. */
move: (uci) => {
const m = findMoveByUci(G.st, uci);
if (!m) return null;
const san = moveToSan(G.st, m, legalMoves(G.st));
const undo = makeMove(G.st, m);
G.history.push({ move: m, san, undo, captured: m.capture });
G.selected = -1; G.targets = new Map();
G.status = gameStatus(G.st);
hidePromo();
renderAll(); Store.save(G);
if (G.status.over) showBanner();
return san;
},
/** UI-level move: applies it and lets the AI reply. */
play: (uci) => {
const m = findMoveByUci(G.st, uci);
if (!m) return false;
doMove(m); return true;
},
undo: () => { undoMove(); return G.history.length; },
newGame: (color) => { newGame({ playerColor: color || G.playerColor }); return toFen(G.st); },
history: () => G.history.map(h => h.san),
status: () => ({
turn: G.st.turn, check: !!G.status.check, over: !!G.status.over,
result: G.status.result, reason: G.status.reason,
ep: G.st.ep >= 0 ? ALGEBRAIC[G.st.ep] : '-',
castling: toFen(G.st).split(' ')[2],
thinking: G.thinking
}),
evalScore: () => evaluate(G.st),
perft: (d) => perft(G.st, d || 1),
setDepth: (d) => { G.depth = d; syncControls(); Store.save(G); },
setColor: (c) => { G.playerColor = c; syncControls(); Store.save(G); },
G,
/* raw engine, usable without touching the UI state */
engine: { loadFen, toFen, generateMoves, legalMoves, makeMove, unmakeMove,
isAttacked, inCheck, gameStatus, evaluate, searchBestMove, perft,
moveToUci, moveToSan, findMoveByUci, SQUARES, ALGEBRAIC, FLAGS }
};
/* ==========================================================================
18. BOOT
========================================================================== */
buildBoard();
const saved = Store.load();
if (saved) restoreGame(saved);
else newGame();
syncControls();
</script>
</body>
</html>