Chess
Deepseek v4.1 Flash · 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 55,118 bytes · SHA-256 566b5d5a6f69
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chess — Play vs Computer</title>
<style>
/* ============================================================
THEME / LAYOUT
============================================================ */
:root{
--light:#f0d9b5;
--dark:#b58863;
--frame:#3a2a1c;
--frame2:#5a422c;
--bg:#15161a;
--bg2:#1d1f25;
--panel:#22242b;
--panel2:#2a2d35;
--line:#383b45;
--text:#eae4d8;
--dim:#9a958a;
--accent:#c9a15c;
--sel:#f6e27a;
--last:#e4cf4d;
--ok:#7fbf6a;
--bad:#e06a5f;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;background:
radial-gradient(1200px 700px at 50% -10%, #2b2f38 0%, transparent 60%),
linear-gradient(180deg,#101116,#171922 70%,#101116);
color:var(--text);
font-family:"Segoe UI",Inter,system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif;
min-height:100vh;
}
.wrap{max-width:1180px;margin:0 auto;padding:18px 18px 40px}
.topbar{display:flex;align-items:baseline;gap:16px;flex-wrap:wrap;margin:2px 0 14px}
.topbar h1{
font-size:20px;margin:0;letter-spacing:.14em;text-transform:uppercase;
font-weight:600;color:var(--text)
}
.topbar h1 span{color:var(--accent)}
#turnInd{
margin-left:auto;font-size:13px;letter-spacing:.06em;text-transform:uppercase;
color:var(--dim);display:flex;align-items:center;gap:9px;
}
#turnInd b{color:var(--text);font-weight:600}
.dot{width:11px;height:11px;border-radius:50%;background:#f5f0e6;box-shadow:0 0 0 1px #000 inset,0 0 10px rgba(255,255,255,.25)}
.dot.b{background:#22201d;box-shadow:0 0 0 1px #6b6459 inset,0 0 10px rgba(0,0,0,.5)}
.dot.think{animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
main{display:flex;gap:22px;align-items:flex-start}
.boardCol{flex:0 0 auto;display:flex;flex-direction:column;align-items:center;gap:10px}
.boardFrame{
position:relative;padding:12px;border-radius:10px;
background:linear-gradient(160deg,var(--frame2),var(--frame) 60%,#241a11);
box-shadow:0 18px 40px rgba(0,0,0,.55), 0 0 0 1px rgba(255,255,255,.05) inset,
0 2px 0 rgba(255,255,255,.06) inset;
}
.board{
display:grid;grid-template-columns:repeat(8,1fr);grid-template-rows:repeat(8,1fr);
width:min(72vmin,600px);aspect-ratio:1;border-radius:3px;overflow:hidden;
user-select:none;-webkit-user-select:none;touch-action:none;position:relative;
box-shadow:0 0 0 1px rgba(0,0,0,.5) inset;
}
.sq{position:relative;display:flex;align-items:center;justify-content:center;line-height:1}
.sq.light{background:var(--light)}
.sq.dark{background:var(--dark)}
.sq.last::after{content:"";position:absolute;inset:0;background:var(--last);opacity:.42}
.sq.sel::after{content:"";position:absolute;inset:0;background:var(--sel);opacity:.55}
.sq.over::after{content:"";position:absolute;inset:0;box-shadow:0 0 0 3px var(--sel) inset;opacity:.9}
.sq.check::after{
content:"";position:absolute;inset:0;
background:radial-gradient(circle at 50% 50%, rgba(255,60,50,.85) 0%, rgba(220,30,20,.45) 45%, rgba(200,0,0,0) 72%)
}
.sq .hint{
position:absolute;width:30%;height:30%;border-radius:50%;
background:rgba(24,20,14,.34);pointer-events:none
}
.sq .hint.cap{
width:88%;height:88%;border-radius:50%;background:transparent;
box-shadow:0 0 0 .34em rgba(24,20,14,.30) inset;opacity:.95
}
.sq .coord{
position:absolute;font-size:.2em;font-weight:700;letter-spacing:.02em;
pointer-events:none;user-select:none
}
.sq .coord.f{right:.22em;bottom:.12em}
.sq .coord.r{left:.22em;top:.06em}
.sq.light .coord{color:rgba(120,85,50,.75)}
.sq.dark .coord{color:rgba(250,235,215,.8)}
.pc{
font-size:1em;line-height:1;pointer-events:none;position:relative;z-index:2;
font-family:"Segoe UI Symbol","Noto Sans Symbols 2","Apple Symbols","DejaVu Sans",serif;
transition:transform .07s ease;
}
.pc.w{color:#fdfbf5;-webkit-text-stroke:1.6px #2c2419;text-shadow:0 2px 2px rgba(0,0,0,.35)}
.pc.b{color:#292523;-webkit-text-stroke:1.4px #0e0c0b;text-shadow:0 2px 2px rgba(0,0,0,.28)}
.sq.sel .pc{transform:translateY(-2px)}
/* dragged ghost */
#ghost{
position:fixed;z-index:60;pointer-events:none;transform:translate(-50%,-50%);
filter:drop-shadow(0 8px 10px rgba(0,0,0,.5));opacity:.95
}
/* promotion overlay */
.promo{
position:absolute;z-index:40;display:grid;grid-template-columns:repeat(2,1fr);
gap:4px;padding:6px;border-radius:8px;
background:linear-gradient(180deg,#fdf8ee,#e8dcc6);
box-shadow:0 12px 28px rgba(0,0,0,.55),0 0 0 1px rgba(0,0,0,.35);
}
.promo button{
width:2.55rem;height:2.55rem;border:0;border-radius:6px;cursor:pointer;
background:#f4ecdc;display:flex;align-items:center;justify-content:center;
font-size:1.7rem;line-height:1;padding:0;
font-family:"Segoe UI Symbol","Noto Sans Symbols 2","DejaVu Sans",serif;
}
.promo button:hover{background:var(--sel)}
.promo button .pc{font-size:1.6rem}
/* banner */
.banner{
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) scale(1);
z-index:30;min-width:60%;text-align:center;padding:16px 22px;border-radius:12px;
background:rgba(18,16,14,.9);backdrop-filter:blur(3px);
box-shadow:0 16px 40px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.1) inset;
animation:pop .25s ease-out
}
@keyframes pop{from{transform:translate(-50%,-50%) scale(.85);opacity:0}to{transform:translate(-50%,-50%) scale(1);opacity:1}}
.banner .big{font-size:19px;font-weight:700;letter-spacing:.05em;margin-bottom:4px}
.banner .sub{font-size:12.5px;color:var(--dim);letter-spacing:.05em}
.banner button{margin-top:12px}
.hidden{display:none!important}
/* panel */
.panel{
flex:1 1 300px;min-width:270px;max-width:420px;display:flex;flex-direction:column;gap:12px
}
.group{
background:linear-gradient(180deg,var(--panel),var(--panel2));
border:1px solid var(--line);border-radius:10px;padding:11px 12px;
box-shadow:0 8px 20px rgba(0,0,0,.3)
}
.group h2{
margin:0 0 8px;font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;
color:var(--dim);font-weight:700
}
.row{display:flex;align-items:center;gap:10px;margin:6px 0}
.row .lbl{font-size:12px;color:var(--dim);flex:0 0 78px}
select{
flex:1;background:#15171c;color:var(--text);border:1px solid var(--line);
border-radius:7px;padding:6px 8px;font-size:13px;font-family:inherit;cursor:pointer
}
button{
background:linear-gradient(180deg,#33363f,#282b33);color:var(--text);
border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:12.5px;
font-family:inherit;font-weight:600;letter-spacing:.03em;cursor:pointer;
transition:background .12s,transform .06s
}
button:hover{background:linear-gradient(180deg,#3d414c,#313540)}
button:active{transform:translateY(1px)}
button:disabled{opacity:.42;cursor:default}
.controls{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.controls button{width:100%}
.trays{display:flex;flex-direction:column;gap:7px}
.trayRow{display:flex;align-items:center;gap:8px}
.trayRow .side{font-size:10.5px;color:var(--dim);letter-spacing:.08em;text-transform:uppercase;flex:0 0 52px}
.tray{
flex:1;min-height:30px;display:flex;flex-wrap:wrap;align-items:center;gap:0;
font-size:20px;line-height:1;letter-spacing:-2px;
background:rgba(0,0,0,.22);border-radius:6px;padding:3px 6px;border:1px solid rgba(255,255,255,.04)
}
.tray .pc{font-size:19px}
.tray .adv{margin-left:auto;font-size:11px;letter-spacing:0;color:var(--ok);font-weight:700;align-self:center}
.movesList{
margin:0;padding:0;list-style:none;max-height:230px;overflow-y:auto;
font-variant-numeric:tabular-nums;font-size:13.5px;scrollbar-width:thin
}
.movesList::-webkit-scrollbar{width:8px}
.movesList::-webkit-scrollbar-thumb{background:#3b3f4a;border-radius:8px}
.mvrow{display:grid;grid-template-columns:34px 1fr 1fr;gap:4px;padding:2.5px 4px;border-radius:5px}
.mvrow:nth-child(odd){background:rgba(255,255,255,.028)}
.mvrow .num{color:var(--dim);font-size:12px}
.mvrow .mv{font-weight:600}
.mvrow .mv.cur{color:var(--accent)}
.movesEmpty{color:var(--dim);font-size:12.5px;font-style:italic}
.statusline{font-size:12.5px;color:var(--dim);letter-spacing:.04em;min-height:17px;text-align:center}
.statusline b{color:var(--text)}
.footnote{font-size:11px;color:#6f6b63;text-align:center;margin-top:6px;letter-spacing:.03em}
@media (max-width:900px){
main{flex-direction:column;align-items:stretch}
.boardCol{align-items:center}
.panel{max-width:none}
.board{width:min(92vmin,600px)}
}
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<h1>Chess<span>·</span>Engine</h1>
<div id="turnInd"><span class="dot" id="turnDot"></span><span id="turnTxt">White to move</span></div>
</div>
<main>
<section class="boardCol">
<div class="boardFrame" id="boardFrame">
<div class="board" id="board"></div>
<div class="promo hidden" id="promo"></div>
<div class="banner hidden" id="banner"></div>
</div>
<div class="statusline" id="statusLine"></div>
</section>
<aside class="panel">
<div class="group">
<h2>Game</h2>
<div class="row"><span class="lbl">You play</span>
<select id="sideSel">
<option value="w">White</option>
<option value="b">Black</option>
<option value="both">Both (analysis)</option>
</select>
</div>
<div class="row"><span class="lbl">Strength</span>
<select id="depthSel">
<option value="1">Easy — depth 1</option>
<option value="2" selected>Medium — depth 2</option>
<option value="3">Hard — depth 3</option>
</select>
</div>
</div>
<div class="group">
<h2>Captured</h2>
<div class="trays">
<div class="trayRow"><span class="side">White</span><div class="tray" id="trayW"></div></div>
<div class="trayRow"><span class="side">Black</span><div class="tray" id="trayB"></div></div>
</div>
</div>
<div class="group">
<h2>Moves</h2>
<ol class="movesList" id="movesList"></ol>
</div>
<div class="group">
<h2>Controls</h2>
<div class="controls">
<button id="newBtn">New Game</button>
<button id="undoBtn">Undo</button>
<button id="flipBtn">Flip Board</button>
<button id="muteBtn">🔊 Sound</button>
</div>
</div>
</aside>
</main>
<div class="footnote">Click or drag to move · Debug: <code>game.fen()</code>, <code>game.move('e2e4')</code>, <code>game.legalMoves()</code></div>
</div>
<script>
/* ============================================================================
CHESS ENGINE — vanilla JS, no dependencies
Sections:
1. Tunables / constants
2. Board representation + utilities
3. Move generation (pseudo-legal) + attack detection
4. Making moves (immutable clone)
5. Legal move generator (king-safety filtered)
6. Game status (check / mate / stalemate / draws)
7. Algebraic notation (SAN)
8. Evaluation
9. Search (negamax + alpha-beta + quiescence, time-sliced)
10. Audio
11. Rendering / UI
12. Persistence (localStorage)
13. Debug interface
14. Boot
Board layout: index 0 = a8, 7 = h8, 56 = a1, 63 = h1.
Pieces: single chars, uppercase = white, lowercase = black.
============================================================================ */
/* ============================ 1. TUNABLES ================================= */
const CFG = {
// colours
COLOR_LIGHT: '#f0d9b5',
COLOR_DARK: '#b58863',
// AI
AI_TIME_MS: 1400, // soft time cap per move (iterative deepening aborts past this)
AI_MAX_QUIESCE: 4, // quiescence-lite depth
DEPTHS: { 1:1, 2:2, 3:3 },
// evaluation weights
W_MATERIAL: { P:100, N:320, B:330, R:500, Q:900, K:0 },
W_BISHOP_PAIR: 28,
W_TEMPO: 8,
W_PAWN_STRUCT: { DOUBLED:-12, ISOLATED:-14, PASSED:0 },
W_MOBILITY: 0 // reserved (kept 0 for speed)
};
const MATE = 100000, INF = 1e9;
const FILES = 'abcdefgh';
/* ==================== 2. BOARD REP + UTILITIES ============================ */
const P_VAL = CFG.W_MATERIAL;
function colorOf(p){ return p ? (p === p.toUpperCase() ? 'w' : 'b') : null; }
function typeOf(p){ return p ? p.toUpperCase() : null; }
function opp(c){ return c === 'w' ? 'b' : 'w'; }
function sqName(sq){ return FILES[sq & 7] + (8 - (sq >> 3)); }
function nameSq(s){
const f = FILES.indexOf(s[0]), r = 8 - parseInt(s[1], 10);
if (f < 0 || r < 0 || r > 7) return -1;
return r * 8 + f;
}
const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
function parseFen(fen){
const parts = fen.trim().split(/\s+/);
const board = new Array(64).fill(null);
let sq = 0;
for (const ch of parts[0]) {
if (ch === '/') continue;
if (ch >= '1' && ch <= '8') sq += +ch;
else board[sq++] = ch;
}
return {
board,
turn: parts[1] || 'w',
castling: parts[2] === '-' ? '' : (parts[2] || ''),
ep: (!parts[3] || parts[3] === '-') ? -1 : nameSq(parts[3]),
half: parts[4] ? +parts[4] : 0,
full: parts[5] ? +parts[5] : 1
};
}
function toFen(st){
let out = '', empty = 0;
for (let sq = 0; sq < 64; sq++){
const p = st.board[sq];
if (!p) { empty++; }
else {
if (empty) { out += empty; empty = 0; }
out += p;
}
if ((sq & 7) === 7){ if (empty){ out += empty; empty = 0; } if (sq !== 63) out += '/'; }
}
return out + ' ' + st.turn + ' ' + (st.castling || '-') + ' ' +
(st.ep >= 0 ? sqName(st.ep) : '-') + ' ' + st.half + ' ' + st.full;
}
function cloneState(st){
return { board: st.board.slice(), turn: st.turn, castling: st.castling,
ep: st.ep, half: st.half, full: st.full };
}
function findKing(board, color){
const k = color === 'w' ? 'K' : 'k';
for (let i = 0; i < 64; i++) if (board[i] === k) return i;
return -1;
}
/* ================= 3. MOVE GENERATION + ATTACKS =========================== */
const N_DIRS = [[1,2],[2,1],[2,-1],[1,-2],[-1,-2],[-2,-1],[-2,1],[-1,2]];
const B_DIRS = [[1,1],[1,-1],[-1,1],[-1,-1]];
const R_DIRS = [[1,0],[-1,0],[0,1],[0,-1]];
const K_DIRS = B_DIRS.concat(R_DIRS);
function mk(from, to, piece, capture, extra){
return Object.assign({ from, to, piece, capture: capture || null, promo: null, flag: null }, extra || {});
}
/** Pseudo-legal moves for side to move. Captures are flagged. */
function genPseudo(st, capturesOnly){
const b = st.board, us = st.turn, them = opp(us);
const out = [];
for (let sq = 0; sq < 64; sq++){
const p = b[sq];
if (!p || colorOf(p) !== us) continue;
const t = typeOf(p), f = sq & 7, r = sq >> 3;
if (t === 'P'){
const dir = us === 'w' ? -1 : 1;
const startR = us === 'w' ? 6 : 1;
const promoR = us === 'w' ? 0 : 7;
const one = sq + dir * 8;
// pushes
if (!capturesOnly && one >= 0 && one < 64 && !b[one]){
if ((one >> 3) === promoR){
for (const q of 'QRBN') out.push(mk(sq, one, p, null, { promo: q }));
} else {
out.push(mk(sq, one, p));
const two = sq + dir * 16;
if (r === startR && !b[two]) out.push(mk(sq, two, p, null, { flag:'double' }));
}
}
// captures
for (const df of [-1, 1]){
const nf = f + df, nr = r + dir;
if (nf < 0 || nf > 7 || nr < 0 || nr > 7) continue;
const to = nr * 8 + nf;
const tp = b[to];
if (tp && colorOf(tp) === them){
if (nr === promoR){ for (const q of 'QRBN') out.push(mk(sq, to, p, tp, { promo:q })); }
else out.push(mk(sq, to, p, tp));
} else if (!tp && to === st.ep && st.ep >= 0){
const victim = to - dir * 8;
if (b[victim] === (us === 'w' ? 'p' : 'P'))
out.push(mk(sq, to, p, b[victim], { flag:'ep' }));
}
}
}
else if (t === 'N' || t === 'K'){
const dirs = t === 'N' ? N_DIRS : K_DIRS;
for (const [df, dr] of dirs){
const nf = f + df, nr = r + dr;
if (nf < 0 || nf > 7 || nr < 0 || nr > 7) continue;
const to = nr * 8 + nf, tp = b[to];
if (tp && colorOf(tp) === us) continue;
if (capturesOnly && !tp) continue;
out.push(mk(sq, to, p, tp));
}
if (t === 'K') genCastles(st, sq, us, out);
}
else { // sliders
const dirs = t === 'B' ? B_DIRS : t === 'R' ? R_DIRS : K_DIRS;
for (const [df, dr] of dirs){
let nf = f + df, nr = r + dr;
while (nf >= 0 && nf < 8 && nr >= 0 && nr < 8){
const to = nr * 8 + nf, tp = b[to];
if (!tp){ if (!capturesOnly) out.push(mk(sq, to, p)); }
else {
if (colorOf(tp) === them) out.push(mk(sq, to, p, tp));
break;
}
nf += df; nr += dr;
}
}
}
}
return out;
}
function genCastles(st, ksq, us, out){
const b = st.board;
const enemy = opp(us);
if (isAttacked(st, ksq, enemy)) return;
if (us === 'w'){
if (ksq !== 60) return;
if (st.castling.includes('K') && b[63] === 'R' && !b[61] && !b[62] &&
!isAttacked(st, 61, enemy) && !isAttacked(st, 62, enemy))
out.push(mk(60, 62, 'K', null, { flag:'castle-k' }));
if (st.castling.includes('Q') && b[56] === 'R' && !b[59] && !b[58] && !b[57] &&
!isAttacked(st, 59, enemy) && !isAttacked(st, 58, enemy))
out.push(mk(60, 58, 'K', null, { flag:'castle-q' }));
} else {
if (ksq !== 4) return;
if (st.castling.includes('k') && b[7] === 'r' && !b[5] && !b[6] &&
!isAttacked(st, 5, enemy) && !isAttacked(st, 6, enemy))
out.push(mk(4, 6, 'k', null, { flag:'castle-k' }));
if (st.castling.includes('q') && b[0] === 'r' && !b[3] && !b[2] && !b[1] &&
!isAttacked(st, 3, enemy) && !isAttacked(st, 2, enemy))
out.push(mk(4, 2, 'k', null, { flag:'castle-q' }));
}
}
/** Is square `sq` attacked by side `by`? */
function isAttacked(st, sq, by){
const b = st.board;
const f = sq & 7, r = sq >> 3;
// pawns
if (by === 'w'){
if (r + 1 < 8){
if (f > 0 && b[(r+1)*8 + f-1] === 'P') return true;
if (f < 7 && b[(r+1)*8 + f+1] === 'P') return true;
}
} else {
if (r - 1 >= 0){
if (f > 0 && b[(r-1)*8 + f-1] === 'p') return true;
if (f < 7 && b[(r-1)*8 + f+1] === 'p') return true;
}
}
// knights
const kn = by === 'w' ? 'N' : 'n';
for (const [df, dr] of N_DIRS){
const nf = f + df, nr = r + dr;
if (nf < 0 || nf > 7 || nr < 0 || nr > 7) continue;
if (b[nr*8+nf] === kn) return true;
}
// king
const kg = by === 'w' ? 'K' : 'k';
for (const [df, dr] of K_DIRS){
const nf = f + df, nr = r + dr;
if (nf < 0 || nf > 7 || nr < 0 || nr > 7) continue;
if (b[nr*8+nf] === kg) return true;
}
// sliders
for (const [df, dr] of B_DIRS){
let nf = f + df, nr = r + dr;
while (nf >= 0 && nf < 8 && nr >= 0 && nr < 8){
const p = b[nr*8+nf];
if (p){
const t = typeOf(p);
if (colorOf(p) === by && (t === 'B' || t === 'Q')) return true;
break;
}
nf += df; nr += dr;
}
}
for (const [df, dr] of R_DIRS){
let nf = f + df, nr = r + dr;
while (nf >= 0 && nf < 8 && nr >= 0 && nr < 8){
const p = b[nr*8+nf];
if (p){
const t = typeOf(p);
if (colorOf(p) === by && (t === 'R' || t === 'Q')) return true;
break;
}
nf += df; nr += dr;
}
}
return false;
}
/* ======================= 4. MAKING MOVES ================================= */
function makeMove(st, m){
const ns = {
board: st.board.slice(), turn: opp(st.turn), castling: st.castling,
ep: -1, half: st.half + 1, full: st.full + (st.turn === 'b' ? 1 : 0)
};
const b = ns.board, us = st.turn;
const p = b[m.from];
b[m.from] = null;
if (m.flag === 'ep'){
b[m.to + (us === 'w' ? 8 : -8)] = null;
}
if (m.promo){
b[m.to] = us === 'w' ? m.promo : m.promo.toLowerCase();
} else {
b[m.to] = p;
}
if (m.flag === 'castle-k'){
if (us === 'w'){ b[61] = b[63]; b[63] = null; } else { b[5] = b[7]; b[7] = null; }
} else if (m.flag === 'castle-q'){
if (us === 'w'){ b[59] = b[56]; b[56] = null; } else { b[3] = b[0]; b[0] = null; }
}
if (m.flag === 'double') ns.ep = (m.from + m.to) / 2;
// halfmove clock
if (typeOf(p) === 'P' || m.capture) ns.half = 0;
// castling rights
let rights = ns.castling;
if (typeOf(p) === 'K') rights = rights.replace(/[KQ]/g, '');
if (typeOf(p) === 'k') rights = rights.replace(/[kq]/g, '');
const clear = sq => {
if (sq === 63) rights = rights.replace('K','');
if (sq === 56) rights = rights.replace('Q','');
if (sq === 7) rights = rights.replace('k','');
if (sq === 0) rights = rights.replace('q','');
};
clear(m.from); clear(m.to);
ns.castling = rights;
return ns;
}
/* ================= 5. LEGAL MOVE GENERATION ============================== */
function inCheck(st, color){
const k = findKing(st.board, color);
if (k < 0) return false;
return isAttacked(st, k, opp(color));
}
/** All fully-legal moves for the side to move (king-safety filtered). */
function legalMoves(st){
const us = st.turn, out = [];
for (const m of genPseudo(st, false)){
const ns = makeMove(st, m);
if (!inCheck(ns, us)) out.push(m);
}
return out;
}
function legalMovesFrom(st, from){
return legalMoves(st).filter(m => m.from === from);
}
function moveToUci(m){
return sqName(m.from) + sqName(m.to) + (m.promo ? m.promo.toLowerCase() : '');
}
/* ======================= 6. GAME STATUS ================================== */
/**
* Returns { over, result, reason, check }
* result: 'w' | 'b' | 'draw' | null
*/
function gameStatus(st, history){
const moves = legalMoves(st);
const check = inCheck(st, st.turn);
if (moves.length === 0){
if (check) return { over:true, result: opp(st.turn), reason:'checkmate', check:true, moves };
return { over:true, result:'draw', reason:'stalemate', check:false, moves };
}
if (st.half >= 100) return { over:true, result:'draw', reason:'fifty-move rule', check, moves };
if (insufficientMaterial(st.board)) return { over:true, result:'draw', reason:'insufficient material', check, moves };
if (history && isThreefold(history, st)) return { over:true, result:'draw', reason:'threefold repetition', check, moves };
return { over:false, result:null, reason:null, check, moves };
}
function insufficientMaterial(board){
const pieces = [];
for (let i = 0; i < 64; i++) if (board[i]) pieces.push({ p: board[i], sq: i });
if (pieces.length > 4) return false;
const nonKings = pieces.filter(x => typeOf(x.p) !== 'K');
if (nonKings.length === 0) return true; // K vs K
if (nonKings.length === 1){
const t = typeOf(nonKings[0].p);
return t === 'B' || t === 'N'; // K+B vs K, K+N vs K
}
if (nonKings.length === 2){
const a = nonKings[0], b = nonKings[1];
const ta = typeOf(a.p), tb = typeOf(b.p);
// K+B vs K+B with both bishops on the same colour complex
if (ta === 'B' && tb === 'B' && colorOf(a.p) !== colorOf(b.p)){
const ca = ((a.sq >> 3) + (a.sq & 7)) & 1;
const cb = ((b.sq >> 3) + (b.sq & 7)) & 1;
return ca === cb;
}
}
return false;
}
/** Threefold repetition: compare reduced position keys. */
function posKey(st){
return st.board.join('') + '|' + st.turn + '|' + st.castling + '|' + st.ep +
'|' + (st.castling ? '' : ''); // ep included only when relevant — conservative
}
function isThreefold(history, st){
const key = posKey(st);
let n = 1;
for (let i = history.length - 1; i >= 0; i--){
if (posKey(history[i]) === key) n++;
if (n >= 3) return true;
}
return false;
}
/* ==================== 7. ALGEBRAIC NOTATION ============================== */
function toSAN(st, m){
const legal = legalMoves(st);
const t = typeOf(m.piece);
let san;
if (m.flag === 'castle-k') san = 'O-O';
else if (m.flag === 'castle-q') san = 'O-O-O';
else {
const dest = sqName(m.to);
if (t === 'P'){
san = m.capture ? FILES[m.from & 7] + 'x' + dest : dest;
if (m.flag === 'ep') san += ' e.p.';
} else {
// disambiguation
const rivals = legal.filter(o => o.to === m.to && typeOf(o.piece) === t &&
(o.from !== m.from) && o.promo === m.promo);
let dis = '';
if (rivals.length){
const sameFile = rivals.some(o => (o.from & 7) === (m.from & 7));
const sameRank = rivals.some(o => (o.from >> 3) === (m.from >> 3));
if (!sameFile) dis = FILES[m.from & 7];
else if (!sameRank) dis = String(8 - (m.from >> 3));
else dis = sqName(m.from);
}
san = t + dis + (m.capture ? 'x' : '') + dest;
}
if (m.promo) san += '=' + m.promo;
}
const ns = makeMove(st, m);
const status = gameStatus(ns, null);
if (status.check){
if (status.moves.length === 0) san += '#';
else san += '+';
}
return san;
}
/* ======================= 8. EVALUATION =================================== */
/* Piece-square tables (white perspective, index 0 = a8 ... 63 = h1) */
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],
KE: [
-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]
};
/** Static evaluation, from the perspective of the side to move (negamax). */
function evaluate(st){
const b = st.board;
let score = 0, nonPawn = 0;
let wb = 0, bb = 0;
const pawnFilesW = [0,0,0,0,0,0,0,0], pawnFilesB = [0,0,0,0,0,0,0,0];
const pawnsW = [], pawnsB = [];
for (let sq = 0; sq < 64; sq++){
const p = b[sq];
if (!p) continue;
const t = typeOf(p), w = colorOf(p) === 'w';
if (t !== 'P' && t !== 'K') nonPawn += P_VAL[t];
if (t === 'B'){ w ? wb++ : bb++; }
if (t === 'P'){ (w ? pawnsW : pawnsB).push(sq); (w ? pawnFilesW : pawnFilesB)[sq & 7]++; }
}
const endgame = nonPawn < 1500;
for (let sq = 0; sq < 64; sq++){
const p = b[sq];
if (!p) continue;
const t = typeOf(p), w = colorOf(p) === 'w';
const idx = w ? sq : (sq ^ 56);
const table = (t === 'K') ? (endgame ? PST.KE : PST.K) : PST[t];
const v = P_VAL[t] + table[idx];
score += w ? v : -v;
}
// small positional terms
for (let f = 0; f < 8; f++){
if (pawnFilesW[f] > 1) score += (pawnFilesW[f] - 1) * CFG.W_PAWN_STRUCT.DOUBLED;
if (pawnFilesB[f] > 1) score -= (pawnFilesB[f] - 1) * CFG.W_PAWN_STRUCT.DOUBLED;
const wIso = pawnFilesW[f] > 0 && (f === 0 || !pawnFilesW[f-1]) && (f === 7 || !pawnFilesW[f+1]);
const bIso = pawnFilesB[f] > 0 && (f === 0 || !pawnFilesB[f-1]) && (f === 7 || !pawnFilesB[f+1]);
if (wIso) score += CFG.W_PAWN_STRUCT.ISOLATED;
if (bIso) score -= CFG.W_PAWN_STRUCT.ISOLATED;
}
if (wb >= 2) score += CFG.W_BISHOP_PAIR;
if (bb >= 2) score -= CFG.W_BISHOP_PAIR;
// passed pawns (simple: no enemy pawn on file or adjacent files ahead)
for (const sq of pawnsW){
const f = sq & 7, r = sq >> 3;
let passed = true;
for (let rr = 0; rr < r; rr++)
for (let ff = Math.max(0,f-1); ff <= Math.min(7,f+1); ff++)
if (b[rr*8+ff] === 'p') { passed = false; break; }
if (passed) score += 12 + (6 - r) * 5;
}
for (const sq of pawnsB){
const f = sq & 7, r = sq >> 3;
let passed = true;
for (let rr = r + 1; rr < 8; rr++)
for (let ff = Math.max(0,f-1); ff <= Math.min(7,f+1); ff++)
if (b[rr*8+ff] === 'P') { passed = false; break; }
if (passed) score -= 12 + (r - 1) * 5;
}
score += CFG.W_TEMPO * (st.turn === 'w' ? 1 : -1);
return st.turn === 'w' ? score : -score;
}
/* ========================= 9. SEARCH (AI) ================================ */
const ABORT = { abort: true };
let nodesSearched = 0;
function orderMoves(st, moves){
for (const m of moves){
let s = 0;
if (m.capture) s += 100 * P_VAL[typeOf(m.capture)] - P_VAL[typeOf(m.piece)];
if (m.promo) s += P_VAL[m.promo] + 50;
if (m.flag === 'castle-k' || m.flag === 'castle-q') s += 30;
m._s = s;
}
moves.sort((a, b) => b._s - a._s);
return moves;
}
function checkTime(start, limit){
if (performance.now() - start > limit) throw ABORT;
}
function quiesce(st, alpha, beta, qd, start, limit, ply){
nodesSearched++;
if ((nodesSearched & 511) === 0) checkTime(start, limit);
const stand = evaluate(st);
if (stand >= beta) return beta;
if (stand > alpha) alpha = stand;
if (qd <= 0 || ply > 40) return alpha;
const caps = orderMoves(st, legalMoves(st).filter(m => m.capture || m.promo));
for (const m of caps){
const ns = makeMove(st, m);
const sc = -quiesce(ns, -beta, -alpha, qd - 1, start, limit, ply + 1);
if (sc >= beta) return beta;
if (sc > alpha) alpha = sc;
}
return alpha;
}
function negamax(st, depth, alpha, beta, ply, start, limit){
nodesSearched++;
if ((nodesSearched & 511) === 0) checkTime(start, limit);
const moves = legalMoves(st);
if (moves.length === 0){
if (inCheck(st, st.turn)) return -MATE + ply; // prefer faster mates
return 0; // stalemate
}
if (st.half >= 100 || insufficientMaterial(st.board)){
// 50-move / dead position — score as a draw unless a mate is on the board
return 0;
}
if (depth <= 0) return quiesce(st, alpha, beta, CFG.AI_MAX_QUIESCE, start, limit, ply);
orderMoves(st, moves);
let best = -INF;
// check extension: look one ply deeper when in check
const ext = inCheck(st, st.turn) ? 1 : 0;
for (const m of moves){
const ns = makeMove(st, m);
const sc = -negamax(ns, depth - 1 + ext, -beta, -alpha, ply + 1, start, limit);
if (sc > best) best = sc;
if (sc > alpha) alpha = sc;
if (alpha >= beta) break;
}
return best;
}
/** Iterative deepening root search. Returns { move, score, depth, nodes, ms }. */
function findBestMove(st, maxDepth, timeLimit){
const start = performance.now();
nodesSearched = 0;
let best = null, bestScore = 0, reachedDepth = 0;
let prevMove = null;
const rootMoves = legalMoves(st);
if (!rootMoves.length) return null;
for (let d = 1; d <= maxDepth; d++){
const moves = orderMoves(st, legalMoves(st));
if (prevMove){
const i = moves.findIndex(m => m.from === prevMove.from && m.to === prevMove.to && m.promo === prevMove.promo);
if (i > 0) moves.unshift(moves.splice(i, 1)[0]);
}
let alpha = -INF, localBest = moves[0], localScore = -INF;
let completed = true;
try {
for (const m of moves){
const ns = makeMove(st, m);
const sc = -negamax(ns, d - 1, -INF, -alpha, 1, start, timeLimit);
if (sc > localScore){ localScore = sc; localBest = m; }
if (sc > alpha) alpha = sc;
}
} catch (e){
if (e !== ABORT) throw e;
completed = false;
}
if (completed){
best = localBest; bestScore = localScore; reachedDepth = d; prevMove = localBest;
if (Math.abs(localScore) > MATE - 500) break; // mate found — stop early
} else if (!best){
best = localBest; bestScore = localScore; reachedDepth = Math.max(reachedDepth, 1);
break;
} else break;
}
return { move: best, score: bestScore, depth: reachedDepth,
nodes: nodesSearched, ms: Math.round(performance.now() - start) };
}
/* =========================== 10. AUDIO =================================== */
let audioCtx = null;
function ac(){
if (G.muted) return null;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
if (!audioCtx) audioCtx = new AC();
if (audioCtx.state === 'suspended') { try { audioCtx.resume(); } catch (e) {} }
return audioCtx;
}
// create/resume the context on the first real user gesture (browser autoplay policy)
window.addEventListener('pointerdown', function primeAudio(){ ac(); }, { once: true });
function tone(freq, dur, type, vol, delay){
if (G.muted) return;
const c = ac(); if (!c) return;
const t0 = c.currentTime + (delay || 0);
const o = c.createOscillator(), g = c.createGain();
o.type = type || 'sine'; o.frequency.setValueAtTime(freq, t0);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol, t0 + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o.connect(g); g.connect(c.destination);
o.start(t0); o.stop(t0 + dur + 0.02);
}
function noise(dur, vol, delay){
if (G.muted) return;
const c = ac(); if (!c) return;
const len = Math.floor(c.sampleRate * dur);
const buf = c.createBuffer(1, len, c.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len);
const src = c.createBufferSource(); src.buffer = buf;
const g = c.createGain(); g.gain.value = vol;
const f = c.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 1400;
src.connect(f); f.connect(g); g.connect(c.destination);
src.start(c.currentTime + (delay || 0));
}
const SFX = {
move(){ tone(320, 0.07, 'triangle', 0.13); noise(0.05, 0.05); },
capture(){ tone(180, 0.13, 'square', 0.10); noise(0.10, 0.12); },
check(){ tone(760, 0.10, 'sine', 0.13); tone(1020, 0.13, 'sine', 0.10, 0.10); },
castle(){ tone(300, 0.06, 'triangle', 0.12); tone(400, 0.07, 'triangle', 0.11, 0.07); },
end(){ tone(520, 0.16, 'sine', 0.13); tone(392, 0.20, 'sine', 0.12, 0.15); tone(262, 0.34, 'sine', 0.11, 0.32); }
};
/* ==================== 11. RENDERING / UI ================================= */
const GLYPH = { K:'\u265A', Q:'\u265B', R:'\u265C', B:'\u265D', N:'\u265E', P:'\u265F' };
const G = {
state: parseFen(START_FEN),
history: [], // state before each move (undo + repetition)
moves: [],
sans: [],
playerColor: 'w',
flipped: false,
muted: false,
depth: 2,
selected: -1,
thinking: false,
over: false,
pendingPromo: null,
legal: [],
status: null,
lastMove: null
};
const boardEl = document.getElementById('board');
const frameEl = document.getElementById('boardFrame');
const promoEl = document.getElementById('promo');
const bannerEl = document.getElementById('banner');
const statusEl = document.getElementById('statusLine');
const movesEl = document.getElementById('movesList');
const trayWEl = document.getElementById('trayW');
const trayBEl = document.getElementById('trayB');
const turnDot = document.getElementById('turnDot');
const turnTxt = document.getElementById('turnTxt');
const sideSel = document.getElementById('sideSel');
const depthSel = document.getElementById('depthSel');
const sqEls = new Array(64);
/* --- board construction / layout --- */
function buildBoard(){
boardEl.innerHTML = '';
for (let sq = 0; sq < 64; sq++){
const d = document.createElement('div');
const light = (((sq >> 3) + (sq & 7)) & 1) === 0;
d.className = 'sq ' + (light ? 'light' : 'dark');
d.dataset.sq = sq;
sqEls[sq] = d;
boardEl.appendChild(d);
}
layoutBoard();
}
function layoutBoard(){
for (let sq = 0; sq < 64; sq++){
const r = sq >> 3, f = sq & 7;
const el = sqEls[sq];
el.style.gridRow = (G.flipped ? 8 - r : r + 1);
el.style.gridColumn = (G.flipped ? 8 - f : f + 1);
el.innerHTML = '';
const showRank = G.flipped ? f === 7 : f === 0;
const showFile = G.flipped ? r === 0 : r === 7;
if (showRank){
const s = document.createElement('span'); s.className = 'coord r';
s.textContent = String(8 - r); el.appendChild(s);
}
if (showFile){
const s = document.createElement('span'); s.className = 'coord f';
s.textContent = FILES[f]; el.appendChild(s);
}
}
fitFont();
renderBoard();
}
function fitFont(){
const w = boardEl.clientWidth || 480;
boardEl.style.fontSize = (w / 8 * 0.86) + 'px';
}
window.addEventListener('resize', () => { fitFont(); if (G.pendingPromo) hidePromo(); });
/* --- board render --- */
let dragHiddenSq = -1;
function renderBoard(){
const st = G.state;
const kingSq = G.status && G.status.check ? findKing(st.board, st.turn) : -1;
const lm = G.lastMove;
const targets = G.selected >= 0 ? G.legal.filter(m => m.from === G.selected) : null;
for (let sq = 0; sq < 64; sq++){
const el = sqEls[sq];
el.querySelectorAll('.pc,.hint').forEach(n => n.remove());
el.classList.remove('last','sel','check','over');
if (lm && (sq === lm.from || sq === lm.to)) el.classList.add('last');
if (sq === G.selected) el.classList.add('sel');
if (sq === kingSq) el.classList.add('check');
const p = st.board[sq];
if (p){
const s = document.createElement('span');
s.className = 'pc ' + (colorOf(p) === 'w' ? 'w' : 'b');
s.textContent = GLYPH[typeOf(p)];
if (sq === dragHiddenSq) s.style.opacity = '0.2';
el.appendChild(s);
}
if (targets && sq !== G.selected && dragHiddenSq < 0){
const hit = targets.find(m => m.to === sq);
if (hit){
const h = document.createElement('span');
h.className = 'hint' + (hit.capture || hit.flag === 'ep' ? ' cap' : '');
el.appendChild(h);
}
}
}
}
/* --- panel render --- */
function renderPanel(){
const st = G.state, stt = G.status;
// turn indicator
turnDot.className = 'dot' + (st.turn === 'b' ? ' b' : '') + (G.thinking ? ' think' : '');
if (stt && stt.over){
turnTxt.textContent = 'Game over';
} else if (G.thinking){
turnTxt.textContent = 'Computer thinking…';
} else {
turnTxt.textContent = (st.turn === 'w' ? 'White' : 'Black') + ' to move' + (stt && stt.check ? ' — check!' : '');
}
// status line
if (stt && stt.over){
statusEl.innerHTML = '<b>' + resultText(stt) + '</b>';
} else if (stt && stt.check){
statusEl.innerHTML = '<b>' + (st.turn === 'w' ? 'White' : 'Black') + ' is in check</b> · ' +
G.legal.length + ' legal moves';
} else {
statusEl.innerHTML = (G.playerColor === 'both' ? 'Analysis mode' :
(st.turn === G.playerColor ? 'Your move' : 'Computer to move')) +
' · ' + G.legal.length + ' legal moves';
}
// captured trays
const capW = [], capB = [];
let matW = 0, matB = 0;
for (const m of G.moves){
if (m.capture){
// trayW = pieces captured BY White (black pieces), trayB = the reverse
if (colorOf(m.piece) === 'w'){ capW.push(m.capture); matW += P_VAL[typeOf(m.capture)]; }
else { capB.push(m.capture); matB += P_VAL[typeOf(m.capture)]; }
}
if (m.promo){
if (colorOf(m.piece) === 'w') matW += P_VAL[m.promo] - 100; else matB += P_VAL[m.promo] - 100;
}
}
capW.sort((a,b) => P_VAL[typeOf(a)] - P_VAL[typeOf(b)]);
capB.sort((a,b) => P_VAL[typeOf(a)] - P_VAL[typeOf(b)]);
trayWEl.innerHTML = capW.map(p => '<span class="pc ' + (colorOf(p) === 'w' ? 'w' : 'b') + '">' + GLYPH[typeOf(p)] + '</span>').join('');
trayBEl.innerHTML = capB.map(p => '<span class="pc ' + (colorOf(p) === 'w' ? 'w' : 'b') + '">' + GLYPH[typeOf(p)] + '</span>').join('');
const diff = matW - matB;
if (diff > 0) trayWEl.innerHTML += '<span class="adv">+' + Math.round(diff/100) + '</span>';
else if (diff < 0) trayBEl.innerHTML += '<span class="adv">+' + Math.round(-diff/100) + '</span>';
// move list
if (!G.sans.length){
movesEl.innerHTML = '<div class="movesEmpty">No moves yet — White begins.</div>';
} else {
let html = '';
for (let i = 0; i < G.sans.length; i += 2){
const n = (i / 2) + 1;
const w = G.sans[i] || '';
const b = G.sans[i + 1] || '';
const wCur = i === G.sans.length - 1 ? ' cur' : '';
const bCur = i + 1 === G.sans.length - 1 ? ' cur' : '';
html += '<div class="mvrow"><span class="num">' + n + '.</span>' +
'<span class="mv' + wCur + '">' + w + '</span>' +
'<span class="mv' + bCur + '">' + b + '</span></div>';
}
movesEl.innerHTML = html;
movesEl.scrollTop = movesEl.scrollHeight;
}
// buttons
document.getElementById('undoBtn').disabled = G.history.length === 0 || G.thinking;
document.getElementById('muteBtn').textContent = G.muted ? '🔇 Muted' : '🔊 Sound';
}
function resultText(stt){
if (stt.result === 'draw') return 'Draw — ' + stt.reason;
return (stt.result === 'w' ? 'White' : 'Black') + ' wins by ' + stt.reason;
}
function refresh(){
G.legal = legalMoves(G.state);
G.status = gameStatus(G.state, G.history);
if (!G.status.over) G.status.moves = G.legal;
renderBoard();
renderPanel();
}
function showBanner(){
if (!G.status.over) { bannerEl.classList.add('hidden'); return; }
bannerEl.classList.remove('hidden');
bannerEl.innerHTML = '<div class="big">' + resultText(G.status) + '</div>' +
'<div class="sub">' + (G.state.turn === 'w' ? 'White' : 'Black') + ' to move</div>' +
'<button id="againBtn">New Game</button>';
document.getElementById('againBtn').onclick = newGame;
}
function hideBanner(){ bannerEl.classList.add('hidden'); }
/* --- promotion picker --- */
function showPromo(from, to){
const opts = G.legal.filter(m => m.from === from && m.to === to && m.promo);
if (!opts.length) return;
G.pendingPromo = { from, to };
promoEl.innerHTML = '';
promoEl.classList.remove('hidden');
for (const q of ['Q','R','B','N']){
const m = opts.find(x => x.promo === q);
if (!m) continue;
const btn = document.createElement('button');
const sp = document.createElement('span');
sp.className = 'pc ' + (G.state.turn === 'w' ? 'w' : 'b');
sp.textContent = GLYPH[q];
btn.appendChild(sp);
btn.title = 'Promote to ' + q;
btn.onclick = (ev) => { ev.stopPropagation(); hidePromo(); doMove(m); };
promoEl.appendChild(btn);
}
// position over the promotion square
const sr = sqEls[to].getBoundingClientRect();
const fr = frameEl.getBoundingClientRect();
const w = promoEl.offsetWidth, h = promoEl.offsetHeight;
let left = sr.left - fr.left + sr.width / 2 - w / 2;
let top = sr.bottom - fr.top + 6;
if (top + h > fr.height - 2) top = sr.top - fr.top - h - 6;
left = Math.max(2, Math.min(left, fr.width - w - 2));
top = Math.max(2, Math.min(top, fr.height - h - 2));
promoEl.style.left = left + 'px';
promoEl.style.top = top + 'px';
}
function hidePromo(){
G.pendingPromo = null;
promoEl.classList.add('hidden');
promoEl.innerHTML = '';
}
/* --- interaction: click + drag --- */
function squareAt(clientX, clientY){
const r = boardEl.getBoundingClientRect();
const x = clientX - r.left, y = clientY - r.top;
if (x < 0 || y < 0 || x >= r.width || y >= r.height) return -1;
let col = Math.floor(x / (r.width / 8));
let row = Math.floor(y / (r.height / 8));
if (G.flipped){ col = 7 - col; row = 7 - row; }
return row * 8 + col;
}
function isHumanTurn(){
if (G.over || G.thinking || G.pendingPromo) return false;
if (G.playerColor === 'both') return true;
return G.state.turn === G.playerColor;
}
function findMove(from, to){
return G.legal.find(m => m.from === from && m.to === to) || null;
}
let drag = null, ghostEl = null;
boardEl.addEventListener('pointerdown', (e) => {
const sq = squareAt(e.clientX, e.clientY);
if (sq < 0) return;
if (!isHumanTurn()) { G.selected = -1; renderBoard(); return; }
if (G.selected >= 0){
const mv = findMove(G.selected, sq);
if (mv){
e.preventDefault();
cancelDrag();
if (mv.promo){ G.selected = -1; renderBoard(); showPromo(mv.from, mv.to); }
else doMove(mv);
return;
}
if (sq === G.selected){ G.selected = -1; renderBoard(); return; }
}
const p = G.state.board[sq];
if (p && colorOf(p) === G.state.turn){
G.selected = sq;
drag = { from: sq, x: e.clientX, y: e.clientY, active: false, moved: false };
renderBoard();
e.preventDefault();
} else {
G.selected = -1;
renderBoard();
}
});
window.addEventListener('pointermove', (e) => {
if (!drag) return;
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (!drag.active && (dx * dx + dy * dy) > 16){
drag.active = true;
dragHiddenSq = drag.from;
const p = G.state.board[drag.from];
ghostEl = document.createElement('div');
ghostEl.id = 'ghost';
ghostEl.style.fontSize = boardEl.style.fontSize;
ghostEl.innerHTML = '<span class="pc ' + (colorOf(p) === 'w' ? 'w' : 'b') + '">' + GLYPH[typeOf(p)] + '</span>';
document.body.appendChild(ghostEl);
renderBoard();
}
if (drag.active){
ghostEl.style.left = e.clientX + 'px';
ghostEl.style.top = e.clientY + 'px';
const sq = squareAt(e.clientX, e.clientY);
for (let i = 0; i < 64; i++) sqEls[i].classList.remove('over');
if (sq >= 0 && findMove(drag.from, sq)) sqEls[sq].classList.add('over');
}
});
window.addEventListener('pointerup', (e) => {
if (!drag) return;
const d = drag;
drag = null;
dragHiddenSq = -1;
for (let i = 0; i < 64; i++) sqEls[i].classList.remove('over');
if (d.active){
const sq = squareAt(e.clientX, e.clientY);
const mv = sq >= 0 ? findMove(d.from, sq) : null;
cancelDrag();
if (mv){
if (mv.promo){ G.selected = -1; renderBoard(); showPromo(mv.from, mv.to); }
else doMove(mv);
} else {
G.selected = d.from;
renderBoard();
}
} else {
renderBoard();
}
});
window.addEventListener('pointercancel', () => { cancelDrag(); renderBoard(); });
function cancelDrag(){
if (ghostEl){ ghostEl.remove(); ghostEl = null; }
dragHiddenSq = -1;
}
/* --- applying a move --- */
function doMove(m, opts){
opts = opts || {};
if (G.over || G.pendingPromo) return false;
hidePromo();
const san = toSAN(G.state, m);
G.history.push(cloneState(G.state));
G.moves.push(m);
G.sans.push(san);
G.state = makeMove(G.state, m);
G.lastMove = m;
G.selected = -1;
cancelDrag();
if (opts.sfx !== false){
if (m.flag === 'castle-k' || m.flag === 'castle-q') SFX.castle();
else if (m.capture || m.flag === 'ep') SFX.capture();
else SFX.move();
}
refresh();
if (G.status.check && !G.status.over && opts.sfx !== false) SFX.check();
if (G.status.over && opts.sfx !== false) SFX.end();
showBanner();
save();
if (!opts.noAI && G.status.over === false) maybeAI();
return true;
}
/* --- AI turn --- */
let aiToken = 0;
function maybeAI(){
if (G.over || G.playerColor === 'both') return;
if (G.state.turn === G.playerColor) return;
G.thinking = true;
renderPanel();
const token = ++aiToken;
const expect = toFen(G.state);
setTimeout(() => runAI(token, expect), 40);
}
function runAI(token, expect){
// abort if the position changed underneath us (undo / new game / manual move)
if (token !== aiToken || G.over || !G.thinking) return;
if (toFen(G.state) !== expect) { G.thinking = false; renderPanel(); return; }
if (G.playerColor !== 'both' && G.state.turn === G.playerColor) { G.thinking = false; renderPanel(); return; }
const deadline = performance.now() + CFG.AI_TIME_MS + 800;
let res = null;
try {
res = findBestMove(G.state, CFG.DEPTHS[G.depth] || 2, CFG.AI_TIME_MS);
} catch (err){
console.error('AI error', err);
}
G.thinking = false;
if (performance.now() > deadline) console.warn('AI exceeded soft deadline');
if (!res || !res.move){ refresh(); showBanner(); return; }
// safety: never play an illegal move
const ok = G.legal.some(x => x.from === res.move.from && x.to === res.move.to && x.promo === res.move.promo);
if (!ok){
console.error('AI produced an illegal move — falling back', res.move);
const fallback = G.legal[Math.floor(Math.random() * G.legal.length)];
doMove(fallback);
return;
}
doMove(res.move);
}
/* --- controls --- */
function popOne(){
if (!G.history.length) return false;
G.state = G.history.pop();
G.moves.pop();
G.sans.pop();
G.lastMove = G.moves.length ? G.moves[G.moves.length - 1] : null;
return true;
}
function undo(){
if (!G.history.length || G.thinking) return;
G.over = false;
hideBanner();
popOne();
if (G.playerColor !== 'both' && G.state.turn !== G.playerColor) popOne();
G.selected = -1;
G.thinking = false;
refresh();
save();
}
function newGame(){
G.state = parseFen(START_FEN);
G.history = []; G.moves = []; G.sans = [];
G.selected = -1; G.over = false; G.thinking = false;
G.lastMove = null; G.pendingPromo = null;
G.playerColor = sideSel.value;
G.depth = +depthSel.value;
G.flipped = (G.playerColor === 'b');
hideBanner(); hidePromo();
layoutBoard(); refresh(); save();
maybeAI();
}
/* ======================= 12. PERSISTENCE ================================= */
const SAVE_KEY = 'chess.engine.save.v1';
function save(){
try {
localStorage.setItem(SAVE_KEY, JSON.stringify({
state: G.state, history: G.history, moves: G.moves, sans: G.sans,
playerColor: G.playerColor, flipped: G.flipped, muted: G.muted, depth: G.depth
}));
} catch (e){ /* storage may be unavailable */ }
}
function load(){
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return false;
const d = JSON.parse(raw);
if (!d || !d.state || !Array.isArray(d.state.board) || d.state.board.length !== 64) return false;
G.state = d.state;
G.history = d.history || [];
G.moves = d.moves || [];
G.sans = d.sans || [];
G.playerColor = d.playerColor || 'w';
G.flipped = !!d.flipped;
G.muted = !!d.muted;
G.depth = d.depth || 2;
G.lastMove = G.moves.length ? G.moves[G.moves.length - 1] : null;
return true;
} catch (e){ return false; }
}
function clearSave(){ try { localStorage.removeItem(SAVE_KEY); } catch(e){} }
/* ====================== 13. DEBUG INTERFACE ============================== */
window.game = {
get state(){ return G.state; },
fen(){ return toFen(G.state); },
legalMoves(){ return legalMoves(G.state).map(moveToUci); },
legalMovesDetailed(){ return legalMoves(G.state).map(m => ({
from: sqName(m.from), to: sqName(m.to), uci: moveToUci(m),
piece: m.piece, capture: m.capture, promo: m.promo, flag: m.flag
})); },
san(){ return G.sans.slice(); },
/** Play a move in UCI-ish form ('e2e4', 'e7e8q'). Returns true if accepted. */
move(str){
if (!str || str.length < 4) return false;
const from = nameSq(str.slice(0,2)), to = nameSq(str.slice(2,4));
if (from < 0 || to < 0) return false;
const promo = str.length > 4 ? str[4].toUpperCase() : null;
const m = G.legal.find(x => x.from === from && x.to === to && (promo ? x.promo === promo : true));
if (!m) return false;
return doMove(m, { noAI: true, sfx: false });
},
/** Load any FEN (board, turn, castling, ep, clocks). */
load(fen){
try {
const st = parseFen(fen);
if (findKing(st.board,'w') < 0 || findKing(st.board,'b') < 0) return false;
G.state = st; G.history = []; G.moves = []; G.sans = [];
G.selected = -1; G.over = false; G.lastMove = null; G.thinking = false;
refresh(); save();
return true;
} catch(e){ return false; }
},
status(){
const s = gameStatus(G.state, G.history);
return { over: s.over, result: s.result, reason: s.reason, check: s.check,
moves: s.moves.length };
},
inCheck(color){ return inCheck(G.state, color || G.state.turn); },
eval(){ return evaluate(G.state); },
bestMove(depth){ return findBestMove(G.state, depth || 2, CFG.AI_TIME_MS); },
reset(){ newGame(); clearSave(); },
board(){ return G.state.board.slice(); },
turn(){ return G.state.turn; },
newGame, undo,
_G: G
};
/* ============================ 14. BOOT =================================== */
function boot(){
buildBoard();
if (!load()){
G.playerColor = 'w';
G.depth = 2;
}
sideSel.value = G.playerColor;
depthSel.value = String(G.depth);
document.getElementById('muteBtn').textContent = G.muted ? '🔇 Muted' : '🔊 Sound';
document.getElementById('newBtn').onclick = newGame;
document.getElementById('undoBtn').onclick = undo;
document.getElementById('flipBtn').onclick = () => {
G.flipped = !G.flipped;
layoutBoard(); save();
};
document.getElementById('muteBtn').onclick = () => {
G.muted = !G.muted;
if (!G.muted) SFX.move();
renderPanel(); save();
};
sideSel.onchange = () => {
G.playerColor = sideSel.value;
// orient the board to the side you play
if (G.playerColor === 'b') G.flipped = true;
else if (G.playerColor === 'w') G.flipped = false;
layoutBoard(); refresh(); save();
maybeAI();
};
depthSel.onchange = () => { G.depth = +depthSel.value; save(); };
layoutBoard();
refresh();
showBanner();
// resume an in-progress AI move after a reload
if (!G.status.over && G.playerColor !== 'both' && G.state.turn !== G.playerColor) maybeAI();
}
document.addEventListener('keydown', (e) => {
if (e.key === 'z' && (e.ctrlKey || e.metaKey)){ e.preventDefault(); undo(); }
if (e.key === 'f' && !e.ctrlKey && !e.metaKey){ G.flipped = !G.flipped; layoutBoard(); save(); }
});
boot();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"9b4bf85f-ada5-4da5-b632-5fb4fd769223","tokensIn":2210189,"tokensOut":60693,"tokensTotal":2270882,"cost":0.048843606,"turns":33,"toolCalls":37,"failedToolCalls":0,"timestamp":"2026-09-10T16:50:12.171Z"} -->