Chess
Deepseek v4 Flash Vision Exp · 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 46,441 bytes · SHA-256 9af68ef24ce5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chess — Vanilla JS Rules Engine + AI</title>
<style>
/* ============================================================
TUNABLE CONSTANTS (colors, board look)
============================================================ */
:root{
--wood-light:#f0d9b5;
--wood-dark:#b58863;
--wood-light-sel:#f7e8b5;
--wood-dark-sel:#d9a86c;
--accent:#2e86ab;
--glow:#e63946;
--panel:#2b2620;
--panel-text:#f1e9d8;
--panel-muted:#bfae90;
--board-size:min(80vmin, 600px);
}
*{ box-sizing:border-box; margin:0; padding:0; }
html,body{ height:100%; }
body{
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
background:radial-gradient(circle at 50% 20%, #3a3228, #1c1712 70%);
color:var(--panel-text);
min-height:100vh;
-webkit-user-select:none; user-select:none;
display:flex; justify-content:center;
}
#app{
width:100%; max-width:1080px;
padding:14px 18px 30px;
display:flex; flex-direction:column; align-items:center; gap:18px;
}
header{ text-align:center; }
header h1{ font-size:30px; letter-spacing:2px; font-weight:600;
background:linear-gradient(180deg,#f7ead0,#c8a878); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; }
header p{ color:var(--panel-muted); font-size:12px; letter-spacing:1px; margin-top:2px; }
main{ display:flex; gap:22px; align-items:flex-start; justify-content:center; flex-wrap:wrap; width:100%; }
/* ---------- Board ---------- */
#board-wrap{ position:relative; }
#board{
width:var(--board-size); height:var(--board-size);
display:grid; grid-template-columns:repeat(8,1fr); grid-template-rows:repeat(8,1fr);
border:8px solid #4a3b2c; border-radius:6px; overflow:hidden;
box-shadow:0 18px 50px rgba(0,0,0,.55), inset 0 0 0 1px rgba(0,0,0,.3);
background:#000;
cursor:pointer;
}
.square{
position:relative; display:flex; align-items:center; justify-content:center;
}
.square.light{ background:var(--wood-light); }
.square.dark{ background:var(--wood-dark); }
.square.light.selected{ background:var(--wood-light-sel); }
.square.dark.selected{ background:var(--wood-dark-sel); }
.square .piece{
font-size:calc(var(--board-size)/8*0.78);
line-height:1; z-index:2; pointer-events:none;
filter:drop-shadow(0 2px 2px rgba(0,0,0,.35));
}
.square .piece.white{ color:#fdf6e3; text-shadow:0 1px 2px rgba(0,0,0,.55); }
.square .piece.black{ color:#2a221a; text-shadow:0 1px 1px rgba(255,255,255,.18); }
.square.last-move::after{
content:""; position:absolute; inset:0;
background:rgba(255,214,120,.45); mix-blend-mode:multiply; pointer-events:none;
}
.square.selected::before{
content:""; position:absolute; inset:0;
background:rgba(255,215,130,.55); mix-blend-mode:multiply; pointer-events:none;
}
.square.check{ box-shadow:inset 0 0 0 4px var(--glow), inset 0 0 24px var(--glow); }
.square.check .piece{ animation:checkPulse 1.1s ease-in-out infinite; }
@keyframes checkPulse{ 0%,100%{ text-shadow:0 2px 4px rgba(230,57,70,.5);} 50%{ text-shadow:0 2px 16px rgba(230,57,70,.95);} }
/* legal move dots / capture rings */
.dot{
width:calc(var(--board-size)/8*0.30); height:calc(var(--board-size)/8*0.30);
border-radius:50%; background:rgba(35,27,20,.35);
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
z-index:3; pointer-events:none; box-shadow:0 0 0 1px rgba(0,0,0,.12);
}
.ring{
width:calc(var(--board-size)/8*0.86); height:calc(var(--board-size)/8*0.86);
border-radius:50%; border:5px solid rgba(35,27,20,.4);
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
z-index:3; pointer-events:none; box-shadow:0 0 0 1px rgba(0,0,0,.15);
}
.castle-marker{
width:calc(var(--board-size)/8*0.72); height:calc(var(--board-size)/8*0.72);
border-radius:6px; border:4px solid rgba(255,215,130,.75);
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
z-index:3; pointer-events:none;
}
/* drag ghost */
#drag-ghost{
position:fixed; z-index:99; pointer-events:none; display:none;
font-size:calc(var(--board-size)/8*0.78); line-height:1;
}
#drag-ghost.white{ color:#fdf6e3; text-shadow:0 2px 3px rgba(0,0,0,.6); }
#drag-ghost.black{ color:#2a221a; text-shadow:0 1px 2px rgba(255,255,255,.25); }
/* ---------- Promotion overlay ---------- */
#promo{
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
background:rgba(20,15,10,.55); z-index:8; backdrop-filter:blur(2px);
}
#promo.hidden{ display:none; }
#promo .box{
background:var(--panel); border:1px solid #5a4a36; border-radius:10px;
padding:16px 18px; box-shadow:0 12px 40px rgba(0,0,0,.6); text-align:center;
}
#promo .box h3{ font-size:14px; color:var(--panel-muted); margin-bottom:12px; letter-spacing:1px; }
#promo .opts{ display:flex; gap:10px; }
#promo button{
font-size:40px; width:64px; height:64px; background:#3a3228; color:var(--panel-text);
border:1px solid #5a4a36; border-radius:8px; cursor:pointer; transition:.12s;
}
#promo button:hover{ background:var(--accent); transform:translateY(-2px); }
#promo .cancel{ margin-top:12px; font-size:12px; color:var(--panel-muted); cursor:pointer; }
/* ---------- Banner ---------- */
#banner{
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
background:rgba(18,14,10,.62); z-index:9;
animation:fadeIn .3s ease;
}
#banner.hidden{ display:none; }
#banner .card{
background:var(--panel); border:1px solid #5a4a36; border-radius:12px; padding:26px 34px;
text-align:center; box-shadow:0 16px 50px rgba(0,0,0,.65);
}
#banner .title{ font-size:30px; letter-spacing:1px; margin-bottom:6px; }
#banner .sub{ color:var(--panel-muted); margin-bottom:18px; }
#banner button{ padding:10px 22px; }
@keyframes fadeIn{ from{opacity:0} to{opacity:1} }
/* ---------- Side panel ---------- */
#panel{
width:300px; background:var(--panel); border:1px solid #4a3b2c; border-radius:12px;
padding:18px; display:flex; flex-direction:column; gap:16px;
box-shadow:0 12px 30px rgba(0,0,0,.4);
}
#turn{
display:flex; align-items:center; gap:10px; font-size:15px; font-weight:600; letter-spacing:.5px;
}
#turn .dot{ width:16px; height:16px; border-radius:50%; display:inline-block; position:static; transform:none; }
#turn .dot.w{ background:#fdf6e3; box-shadow:0 0 0 2px #5a4a36; }
#turn .dot.b{ background:#2a221a; box-shadow:0 0 0 2px #5a4a36; }
#turn .think{ font-size:11px; color:var(--accent); margin-left:auto; }
.captured-tray{ display:flex; align-items:center; gap:8px; min-height:26px; }
.captured-tray .label{ font-size:11px; color:var(--panel-muted); width:52px; }
.captured-tray .pieces{ display:flex; flex-wrap:wrap; }
.captured-tray .p{ font-size:22px; line-height:1; }
.captured-tray .p.b{ color:#d9cebb; text-shadow:0 1px 1px #000; }
.captured-tray .p.w{ color:#fdf6e3; text-shadow:0 1px 1px #000; }
.captured-tray .adv{ font-size:11px; color:var(--panel-muted); margin-left:6px; }
#move-list{
flex:1; min-height:150px; max-height:280px; overflow-y:auto;
background:#221d17; border:1px solid #3c3227; border-radius:8px; padding:10px;
font-family:ui-monospace,Menlo,Consolas,monospace; font-size:14px; line-height:1.7;
}
#move-list .ply{ display:inline-block; width:34px; color:var(--panel-muted); }
#move-list .san{ display:inline-block; margin-right:12px; cursor:pointer; }
#move-list .san:hover{ color:var(--accent); }
#move-list .muted{ color:#5c4f3f; }
.controls{ display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.controls .full{ grid-column:1 / -1; }
button{
background:#3a3228; color:var(--panel-text); border:1px solid #5a4a36; border-radius:8px;
padding:9px; font-size:13px; cursor:pointer; transition:.12s; letter-spacing:.3px;
}
button:hover{ background:var(--accent); border-color:var(--accent); }
button:active{ transform:translateY(1px); }
button.primary{ background:var(--accent); border-color:var(--accent); }
button.primary:hover{ background:#3b9bc4; }
.field{ display:flex; flex-direction:column; gap:4px; }
.field label{ font-size:11px; color:var(--panel-muted); letter-spacing:.5px; }
select{
background:#3a3228; color:var(--panel-text); border:1px solid #5a4a36; border-radius:8px;
padding:7px; font-size:13px; cursor:pointer; width:100%;
}
.status-line{ font-size:12px; color:var(--panel-muted); }
@media (max-width:760px){
#app{ padding:10px; }
#panel{ width:min(94vw, 420px); }
#move-list{ max-height:160px; }
}
</style>
</head>
<body>
<div id="app">
<header>
<h1>♞ CHESS</h1>
<p>rule-complete engine · minimax AI</p>
</header>
<main>
<div id="board-wrap">
<div id="board"></div>
<div id="promo" class="hidden"></div>
<div id="banner" class="hidden"></div>
</div>
<aside id="panel">
<div id="turn"><span class="dot w" id="turn-dot"></span><span id="turn-text">White to move</span><span class="think" id="thinking" style="display:none">thinking…</span></div>
<div class="captured-tray"><span class="label">Captured</span><span class="pieces" id="cap-w"></span><span class="adv" id="adv-w"></span></div>
<div class="captured-tray"><span class="label">Captured</span><span class="pieces" id="cap-b"></span><span class="adv" id="adv-b"></span></div>
<div id="move-list"></div>
<div class="controls">
<div class="field">
<label>Play as</label>
<select id="side-select">
<option value="w">White</option>
<option value="b">Black</option>
<option value="both">Both (analysis)</option>
</select>
</div>
<div class="field">
<label>Difficulty</label>
<select id="diff-select">
<option value="1">Depth 1</option>
<option value="2" selected>Depth 2</option>
<option value="3">Depth 3</option>
</select>
</div>
<button class="full primary" id="btn-new">♟ New Game</button>
<button id="btn-undo">⟲ Undo</button>
<button id="btn-flip">⇄ Flip</button>
<button class="full" id="btn-mute">🔊 Sound On</button>
</div>
<div class="status-line" id="status-line"></div>
</aside>
</main>
</div>
<script>
/* =====================================================================
SECTION 1 — TUNABLES + CONSTANTS
===================================================================== */
const TUNE = {
// AI search depth (0 = use difficulty selector value at runtime)
defaultDepth: 2,
maxDepth: 3,
// evaluation weights (centipawns)
material: { P:100, N:320, B:330, R:500, Q:900, K:0 },
checkmateScore: 100000,
};
const FILES = 'abcdefgh';
const GLYPHS = { K:'♔', Q:'♕', R:'♖', B:'♗', N:'♘', P:'♙',
k:'♚', q:'♛', r:'♜', b:'♝', n:'♞', p:'♟' };
const MATE = TUNE.checkmateScore;
// piece-square tables — index 0..63, rank 0 = the piece's home rank (white orientation)
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],
};
/* =====================================================================
SECTION 2 — BOARD HELPERS
===================================================================== */
function pieceType(p){ return p.toUpperCase(); }
function pieceColor(p){ return p === p.toUpperCase() ? 'w' : 'b'; }
function other(c){ return c === 'w' ? 'b' : 'w'; }
function sqName(i){ return FILES[i % 8] + (Math.floor(i / 8) + 1); }
function inBoard(f, r){ return f >= 0 && f < 8 && r >= 0 && r < 8; }
function mirror(i){ const f = i % 8, r = Math.floor(i / 8); return (7 - r) * 8 + f; }
function makeEmptyBoard(){ return new Array(64).fill(null); }
function initialBoard(){
const b = makeEmptyBoard();
const back = ['R','N','B','Q','K','B','N','R'];
for (let f = 0; f < 8; f++){
b[f] = back[f]; // white back rank (rank index 0)
b[8 + f] = 'P'; // white pawns
b[56 + f] = back[f].toLowerCase(); // black back rank
b[48 + f] = 'p'; // black pawns
}
return b;
}
function newState(){
return {
board: initialBoard(),
turn: 'w',
castling: { K:true, Q:true, k:true, q:true },
ep: -1, // en-passant target square, -1 if none
halfmove: 0,
fullmove: 1,
};
}
function cloneState(s){
return {
board: s.board.slice(),
turn: s.turn,
castling: { ...s.castling },
ep: s.ep,
halfmove: s.halfmove,
fullmove: s.fullmove,
};
}
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;
}
/* Attack detection: is square `sq` attacked by any piece of `byColor`? */
function isSquareAttacked(board, sq, byColor){
const f = sq % 8, r = Math.floor(sq / 8);
const test = (i, type) => { const p = board[i]; return p && p.toUpperCase() === type && pieceColor(p) === byColor; };
// pawns — a pawn attacks diagonally forward; check the two squares it could come from
const prow = byColor === 'w' ? r - 1 : r + 1;
if (prow >= 0 && prow < 8){
for (const df of [-1, 1]){
const nf = f + df;
if (nf >= 0 && nf < 8 && test(prow * 8 + nf, 'P')) return true;
}
}
// knights
const kDirs = [[1,2],[2,1],[2,-1],[1,-2],[-1,-2],[-2,-1],[-2,1],[-1,2]];
for (const [df, dr] of kDirs){
const nf = f + df, nr = r + dr;
if (inBoard(nf, nr) && test(nr * 8 + nf, 'N')) return true;
}
// king
const kDirs2 = [[1,0],[1,1],[0,1],[-1,1],[-1,0],[-1,-1],[0,-1],[1,-1]];
for (const [df, dr] of kDirs2){
const nf = f + df, nr = r + dr;
if (inBoard(nf, nr) && test(nr * 8 + nf, 'K')) return true;
}
// diagonal sliders (bishop / queen)
for (const [df, dr] of [[1,1],[1,-1],[-1,1],[-1,-1]]){
let nf = f + df, nr = r + dr;
while (inBoard(nf, nr)){
const p = board[nr * 8 + nf];
if (p){ const t = p.toUpperCase(); if (pieceColor(p) === byColor && (t === 'B' || t === 'Q')) return true; break; }
nf += df; nr += dr;
}
}
// orthogonal sliders (rook / queen)
for (const [df, dr] of [[1,0],[-1,0],[0,1],[0,-1]]){
let nf = f + df, nr = r + dr;
while (inBoard(nf, nr)){
const p = board[nr * 8 + nf];
if (p){ const t = p.toUpperCase(); if (pieceColor(p) === byColor && (t === 'R' || t === 'Q')) return true; break; }
nf += df; nr += dr;
}
}
return false;
}
function kingInCheck(board, color){ const k = findKing(board, color); return k >= 0 && isSquareAttacked(board, k, other(color)); }
/* =====================================================================
SECTION 3 — MOVE GENERATION (generator + legality filter)
===================================================================== */
function mkMove(from, to, piece, captured, opts = {}){
return { from, to, piece, captured, promo: opts.promo || null, castle: opts.castle || null,
double: !!opts.double, ep: !!opts.ep };
}
function genPawnMoves(state, out, i, color){
const b = state.board;
const f = i % 8, r = Math.floor(i / 8);
const dir = color === 'w' ? 1 : -1;
const startRank = color === 'w' ? 1 : 6;
const promoRank = color === 'w' ? 6 : 1;
const nr = r + dir;
if (nr >= 0 && nr < 8){
const ni = nr * 8 + f;
if (!b[ni]){
if (nr === promoRank){ for (const promo of ['Q','R','B','N']) out.push(mkMove(i, ni, b[i], null, { promo })); }
else {
out.push(mkMove(i, ni, b[i], null));
if (r === startRank){
const ni2 = (r + 2 * dir) * 8 + f;
if (!b[ni2]) out.push(mkMove(i, ni2, b[i], null, { double: true }));
}
}
}
}
// captures + en passant
for (const df of [-1, 1]){
const nf = f + df;
if (nf < 0 || nf > 7) continue;
const nr2 = r + dir;
if (nr2 < 0 || nr2 > 7) continue;
const ni = nr2 * 8 + nf;
const target = b[ni];
if (target && pieceColor(target) !== color){
if (nr2 === promoRank){ for (const promo of ['Q','R','B','N']) out.push(mkMove(i, ni, b[i], target, { promo })); }
else out.push(mkMove(i, ni, b[i], target));
} else if (state.ep === ni){
// en passant — only on the square a pawn just double-pushed through (guaranteed by how ep is set)
out.push(mkMove(i, ni, b[i], target || (color === 'w' ? 'p' : 'P'), { ep: true }));
}
}
}
const KNIGHT_OFFS = [[1,2],[2,1],[2,-1],[1,-2],[-1,-2],[-2,-1],[-2,1],[-1,2]];
const KING_OFFS = [[1,0],[1,1],[0,1],[-1,1],[-1,0],[-1,-1],[0,-1],[1,-1]];
const BISHOP_OFFS = [[1,1],[1,-1],[-1,1],[-1,-1]];
const ROOK_OFFS = [[1,0],[-1,0],[0,1],[0,-1]];
const QUEEN_OFFS = [...BISHOP_OFFS, ...ROOK_OFFS];
function genLeaper(state, out, i, color, offsets, type){
const b = state.board, f = i % 8, r = Math.floor(i / 8);
for (const [df, dr] of offsets){
const nf = f + df, nr = r + dr;
if (!inBoard(nf, nr)) continue;
const ni = nr * 8 + nf, t = b[ni];
if (!t) out.push(mkMove(i, ni, b[i], null));
else if (pieceColor(t) !== color) out.push(mkMove(i, ni, b[i], t));
}
}
function genSlider(state, out, i, color, offsets, type){
const b = state.board, f = i % 8, r = Math.floor(i / 8);
for (const [df, dr] of offsets){
let nf = f + df, nr = r + dr;
while (inBoard(nf, nr)){
const ni = nr * 8 + nf, t = b[ni];
if (!t) out.push(mkMove(i, ni, b[i], null));
else { if (pieceColor(t) !== color) out.push(mkMove(i, ni, b[i], t)); break; }
nf += df; nr += dr;
}
}
}
function genCastling(state, out, color){
// castling is only ever generated for the side to move
if (color !== state.turn) return;
const b = state.board, rank = color === 'w' ? 0 : 7, ksq = rank * 8 + 4;
if (findKing(b, color) !== ksq) return;
const enemy = other(color);
const kr = color === 'w' ? state.castling.K : state.castling.k;
const qr = color === 'w' ? state.castling.Q : state.castling.q;
// king-side: e->g (f,g empty; e,f,g not attacked)
if (kr){
if (!b[rank*8+5] && !b[rank*8+6] &&
!isSquareAttacked(b, ksq, enemy) && !isSquareAttacked(b, rank*8+5, enemy) && !isSquareAttacked(b, rank*8+6, enemy)){
out.push(mkMove(ksq, rank*8+6, b[ksq], null, { castle:'K' }));
}
}
// queen-side: e->c (b,c,d empty; e,d,c not attacked)
if (qr){
if (!b[rank*8+1] && !b[rank*8+2] && !b[rank*8+3] &&
!isSquareAttacked(b, ksq, enemy) && !isSquareAttacked(b, rank*8+3, enemy) && !isSquareAttacked(b, rank*8+2, enemy)){
out.push(mkMove(ksq, rank*8+2, b[ksq], null, { castle:'Q' }));
}
}
}
function pseudoMoves(state, color){
const out = [];
const b = state.board;
for (let i = 0; i < 64; i++){
const p = b[i];
if (!p || pieceColor(p) !== color) continue;
const t = pieceType(p);
if (t === 'P') genPawnMoves(state, out, i, color);
else if (t === 'N') genLeaper(state, out, i, color, KNIGHT_OFFS, 'N');
else if (t === 'B') genSlider(state, out, i, color, BISHOP_OFFS, 'B');
else if (t === 'R') genSlider(state, out, i, color, ROOK_OFFS, 'R');
else if (t === 'Q') genSlider(state, out, i, color, QUEEN_OFFS, 'Q');
else if (t === 'K') genLeaper(state, out, i, color, KING_OFFS, 'K');
}
genCastling(state, out, color);
return out;
}
/* apply a move to a bare board (no state bookkeeping) */
function applyMoveToArray(b, m){
const from = m.from, to = m.to;
b[to] = m.promo || b[from];
b[from] = null;
if (m.ep){
const cap = pieceColor(b[to]) === 'w' ? to - 8 : to + 8;
b[cap] = null;
}
if (m.castle){
const rank = from < 32 ? 0 : 7;
const rf = m.castle === 'K' ? rank*8+7 : rank*8+0;
const rt = m.castle === 'K' ? rank*8+5 : rank*8+3;
b[rt] = b[rf]; b[rf] = null;
}
}
/* legality — reject any move that leaves/puts own king in check */
function filterLegal(state, color){
const candidates = pseudoMoves(state, color);
const legal = [];
for (const m of candidates){
const b = state.board.slice();
applyMoveToArray(b, m);
if (!kingInCheck(b, color)) legal.push(m);
}
return legal;
}
function legalMoves(state, color){
return filterLegal(state, color || state.turn);
}
/* =====================================================================
SECTION 4 — STATE BOOKKEEPING (applyMove) + NOTATION + STATUS
===================================================================== */
function castlingAfter(m, before){
const c = { ...before };
const col = pieceColor(m.piece);
if (m.piece.toUpperCase() === 'K'){
if (col === 'w'){ c.K = false; c.Q = false; } else { c.k = false; c.q = false; }
}
// rook left its home square
if (m.from === 0) c.Q = false;
if (m.from === 7) c.K = false;
if (m.from === 56) c.q = false;
if (m.from === 63) c.k = false;
// rook captured on its home square
if (m.to === 0) c.Q = false;
if (m.to === 7) c.K = false;
if (m.to === 56) c.q = false;
if (m.to === 63) c.k = false;
return c;
}
function applyMove(state, m){
const b = state.board;
const col = pieceColor(m.piece);
state.castling = castlingAfter(m, state.castling);
state.ep = m.double ? (m.from + m.to) / 2 : -1;
state.halfmove = (m.piece.toUpperCase() === 'P' || m.captured) ? 0 : state.halfmove + 1;
applyMoveToArray(b, m);
state.turn = other(col);
if (state.turn === 'w') state.fullmove++;
}
/* Build the SAN prefix (no check/mate suffix) using the pre-move legal set. */
function baseSAN(state, m, preLegal){
const type = m.piece.toUpperCase();
let san = '';
if (m.castle) return m.castle === 'K' ? 'O-O' : 'O-O-O';
if (type === 'P'){
if (m.captured) san += FILES[m.from % 8] + 'x';
san += FILES[m.to % 8] + (Math.floor(m.to / 8) + 1);
if (m.promo) san += '=' + m.promo;
if (m.ep) san += ' e.p.';
return san;
}
san = type;
const others = preLegal.filter(x => x !== m && x.to === m.to && x.from !== m.from && x.piece.toUpperCase() === type);
if (others.length){
const sameFile = others.some(o => o.from % 8 === m.from % 8);
const sameRank = others.some(o => Math.floor(o.from / 8) === Math.floor(m.from / 8));
if (!sameFile) san += FILES[m.from % 8];
else if (!sameRank) san += String(Math.floor(m.from / 8) + 1);
else san += FILES[m.from % 8] + String(Math.floor(m.from / 8) + 1);
}
if (m.captured) san += 'x';
san += FILES[m.to % 8] + (Math.floor(m.to / 8) + 1);
return san;
}
/* Full SAN (with + / #) simulated from an arbitrary state. */
function sanFor(state, m){
const pre = legalMoves(state, state.turn);
const base = baseSAN(state, m, pre);
const ns = cloneState(state); applyMove(ns, m);
const opp = ns.turn;
const oppMoves = legalMoves(ns, opp);
const chk = kingInCheck(ns.board, opp);
if (oppMoves.length === 0) return base + (chk ? '#' : '');
return base + (chk ? '+' : '');
}
/* ---- game-ending conditions ---- */
function insufficientMaterial(board){
const pieces = [];
for (let i = 0; i < 64; i++){
const p = board[i];
if (p && p.toUpperCase() !== 'K') pieces.push({ p, sq: i });
}
if (pieces.length === 0) return true; // K vs K
if (pieces.length === 1){
const t = pieces[0].p.toUpperCase();
if (t === 'B' || t === 'N') return true; // K+B vs K, K+N vs K
return false;
}
// K+bishops all on the same color square -> draw
if (pieces.every(x => x.p.toUpperCase() === 'B')){
const cols = pieces.map(x => (x.sq % 8 + Math.floor(x.sq / 8)) % 2);
if (new Set(cols).size === 1) return true;
}
return false;
}
/* =====================================================================
SECTION 5 — EVALUATION + MINIMAX (alpha-beta) + QUIESCENCE-LITE
===================================================================== */
function evaluateSide(board, color){
let score = 0;
for (let i = 0; i < 64; i++){
const p = board[i];
if (!p || pieceColor(p) !== color) continue;
const t = p.toUpperCase();
score += TUNE.material[t];
score += PST[t][color === 'w' ? i : mirror(i)];
}
return score;
}
function evaluate(state, color){
return evaluateSide(state.board, color) - evaluateSide(state.board, other(color));
}
function moveScore(m){
const val = TUNE.material;
let s = 0;
if (m.captured) s += val[m.captured.toUpperCase()] * 10 - val[m.piece.toUpperCase()];
if (m.promo) s += val[m.promo];
if (m.castle) s += 30;
return s;
}
function orderMoves(moves){ moves.sort((a, b) => moveScore(b) - moveScore(a)); }
function quiescence(state, alpha, beta){
const stand = evaluate(state, state.turn);
if (stand >= beta) return beta;
if (stand > alpha) alpha = stand;
const moves = legalMoves(state, state.turn).filter(m => m.captured || m.promo);
orderMoves(moves);
const opp = other(state.turn);
for (const m of moves){
const ns = cloneState(state); applyMove(ns, m);
const score = -quiescence(ns, -beta, -alpha);
if (score >= beta) return beta;
if (score > alpha) alpha = score;
}
return alpha;
}
function negamax(state, depth, alpha, beta, ply){
const moves = legalMoves(state, state.turn);
if (moves.length === 0){
return kingInCheck(state.board, state.turn) ? -(MATE - ply) : 0; // mated / stalemate
}
if (depth <= 0) return quiescence(state, alpha, beta);
orderMoves(moves);
let best = -Infinity, a = alpha;
const opp = other(state.turn);
for (const m of moves){
const ns = cloneState(state); applyMove(ns, m);
const sc = -negamax(ns, depth - 1, -beta, -a, ply + 1);
if (sc > best) best = sc;
if (best > a) a = best;
if (a >= beta) break;
}
return best;
}
function search(state, depth){
const moves = legalMoves(state, state.turn);
if (moves.length === 0) return null;
orderMoves(moves);
let best = null, bestScore = -Infinity, alpha = -Infinity, beta = Infinity;
const opp = other(state.turn);
for (const m of moves){
const ns = cloneState(state); applyMove(ns, m);
const sc = -negamax(ns, depth - 1, -beta, -alpha, 1);
if (sc > bestScore){ bestScore = sc; best = m; }
if (sc > alpha) alpha = sc;
}
return best;
}
/* =====================================================================
SECTION 6 — GAME STATE (UI-facing) + PERSISTENCE
===================================================================== */
let stateStack = []; // snapshots for undo (in-memory; rebuilt from log on reload)
const game = {
state: newState(),
playerColor: 'w',
mode: 'normal', // 'normal' | 'analysis'
depth: 2,
flipped: false,
muted: false,
log: [], // move records {from,to,promo,piece,castle,double,ep,captured,san,byHuman}
selected: null,
legalCache: [],
status: 'playing',
lastMove: null,
aiThinking: false,
};
const aiColor = () => (game.mode === 'analysis' ? null : other(game.playerColor));
function snapshot(s){
return { board: s.board.slice(), turn: s.turn, castling: { ...s.castling },
ep: s.ep, halfmove: s.halfmove, fullmove: s.fullmove };
}
function restoreSnapshot(s){
game.state = { board: s.board.slice(), turn: s.turn, castling: { ...s.castling },
ep: s.ep, halfmove: s.halfmove, fullmove: s.fullmove };
}
function toFEN(s){
const rows = [];
for (let r = 7; r >= 0; r--){
let row = '', empty = 0;
for (let f = 0; f < 8; f++){
const p = s.board[r * 8 + f];
if (!p){ empty++; }
else { if (empty){ row += empty; empty = 0; } row += p; }
}
if (empty) row += empty;
rows.push(row);
}
let castling = '';
if (s.castling.K) castling += 'K';
if (s.castling.Q) castling += 'Q';
if (s.castling.k) castling += 'k';
if (s.castling.q) castling += 'q';
if (!castling) castling = '-';
const ep = s.ep >= 0 ? sqName(s.ep) : '-';
return rows.join('/') + ' ' + s.turn + ' ' + castling + ' ' + ep + ' ' + s.halfmove + ' ' + s.fullmove;
}
function serialize(){
return {
state: game.state,
playerColor: game.playerColor,
mode: game.mode,
depth: game.depth,
flipped: game.flipped,
muted: game.muted,
log: game.log,
status: game.status,
lastMove: game.lastMove,
};
}
function persist(){
try { localStorage.setItem('chess-game', JSON.stringify(serialize())); } catch (e) {}
}
function loadPersisted(){
try {
const raw = localStorage.getItem('chess-game');
if (!raw) return false;
const d = JSON.parse(raw);
game.state = { board: d.state.board, turn: d.state.turn, castling: d.state.castling,
ep: d.state.ep, halfmove: d.state.halfmove, fullmove: d.state.fullmove };
game.playerColor = d.playerColor || 'w';
game.mode = d.mode || 'normal';
game.depth = d.depth || 2;
game.flipped = !!d.flipped;
game.muted = !!d.muted;
game.log = d.log || [];
game.status = d.status || 'playing';
game.lastMove = d.lastMove || null;
// rebuild undo history by replaying the log
stateStack = [];
const s = newState();
for (const m of game.log){ s.board = s.board; applyMove(s, m); }
return true;
} catch (e){ return false; }
}
/* =====================================================================
SECTION 7 — SOUND (Web Audio)
===================================================================== */
let audioCtx = null;
function ensureAudio(){ if (!audioCtx){ const AC = window.AudioContext || window.webkitAudioContext; if (AC) audioCtx = new AC(); } }
function playSound(kind){
if (game.muted) return;
try {
ensureAudio();
if (!audioCtx) return;
const t = audioCtx.currentTime;
const o = audioCtx.createOscillator(), g = audioCtx.createGain();
let freq = 500, dur = 0.07, type = 'triangle';
if (kind === 'move'){ freq = 520; dur = 0.06; type = 'triangle'; }
else if (kind === 'capture'){ freq = 240; dur = 0.12; type = 'sawtooth'; }
else if (kind === 'check'){ freq = 760; dur = 0.20; type = 'square'; }
o.type = type; o.frequency.value = freq;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.22, t + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g); g.connect(audioCtx.destination);
o.start(t); o.stop(t + dur + 0.03);
} catch (e) {}
}
/* =====================================================================
SECTION 8 — RENDERING
===================================================================== */
function glyphFor(color, type){ return color === 'w' ? GLYPHS[type] : GLYPHS[type.toLowerCase()]; }
function castleRook(m){ const rank = m.from < 32 ? 0 : 7; return m.castle === 'K' ? rank * 8 + 5 : rank * 8 + 3; }
function renderBoard(){
const board = game.state.board;
const wrap = document.getElementById('board');
wrap.innerHTML = '';
for (let dr = 0; dr < 8; dr++){
for (let col = 0; col < 8; col++){
const actualRow = game.flipped ? dr : 7 - dr;
const idx = actualRow * 8 + col;
const sq = document.createElement('div');
sq.className = 'square ' + (((idx % 8) + Math.floor(idx / 8)) % 2 === 0 ? 'light' : 'dark');
sq.dataset.index = idx;
if (game.lastMove && (idx === game.lastMove.from || idx === game.lastMove.to)) sq.classList.add('last-move');
if (game.selected === idx) sq.classList.add('selected');
const p = board[idx];
if (p){
const sp = document.createElement('span');
sp.className = 'piece ' + (pieceColor(p) === 'w' ? 'white' : 'black');
sp.textContent = GLYPHS[p];
sq.appendChild(sp);
if (p.toUpperCase() === 'K' && pieceColor(p) === game.state.turn && kingInCheck(board, pieceColor(p))) sq.classList.add('check');
}
if (game.selected != null && game.status === 'playing'){
for (const m of game.legalCache){
if (m.from === game.selected && (m.to === idx || (m.castle && (idx === m.to || idx === castleRook(m))))){
if (m.castle){ const mk = document.createElement('span'); mk.className = 'castle-marker'; sq.appendChild(mk); }
else if (m.captured || m.ep){ const mk = document.createElement('span'); mk.className = 'ring'; sq.appendChild(mk); }
else { const mk = document.createElement('span'); mk.className = 'dot'; sq.appendChild(mk); }
break;
}
}
}
wrap.appendChild(sq);
}
}
}
function renderCaptured(){
const capW = [], capB = [];
for (const m of game.log){ if (m.captured){ (pieceColor(m.piece) === 'w' ? capW : capB).push(m.captured); } }
const val = TUNE.material;
capW.sort((a, b) => val[b.toUpperCase()] - val[a.toUpperCase()]);
capB.sort((a, b) => val[b.toUpperCase()] - val[a.toUpperCase()]);
document.getElementById('cap-w').innerHTML = capW.map(p => `<span class="p ${pieceColor(p)}">${GLYPHS[p]}</span>`).join('');
document.getElementById('cap-b').innerHTML = capB.map(p => `<span class="p ${pieceColor(p)}">${GLYPHS[p]}</span>`).join('');
const advW = capW.reduce((a, p) => a + val[p.toUpperCase()], 0) - capB.reduce((a, p) => a + val[p.toUpperCase()], 0);
document.getElementById('adv-w').textContent = advW > 0 ? '+' + Math.round(advW / 100) : '';
document.getElementById('adv-b').textContent = advW < 0 ? '+' + Math.round(-advW / 100) : '';
}
function renderTurn(){
const t = game.state.turn;
const dot = document.getElementById('turn-dot');
dot.className = 'dot ' + t;
document.getElementById('turn-text').textContent = (t === 'w' ? 'White' : 'Black') + ' to move';
document.getElementById('thinking').style.display = game.aiThinking ? 'inline' : 'none';
}
function renderMoveList(){
const el = document.getElementById('move-list');
if (game.log.length === 0){ el.innerHTML = '<div class="muted">No moves yet</div>'; return; }
let html = '';
for (let i = 0; i < game.log.length; i++){
if (i % 2 === 0) html += `<div><span class="ply">${Math.floor(i / 2) + 1}.</span>`;
html += `<span class="san">${game.log[i].san}</span>`;
if (i % 2 === 1) html += '</div>';
}
if (game.log.length % 2 === 1) html += '</div>';
el.innerHTML = html;
el.scrollTop = el.scrollHeight;
}
function renderStatusLine(){
const el = document.getElementById('status-line');
el.textContent = game.mode === 'analysis' ? 'Analysis — you control both sides' : 'You play ' + (game.playerColor === 'w' ? 'White' : 'Black');
}
function renderBanner(){
const b = document.getElementById('banner');
if (game.status === 'playing'){ b.classList.add('hidden'); return; }
b.classList.remove('hidden');
let title, sub;
if (game.status === 'checkmate'){ title = 'Checkmate!'; sub = (game.state.turn === 'w' ? 'Black' : 'White') + ' wins'; }
else if (game.status === 'stalemate'){ title = 'Stalemate'; sub = 'Draw — no legal moves'; }
else { title = 'Draw'; sub = '50-move rule or insufficient material'; }
b.innerHTML = `<div class="card"><div class="title">${title}</div><div class="sub">${sub}</div><button id="banner-new">New Game</button></div>`;
b.querySelector('#banner-new').onclick = newGame;
}
function renderAll(){ renderBoard(); renderCaptured(); renderTurn(); renderMoveList(); renderStatusLine(); renderBanner(); }
/* =====================================================================
SECTION 9 — MOVE COMMIT + STATUS + AI SCHEDULING + UNDO
===================================================================== */
function doMove(m){
if (!m) return;
const s = game.state;
const preLegal = legalMoves(s, s.turn);
const base = baseSAN(s, m, preLegal);
const movingColor = pieceColor(m.piece);
const byHuman = (game.mode === 'analysis' || movingColor === game.playerColor);
applyMove(s, m);
const opp = s.turn;
const oppMoves = legalMoves(s, opp);
const inCheck = kingInCheck(s.board, opp);
let suffix = '';
game.status = 'playing';
if (oppMoves.length === 0){
if (inCheck){ suffix = '#'; game.status = 'checkmate'; }
else { game.status = 'stalemate'; }
} else {
if (inCheck) suffix = '+';
if (s.halfmove >= 100) game.status = 'draw';
else if (insufficientMaterial(s.board)) game.status = 'draw';
}
m.san = base + suffix;
game.log.push({ from: m.from, to: m.to, promo: m.promo, piece: m.piece, castle: m.castle,
double: m.double, ep: m.ep, captured: m.captured, san: m.san, byHuman });
game.lastMove = m;
game.selected = null;
game.legalCache = legalMoves(s, s.turn);
playSound(m.captured ? 'capture' : (inCheck ? 'check' : 'move'));
renderAll();
persist();
if (game.status === 'playing' && game.mode === 'normal' && s.turn === aiColor()) scheduleAI();
}
function scheduleAI(){
if (game.status !== 'playing') return;
const ai = aiColor();
if (ai === null || game.state.turn !== ai) return;
game.aiThinking = true;
renderTurn();
setTimeout(() => {
const mv = search(game.state, game.depth);
game.aiThinking = false;
if (mv && game.status === 'playing') doMove(mv);
else renderAll();
}, 60);
}
function recomputeStatus(){
const s = game.state, opp = s.turn;
const moves = legalMoves(s, opp), inCheck = kingInCheck(s.board, opp);
game.status = 'playing';
if (moves.length === 0){ if (inCheck) game.status = 'checkmate'; else game.status = 'stalemate'; }
else if (s.halfmove >= 100) game.status = 'draw';
else if (insufficientMaterial(s.board)) game.status = 'draw';
}
function rebuildTo(n){
game.state = newState();
for (let i = 0; i < n; i++) applyMove(game.state, game.log[i]);
game.log = game.log.slice(0, n);
game.selected = null;
game.lastMove = n > 0 ? game.log[n - 1] : null;
recomputeStatus();
game.legalCache = legalMoves(game.state, game.state.turn);
game.aiThinking = false;
renderAll();
persist();
}
function undo(){
let h = -1;
for (let i = game.log.length - 1; i >= 0; i--) if (game.log[i].byHuman){ h = i; break; }
if (h < 0) return;
rebuildTo(h); // keep moves [0 .. h-1], i.e. back to before the player's last move
}
function newGame(){
game.state = newState();
game.log = [];
game.selected = null;
game.status = 'playing';
game.lastMove = null;
game.aiThinking = false;
game.legalCache = legalMoves(game.state, game.state.turn);
renderAll();
persist();
scheduleAI(); // fires if the AI is White (player chose Black)
}
/* =====================================================================
SECTION 10 — INTERACTION (click-to-move + drag & drop)
===================================================================== */
function humanCanMove(){ return game.status === 'playing' && (game.mode === 'analysis' || game.state.turn === game.playerColor); }
function isTarget(from, to){ return game.legalCache.some(m => m.from === from && m.to === to); }
function select(sq){ game.selected = sq; renderBoard(); }
function deselect(){ if (game.selected != null){ game.selected = null; renderBoard(); } }
function findMoves(from, to){ return game.legalCache.filter(m => m.from === from && m.to === to); }
function requestMove(from, to){
const cands = findMoves(from, to);
if (cands.length === 0) return false;
if (cands.some(m => m.promo)){ showPromo(from, to, cands); return true; }
doMove({ ...cands[0] });
return true;
}
function showPromo(from, to, cands){
const promo = document.getElementById('promo');
promo.classList.remove('hidden');
const color = pieceColor(cands[0].piece);
promo.innerHTML = `<div class="box"><h3>Choose promotion</h3><div class="opts">` +
['Q','R','B','N'].map(t => `<button data-t="${t}">${glyphFor(color, t)}</button>`).join('') +
`</div><div class="cancel">Cancel</div></div>`;
promo.querySelectorAll('button[data-t]').forEach(btn => {
btn.onclick = () => { promo.classList.add('hidden'); doMove({ ...cands[0], promo: btn.dataset.t }); };
});
promo.querySelector('.cancel').onclick = () => { promo.classList.add('hidden'); deselect(); };
}
let pressSquare = null, dragging = false, dragMoved = false, dragStartX = 0, dragStartY = 0;
function startDrag(e, piece){
dragging = true;
const gh = document.getElementById('drag-ghost');
gh.style.display = 'block';
gh.className = pieceColor(piece) === 'w' ? 'white' : 'black';
gh.textContent = GLYPHS[piece];
dragStartX = e.clientX; dragStartY = e.clientY;
moveGhost(e);
}
function moveGhost(e){
const gh = document.getElementById('drag-ghost');
gh.style.left = (e.clientX - 26) + 'px';
gh.style.top = (e.clientY - 34) + 'px';
}
function endDrag(){ const gh = document.getElementById('drag-ghost'); if (gh) gh.style.display = 'none'; }
function boardEvents(){
const board = document.getElementById('board');
board.addEventListener('pointerdown', (e) => {
const sq = e.target.closest('.square'); if (!sq) return;
const idx = +sq.dataset.index;
const p = game.state.board[idx];
pressSquare = idx; dragMoved = false; dragging = false;
if (humanCanMove() && p && pieceColor(p) === game.state.turn){
select(idx);
startDrag(e, p);
}
});
document.addEventListener('pointermove', (e) => {
if (pressSquare == null || !dragging) return;
if (!dragMoved && Math.hypot(e.clientX - dragStartX, e.clientY - dragStartY) > 6) dragMoved = true;
if (dragMoved){ moveGhost(e); renderBoard(); }
});
document.addEventListener('pointerup', (e) => {
if (pressSquare == null) return;
const sq = e.target.closest('.square');
const idx = sq ? +sq.dataset.index : -1;
let moved = false;
if (game.selected != null && idx >= 0 && idx !== game.selected && isTarget(game.selected, idx)){
moved = true;
requestMove(game.selected, idx);
} else if (idx >= 0 && game.selected != null && idx !== game.selected){
const p = game.state.board[idx];
if (!(p && pieceColor(p) === game.state.turn)) deselect();
}
endDrag();
pressSquare = null; dragging = false; dragMoved = false;
});
}
/* =====================================================================
SECTION 11 — DEBUG INTERFACE + INIT
===================================================================== */
function parseCoord(str){
if (typeof str !== 'string' || str.length < 4) return null;
const f1 = FILES.indexOf(str[0]), r1 = +str[1] - 1;
const f2 = FILES.indexOf(str[2]), r2 = +str[3] - 1;
if (f1 < 0 || r1 < 0 || f1 > 7 || r1 > 7 || f2 < 0 || r2 < 0 || f2 > 7 || r2 > 7) return null;
return { from: r1 * 8 + f1, to: r2 * 8 + f2 };
}
function dbgMove(str){
const c = parseCoord(str); if (!c) return null;
const m = findMoves(c.from, c.to)[0];
if (!m) return null;
const cloned = { ...m };
doMove(cloned);
return cloned.san;
}
window.game = {
fen: () => toFEN(game.state),
move: dbgMove,
legalMoves: () => legalMoves(game.state, game.state.turn).map(m => sanFor(game.state, m)),
setDepth: (d) => { game.depth = Math.max(1, Math.min(3, d)); },
};
function bindControls(){
document.getElementById('btn-new').onclick = newGame;
document.getElementById('btn-undo').onclick = undo;
document.getElementById('btn-flip').onclick = () => { game.flipped = !game.flipped; renderBoard(); persist(); };
document.getElementById('btn-mute').onclick = () => {
game.muted = !game.muted;
document.getElementById('btn-mute').textContent = game.muted ? '🔇 Sound Off' : '🔊 Sound On';
persist();
};
document.getElementById('side-select').onchange = (e) => {
const v = e.target.value;
if (v === 'both'){ game.mode = 'analysis'; game.playerColor = 'w'; }
else { game.mode = 'normal'; game.playerColor = v; }
newGame();
};
document.getElementById('diff-select').onchange = (e) => { game.depth = +e.target.value; persist(); };
}
function syncControls(){
const side = document.getElementById('side-select');
side.value = game.mode === 'analysis' ? 'both' : game.playerColor;
document.getElementById('diff-select').value = String(game.depth);
document.getElementById('btn-mute').textContent = game.muted ? '🔇 Sound Off' : '🔊 Sound On';
}
function init(){
loadPersisted();
game.legalCache = legalMoves(game.state, game.state.turn);
bindControls();
syncControls();
boardEvents();
renderAll();
if (game.status === 'playing' && game.mode === 'normal' && game.state.turn === aiColor()) scheduleAI();
}
// a drag-ghost element lives at <body> level (position:fixed)
const ghost = document.createElement('div');
ghost.id = 'drag-ghost';
document.body.appendChild(ghost);
init();
</script>
</body>
</html>