Chess
muse spark 1.2 · basic

Browser check: Artifact runtime error. This is a technical check, not a quality review.
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 53,920 bytes · SHA-256 391457993101
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Atlas Chess — Classic</title>
<style>
:root{
--bg:#0f1115;
--panel:#faf7f1;
--panel2:#fff;
--light:#f0d9b5;
--dark:#b58863;
--light-hi:#f7e9c9;
--dark-hi:#c99a6b;
--accent:#c0392b;
--accent2:#2c3e50;
--line:#e8ddd0;
--shadow:0 18px 50px rgba(0,0,0,.35);
--radius:18px;
}
*{box-sizing:border-box}
body{
margin:0;
font-family: ui-serif, Georgia, "Times New Roman", serif;
background:
radial-gradient(1200px 600px at 20% -10%, #2a2e3a 0%, transparent 60%),
radial-gradient(900px 500px at 90% 0%, #3a2f25 0%, transparent 60%),
var(--bg);
color:#1a1a1a;
min-height:100vh;
display:flex;
flex-direction:column;
}
header{
max-width:1180px;
width:100%;
margin:18px auto 0;
padding:0 18px;
display:flex;
align-items:center;
justify-content:space-between;
color:#f5efe6;
}
header h1{
font-size:22px;
letter-spacing:.14em;
font-weight:800;
margin:0;
display:flex;
gap:10px;
align-items:center;
}
header h1 span{font-weight:400;opacity:.7;letter-spacing:.06em;font-size:13px}
.header-actions{display:flex;gap:8px;align-items:center}
.pill{
background:rgba(255,255,255,.08);
border:1px solid rgba(255,255,255,.12);
color:#f5efe6;
padding:7px 12px;
border-radius:999px;
font-size:12px;
letter-spacing:.08em;
text-transform:uppercase;
}
.wrap{
max-width:1180px;
width:100%;
margin:18px auto;
padding:0 18px 28px;
display:grid;
grid-template-columns: 1.22fr .78fr;
gap:22px;
}
@media(max-width:900px){ .wrap{grid-template-columns:1fr} }
.board-card{
background:linear-gradient(180deg, #fffcf7 0%, #fdf6eb 100%);
border-radius:var(--radius);
padding:18px;
box-shadow:var(--shadow);
border:1px solid rgba(0,0,0,.06);
}
.board-top{
display:flex;
align-items:center;
justify-content:space-between;
margin-bottom:12px;
gap:10px;
flex-wrap:wrap;
}
.turn{
display:flex;
align-items:center;
gap:10px;
font-size:13px;
letter-spacing:.08em;
text-transform:uppercase;
color:#5a4a3a;
font-weight:700;
}
.dot{width:10px;height:10px;border-radius:50%;background:#2ecc71;box-shadow:0 0 0 6px rgba(46,204,113,.15)}
.dot.black{background:#2c3e50;box-shadow:0 0 0 6px rgba(44,62,80,.15)}
.status{
font-size:12px;
padding:6px 10px;
border-radius:999px;
background:#fff;
border:1px solid var(--line);
color:#6b5a4a;
}
.captured{ display:flex; gap:6px; min-height:24px; align-items:center; flex-wrap:wrap; font-size:18px; line-height:1}
.captured span{opacity:.9}
.captured .label{font-size:10px; letter-spacing:.12em; text-transform:uppercase; color:#9a8a7a; margin-right:6px; font-family:system-ui,sans-serif}
.board-wrap{
position:relative;
aspect-ratio:1;
background:#3a2a1c;
padding:12px;
border-radius:14px;
box-shadow:inset 0 0 0 1px rgba(255,255,255,.08), 0 12px 30px rgba(0,0,0,.25);
}
.board{
display:grid;
grid-template-columns:repeat(8,1fr);
grid-template-rows:repeat(8,1fr);
width:100%;height:100%;
border-radius:10px;
overflow:hidden;
border:4px solid #2b1d12;
position:relative;
}
.sq{
position:relative;
display:flex;
align-items:center;
justify-content:center;
font-size:44px;
line-height:1;
user-select:none;
cursor:pointer;
}
@media(max-width:560px){ .sq{font-size:34px} }
.sq.light{background:var(--light)}
.sq.dark{background:var(--dark)}
.sq.highlight::after{
content:"";
position:absolute;inset:0;
background:rgba(255,235,120,.62);
pointer-events:none;
}
.sq.last::after{
content:"";
position:absolute;inset:0;
background:rgba(110,180,255,.38);
pointer-events:none;
}
.sq.in-check::after{
content:"";
position:absolute;inset:0;
background:radial-gradient(circle at center, rgba(231,76,60,.9) 0 6px, transparent 7px), rgba(231,76,60,.22);
pointer-events:none;
box-shadow:inset 0 0 0 3px #c0392b;
}
.sq.can-move::before{
content:"";
width:18px;height:18px;
border-radius:50%;
background:rgba(0,0,0,.22);
position:absolute;
box-shadow:0 1px 0 rgba(255,255,255,.6) inset;
}
.sq.can-capture::before{
content:"";
position:absolute;inset:6px;
border-radius:50%;
border:5px solid rgba(0,0,0,.38);
background:transparent;
}
.sq.selected::after{
content:"";
position:absolute;inset:0;
background:rgba(255,214,90,.55);
box-shadow:inset 0 0 0 3px #ffb700;
pointer-events:none;
}
.piece{
position:relative;
z-index:2;
filter: drop-shadow(0 1px 0 rgba(0,0,0,.4)) drop-shadow(0 3px 6px rgba(0,0,0,.25));
transition:transform .08s;
cursor:grab;
}
.piece.w{ color:#ffffff; -webkit-text-stroke:1.2px #2b1d12; text-shadow: 0 1px 0 #2b1d12; }
.piece.b{ color:#111111; -webkit-text-stroke:.6px #fff; }
.sq:active .piece{transform:scale(.96)}
.coords{
position:absolute;
font-family:system-ui,sans-serif;
font-size:10px;
font-weight:800;
opacity:.55;
pointer-events:none;
}
.coords.file{bottom:3px;right:4px}
.coords.rank{top:3px;left:4px}
/* promotion */
.promo{
position:absolute;
z-index:20;
background:#fff;
border-radius:12px;
box-shadow:0 18px 40px rgba(0,0,0,.35);
border:1px solid var(--line);
display:flex;
overflow:hidden;
}
.promo button{
font-size:32px;
width:56px;height:56px;
border:0;
background:#fff;
cursor:pointer;
}
.promo button:hover{background:#fdf0d5}
/* side panel */
.panel{
display:flex;
flex-direction:column;
gap:16px;
}
.card{
background:var(--panel);
border-radius:var(--radius);
padding:16px;
box-shadow:var(--shadow);
border:1px solid rgba(0,0,0,.06);
}
.card h3{
margin:0 0 12px;
font-size:11px;
letter-spacing:.14em;
text-transform:uppercase;
color:#8a7a6a;
display:flex;
justify-content:space-between;
align-items:center;
}
.controls{ display:grid; grid-template-columns:1fr 1fr; gap:8px}
.btn{
padding:10px 12px;
border-radius:999px;
border:1px solid var(--line);
background:#fff;
font-size:13px;
font-weight:700;
letter-spacing:.04em;
cursor:pointer;
display:flex;
align-items:center;
justify-content:center;
gap:6px;
}
.btn.primary{ background:var(--accent2); color:#fff; border-color:var(--accent2)}
.btn:active{transform:scale(.98)}
.row{ display:flex; gap:8px; flex-wrap:wrap; align-items:center}
select{
padding:8px 10px;
border-radius:999px;
border:1px solid var(--line);
background:#fff;
font-size:13px;
}
.moves{
max-height:220px;
overflow:auto;
background:#fff;
border:1px solid var(--line);
border-radius:12px;
padding:8px;
}
.moves ol{ margin:0; padding:0 0 0 22px; columns:2; column-gap:18px}
.moves li{
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size:13px;
padding:3px 6px;
border-radius:6px;
break-inside:avoid;
}
.moves li.active{ background:#2c3e50; color:#fff}
.banner{
padding:14px;
border-radius:12px;
text-align:center;
font-weight:800;
letter-spacing:.04em;
display:none;
}
.banner.show{ display:block}
.banner.mate{ background:#c0392b; color:#fff}
.banner.draw{ background:#f1c40f; color:#2c3e50}
.banner.check{ background:#eaf6ff; color:#2c3e50; border:1px solid #cfe8ff}
.small{ font-size:11px; color:#9a8a7a; font-family:system-ui,sans-serif}
.switch{ display:flex; align-items:center; gap:8px; font-size:13px}
.toggle{ width:36px;height:22px;background:#ddd;border-radius:999px;position:relative;cursor:pointer; border:1px solid #ccc}
.toggle.on{ background:#2ecc71; border-color:#27ae60}
.toggle::after{ content:""; position:absolute; width:16px;height:16px;background:#fff;border-radius:50%;top:2px;left:2px;transition:.18s; box-shadow:0 1px 3px rgba(0,0,0,.3)}
.toggle.on::after{ left:17px}
.flip .board{ transform: rotate(180deg)}
.flip .piece{ transform: rotate(180deg)}
.flip .sq.can-move::before, .flip .sq.can-capture::before{ transform:rotate(180deg)}
</style>
</head>
<body>
<header>
<h1>ATLAS ♔ <span>Classic Chess • Wood & Slate</span></h1>
<div class="header-actions">
<span class="pill" id="evalPill">Even</span>
<span class="pill" id="plyPill">Ply 1</span>
</div>
</header>
<div class="wrap" id="wrap">
<div class="board-card" id="boardCard">
<div class="board-top">
<div class="turn"><span class="dot" id="turnDot"></span> <span id="turnText">White to move</span> <span class="status" id="statusText">Ready</span></div>
<div class="row">
<button class="btn" id="flipBtn">⇅ Flip</button>
<button class="btn" id="muteBtn">🔊 Sound</button>
</div>
</div>
<div class="captured" id="capturedBlack"><span class="label">Captured by White</span></div>
<div class="board-wrap" id="boardWrap">
<div class="board" id="board"></div>
<div id="promoAnchor" style="position:absolute;inset:0;pointer-events:none"></div>
</div>
<div class="captured" id="capturedWhite" style="margin-top:10px"><span class="label">Captured by Black</span></div>
<div class="row" style="margin-top:12px; justify-content:space-between">
<span class="small">Click or drag to move • Dots = quiet, Rings = capture</span>
<span class="small" id="fenSmall" style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
</div>
</div>
<div class="panel">
<div class="card">
<h3>Game Controls</h3>
<div class="controls">
<button class="btn primary" id="newBtn">↻ New Game</button>
<button class="btn" id="undoBtn">↩ Undo</button>
</div>
<div class="row" style="margin-top:12px">
<label class="small">Play as</label>
<select id="colorSel">
<option value="w">White (human)</option>
<option value="b">Black (human)</option>
<option value="both">Both / Analysis</option>
</select>
<label class="small">AI Depth</label>
<select id="depthSel">
<option value="1">Depth 1 — Quick</option>
<option value="2" selected>Depth 2 — Balanced</option>
<option value="3">Depth 3 — Strong</option>
</select>
</div>
<div class="row" style="margin-top:10px">
<label class="switch"><span class="small">AI Thinking</span> <span id="thinking" class="small" style="color:#c0392b; display:none">● thinking…</span></label>
<div style="margin-left:auto" class="switch">
<span class="small">Flip board</span>
<div class="toggle" id="flipToggle"></div>
</div>
</div>
</div>
<div class="card">
<h3>Move List <span class="small" id="moveCount">0 moves</span></h3>
<div class="moves" id="movesBox"><ol id="moveList"></ol></div>
<div id="banner" class="banner" style="margin-top:10px"></div>
</div>
<div class="card">
<h3>How to verify</h3>
<div class="small" style="line-height:1.6">
• Start position has 20 legal moves<br>
• Fool's mate: <code>f3 e5 g4 Qh4#</code> → checkmate<br>
• En passant appears only one move<br>
• Pin test: exposes king → rejected<br>
Use <code>game.legalMoves().length</code>, <code>game.fen()</code>, <code>game.move('e2e4')</code> in console.
</div>
</div>
</div>
</div>
<script>
/* =======================
TUNABLE CONSTANTS
======================= */
const COLORS = {
light: '#f0d9b5',
dark: '#b58863',
highlight: 'rgba(255,235,120,.62)',
lastMove: 'rgba(110,180,255,.38)',
check: 'rgba(231,76,60,.9)'
};
const AI_DEPTHS = { 1:1, 2:2, 3:3 };
const EVAL_WEIGHTS = {
P:100, N:320, B:330, R:500, Q:900, K:20000,
mobility: 2
};
// Piece-square tables (from white perspective, idx 0=a1)
// Simple bonuses
const PST = (() => {
const mk = (arr)=>arr;
// rank 8 to 1? our idx 0=a1 bottom. So PST arrays indexed by idx.
// We'll define by rank order bottom to top.
const 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
];
const 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
];
const 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
];
const 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
];
const 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
];
const k_mid = [
-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
];
return {P:p,N:n,B:b,R:r,Q:q,K:k_mid};
})();
/* =======================
ENGINE - board representation & move generation
======================= */
class Engine {
constructor(){
this.reset();
}
reset(){
// board 64, 0=a1
this.board = new Array(64).fill(null);
const back = ['R','N','B','Q','K','B','N','R'];
for(let c=0;c<8;c++){
this.board[c] = {type:back[c], color:'w'};
this.board[c+8] = {type:'P', color:'w'};
this.board[48+c] = {type:'P', color:'b'};
this.board[56+c] = {type:back[c], color:'b'};
}
this.turn='w';
this.castling={K:true,Q:true,k:true,q:true};
this.enPassant=null;
this.halfmove=0;
this.fullmove=1;
this.history=[];
this.moveHistory=[]; // SAN
this.captured={w:[],b:[]};
}
cloneBoard(){ return this.board.map(p=>p?{...p}:null); }
idxToAlg(idx){
const c=idx%8, r=Math.floor(idx/8);
return String.fromCharCode(97+c)+(r+1);
}
algToIdx(alg){
const c=alg.charCodeAt(0)-97;
const r=parseInt(alg[1])-1;
return r*8+c;
}
findKing(color){
for(let i=0;i<64;i++) if(this.board[i] && this.board[i].type==='K' && this.board[i].color===color) return i;
return -1;
}
isOnBoard(r,c){ return r>=0&&r<8&&c>=0&&c<8; }
pieceAt(idx){ return this.board[idx]; }
isAttacked(sq, byColor){
const r=Math.floor(sq/8), c=sq%8;
// pawn attacks
const dir = byColor==='w'? -1 : 1; // pawn that attacks sq must be one rank behind?
// if byColor white, pawn is south of sq (r-1)
const pr = r + (byColor==='w' ? -1 : 1);
// Actually white pawns move north (+1). To attack sq, white pawn must be one rank south: r-1
// So pr = r -1 for white? Let's think: sq at r=3 (rank4). White pawn at r=2 attacks r=3. So byColor w => pr = r-1 . byColor b => pr = r+1
const pawnRank = byColor==='w' ? r-1 : r+1;
for(let dc of [-1,1]){
const pc = c+dc;
if(this.isOnBoard(pawnRank,pc)){
const idx=pawnRank*8+pc;
const p=this.board[idx];
if(p && p.color===byColor && p.type==='P') return true;
}
}
// knights
const knightDeltas=[[-2,-1],[-2,1],[-1,-2],[-1,2],[1,-2],[1,2],[2,-1],[2,1]];
for(let [dr,dc] of knightDeltas){
const nr=r+dr,nc=c+dc;
if(this.isOnBoard(nr,nc)){
const p=this.board[nr*8+nc];
if(p && p.color===byColor && p.type==='N') return true;
}
}
// bishops / queens diagonals
const diag=[[-1,-1],[-1,1],[1,-1],[1,1]];
for(let [dr,dc] of diag){
let nr=r+dr,nc=c+dc;
while(this.isOnBoard(nr,nc)){
const idx=nr*8+nc;
const p=this.board[idx];
if(p){
if(p.color===byColor && (p.type==='B' || p.type==='Q')) return true;
break;
}
nr+=dr; nc+=dc;
}
}
// rooks / queens orthogonal
const orth=[[-1,0],[1,0],[0,-1],[0,1]];
for(let [dr,dc] of orth){
let nr=r+dr,nc=c+dc;
while(this.isOnBoard(nr,nc)){
const idx=nr*8+nc;
const p=this.board[idx];
if(p){
if(p.color===byColor && (p.type==='R' || p.type==='Q')) return true;
break;
}
nr+=dr; nc+=dc;
}
}
// king
for(let dr=-1;dr<=1;dr++) for(let dc=-1;dc<=1;dc++){
if(dr===0&&dc===0) continue;
const nr=r+dr,nc=c+dc;
if(this.isOnBoard(nr,nc)){
const p=this.board[nr*8+nc];
if(p && p.color===byColor && p.type==='K') return true;
}
}
return false;
}
inCheck(color){
const k=this.findKing(color);
if(k===-1) return false;
return this.isAttacked(k, color==='w'?'b':'w');
}
// generate pseudo moves
pseudoMoves(){
const moves=[];
const us=this.turn;
const them= us==='w'?'b':'w';
for(let idx=0;idx<64;idx++){
const p=this.board[idx];
if(!p || p.color!==us) continue;
const r=Math.floor(idx/8), c=idx%8;
if(p.type==='P'){
const dir = us==='w'?1:-1;
const startRank = us==='w'?1:6;
const promoRank = us==='w'?7:0;
// forward 1
let nr=r+dir;
if(this.isOnBoard(nr,c) && !this.board[nr*8+c]){
if(nr===promoRank){
for(let pr of ['Q','R','B','N']) moves.push({from:idx,to:nr*8+c,promotion:pr,isCapture:false});
} else {
moves.push({from:idx,to:nr*8+c});
// double
if(r===startRank){
let nr2=r+2*dir;
if(!this.board[nr2*8+c]) moves.push({from:idx,to:nr2*8+c, isDouble:true});
}
}
}
// captures
for(let dc of [-1,1]){
const nc=c+dc;
if(!this.isOnBoard(nr,nc)) continue;
const to=nr*8+nc;
const target=this.board[to];
if(target && target.color===them){
if(nr===promoRank){
for(let pr of ['Q','R','B','N']) moves.push({from:idx,to, promotion:pr, isCapture:true});
} else moves.push({from:idx,to,isCapture:true});
}
// en passant
if(this.enPassant!==null && to===this.enPassant){
moves.push({from:idx,to,isEnPassant:true,isCapture:true});
}
}
} else if(p.type==='N'){
const deltas=[[-2,-1],[-2,1],[-1,-2],[-1,2],[1,-2],[1,2],[2,-1],[2,1]];
for(let [dr,dc] of deltas){
const nr=r+dr,nc=c+dc;
if(!this.isOnBoard(nr,nc)) continue;
const to=nr*8+nc;
const t=this.board[to];
if(!t) moves.push({from:idx,to});
else if(t.color===them) moves.push({from:idx,to,isCapture:true});
}
} else if(p.type==='B' || p.type==='R' || p.type==='Q'){
const dirs=[];
if(p.type==='B' || p.type==='Q') dirs.push(...[[-1,-1],[-1,1],[1,-1],[1,1]]);
if(p.type==='R' || p.type==='Q') dirs.push(...[[-1,0],[1,0],[0,-1],[0,1]]);
for(let [dr,dc] of dirs){
let nr=r+dr,nc=c+dc;
while(this.isOnBoard(nr,nc)){
const to=nr*8+nc;
const t=this.board[to];
if(!t) moves.push({from:idx,to});
else {
if(t.color===them) moves.push({from:idx,to,isCapture:true});
break;
}
nr+=dr; nc+=dc;
}
}
} else if(p.type==='K'){
for(let dr=-1;dr<=1;dr++) for(let dc=-1;dc<=1;dc++){
if(dr===0&&dc===0) continue;
const nr=r+dr,nc=c+dc;
if(!this.isOnBoard(nr,nc)) continue;
const to=nr*8+nc;
const t=this.board[to];
if(!t) moves.push({from:idx,to});
else if(t.color===them) moves.push({from:idx,to,isCapture:true});
}
// castling
if(!this.inCheck(us)){
const rank = us==='w'?0:7;
// kingside
if( (us==='w' && this.castling.K) || (us==='b' && this.castling.k)){
const f= rank*8+5, g= rank*8+6;
const rookIdx= rank*8+7;
if(!this.board[f] && !this.board[g] && this.board[rookIdx] && this.board[rookIdx].type==='R'){
if(!this.isAttacked(f, them) && !this.isAttacked(g, them)){
moves.push({from:idx,to:g,isCastling:'K'});
}
}
}
if( (us==='w' && this.castling.Q) || (us==='b' && this.castling.q)){
const d= rank*8+3, c1= rank*8+2, b= rank*8+1;
const rookIdx= rank*8+0;
if(!this.board[d] && !this.board[c1] && !this.board[b] && !this.board[rookIdx]){
// Actually b can be occupied? standard requires b empty for queen side? No, only c,d must be empty; b can be anything? But spec says squares between empty, so c,d,b all empty for comprehensive. Common rule: squares between empty includes b. Keep check b empty.
// Alternative: allow b occupied? but still castling requires b empty in official? For queen side, squares between king and rook are b,c,d. So all must be empty.
// So check.
if(!this.isAttacked(d, them) && !this.isAttacked(c1, them)){
// also rook exists
if(this.board[rookIdx] && this.board[rookIdx].type==='R')
moves.push({from:idx,to:c1,isCastling:'Q'});
}
} else if(!this.board[d] && !this.board[c1]){
// fallback if b not required? Keep first condition, but handle b piece case: if b occupied then can't castle anyway
// We'll not add extra
}
}
}
}
}
return moves;
}
legalMoves(){
const pseudo=this.pseudoMoves();
const legal=[];
const us=this.turn;
for(let m of pseudo){
const state=this.makeMove(m, true); // test
const kingStill = this.findKing(us);
// after making move, turn has flipped, but check king of mover
const attacked = this.isAttacked(kingStill, us==='w'?'b':'w');
this.unmakeMove(state);
if(!attacked) legal.push(m);
}
// move ordering: captures/promotions first
legal.sort((a,b)=>{
const ac = (a.isCapture?10:0)+(a.promotion?9:0)+(a.isCastling?1:0);
const bc = (b.isCapture?10:0)+(b.promotion?9:0)+(b.isCastling?1:0);
return bc-ac;
});
return legal;
}
makeMove(move, testMode=false){
// save state
const saved={
board:this.cloneBoard(),
turn:this.turn,
castling:{...this.castling},
enPassant:this.enPassant,
halfmove:this.halfmove,
fullmove:this.fullmove,
captured: testMode?null: {w:[...this.captured.w], b:[...this.captured.b]},
moveHistory: testMode?null:[...this.moveHistory]
};
const us=this.turn;
const them=us==='w'?'b':'w';
const fromPiece=this.board[move.from];
let capturedPiece=null;
let isCapture = !!move.isCapture;
// en passant capture
if(move.isEnPassant){
const capIdx = us==='w'? move.to-8 : move.to+8;
capturedPiece=this.board[capIdx];
this.board[capIdx]=null;
} else {
capturedPiece=this.board[move.to];
}
// handle capture tracking
if(!testMode && capturedPiece){
// store captured for UI (store type)
const capColor=capturedPiece.color;
// captured by us -> push to our captured list? We maintain w = white captured black pieces etc? We'll map
if(us==='w') this.captured.w.push(capturedPiece.type);
else this.captured.b.push(capturedPiece.type);
}
// move piece
this.board[move.to]= fromPiece;
this.board[move.from]=null;
// promotion
if(move.promotion){
this.board[move.to]={type:move.promotion, color:us};
}
// castling rook
if(move.isCastling){
const rank= us==='w'?0:7;
if(move.isCastling==='K'){
const rf= rank*8+7, rt= rank*8+5;
this.board[rt]= this.board[rf];
this.board[rf]=null;
} else {
const rf= rank*8+0, rt= rank*8+3;
this.board[rt]= this.board[rf];
this.board[rf]=null;
}
}
// update castling rights
if(fromPiece.type==='K'){
if(us==='w'){ this.castling.K=false; this.castling.Q=false; }
else { this.castling.k=false; this.castling.q=false; }
}
if(fromPiece.type==='R'){
if(us==='w'){
if(move.from===0) this.castling.Q=false;
if(move.from===7) this.castling.K=false;
} else {
if(move.from===56) this.castling.q=false;
if(move.from===63) this.castling.k=false;
}
}
// if captured rook on original square, remove rights
if(capturedPiece && capturedPiece.type==='R'){
if(move.to===0) this.castling.Q=false;
if(move.to===7) this.castling.K=false;
if(move.to===56) this.castling.q=false;
if(move.to===63) this.castling.k=false;
// en passant capture rook?
if(move.isEnPassant){
const capIdx = us==='w'? move.to-8 : move.to+8;
if(capIdx===0) this.castling.Q=false;
if(capIdx===7) this.castling.K=false;
if(capIdx===56) this.castling.q=false;
if(capIdx===63) this.castling.k=false;
}
}
// en passant square
if(fromPiece.type==='P' && Math.abs(move.to - move.from)===16){
this.enPassant = us==='w' ? move.from+8 : move.from-8;
} else {
this.enPassant=null;
}
// halfmove
if(fromPiece.type==='P' || isCapture) this.halfmove=0;
else this.halfmove++;
// fullmove
if(us==='b') this.fullmove++;
this.turn=them;
// SAN generation for history if not test
if(!testMode){
const san=this.moveToSAN(move, capturedPiece, saved);
this.moveHistory.push(san);
}
return saved;
}
unmakeMove(saved){
this.board=saved.board;
this.turn=saved.turn;
this.castling=saved.castling;
this.enPassant=saved.enPassant;
this.halfmove=saved.halfmove;
this.fullmove=saved.fullmove;
if(saved.captured){ this.captured=saved.captured; }
if(saved.moveHistory){ this.moveHistory=saved.moveHistory; }
}
moveToSAN(move, capturedPiece, saved){
const us=saved.turn;
const piece=this.board[move.to] ? this.board[move.to] : saved.board[move.from];
// actually after make, board has piece
// castling
if(move.isCastling) return move.isCastling==='K' ? 'O-O' : 'O-O-O';
let san='';
const isCap = !!capturedPiece || move.isEnPassant;
// disambiguation
if(piece.type!=='P'){
san+=piece.type;
// find other pieces that could move to same square
// need to generate legal moves for disam? Use pseudo for same piece type
// simplistic: check files/ranks
const pseudo = (() => {
// generate attacks for disam by brute force: see other pieces of same type that can go to target
let others=[];
for(let i=0;i<64;i++){
if(i===move.from) continue;
const p=saved.board[i];
if(!p || p.color!==us || p.type!==piece.type) continue;
// can p move to move.to ignoring check? Use ray logic simplified: check if move is in pseudo for that piece
// brute: generate pseudo for that piece alone
// We'll approximate by reusing pseudoMoves but filter
}
return others;
})();
// proper disambiguation: we compute legal moves before move
// Instead compute attackers
let candidates=[];
// temporary generate legal moves before move to find disambiguation
// We have saved board: generate pseudo from saved state, filter? Easier: brute check sliding
for(let i=0;i<64;i++){
if(i===move.from) continue;
const p=saved.board[i];
if(!p || p.color!==us || p.type!==piece.type) continue;
if(this.canPieceMove(i, move.to, saved.board, saved.enPassant, saved.castling, isCap)) candidates.push(i);
}
if(candidates.length>0){
const sameFile=candidates.some(c=> c%8===move.from%8);
const sameRank=candidates.some(c=> Math.floor(c/8)===Math.floor(move.from/8));
if(sameFile && sameRank) san+= this.idxToAlg(move.from);
else if(sameFile) san+= (Math.floor(move.from/8)+1);
else if(sameRank) san+= String.fromCharCode(97+ move.from%8);
else san+= String.fromCharCode(97+ move.from%8);
}
} else {
if(isCap) san+= String.fromCharCode(97+ move.from%8);
}
if(isCap) san+='x';
san+= this.idxToAlg(move.to);
if(move.promotion) san+='='+move.promotion;
if(move.isEnPassant) san+=' e.p.';
// check / mate
const them= us==='w'?'b':'w';
const kingPos=this.findKing(them);
const inChk = kingPos!==-1 && this.isAttacked(kingPos, us);
if(inChk){
const lm=this.legalMoves(); // after move, legal moves for them
san+= lm.length===0 ? '#' : '+';
}
return san;
}
canPieceMove(from,to,board,enPass,castling,isCap){
// helper for disambiguation: can piece at from go to to ignoring king safety? approximate
const p=board[from];
if(!p) return false;
const r=Math.floor(from/8), c=from%8;
const tr=Math.floor(to/8), tc=to%8;
const dr=tr-r, dc=tc-c;
if(p.type==='N'){
return (Math.abs(dr)===2 && Math.abs(dc)===1) || (Math.abs(dr)===1 && Math.abs(dc)===2);
}
if(p.type==='K'){
return Math.max(Math.abs(dr),Math.abs(dc))===1;
}
if(p.type==='P'){
const dir=p.color==='w'?1:-1;
if(dc===0 && !isCap) return dr===dir || dr===2*dir;
if(Math.abs(dc)===1 && dr===dir) return true;
return false;
}
if(p.type==='B' || p.type==='R' || p.type==='Q'){
const isDiag=Math.abs(dr)===Math.abs(dc);
const isOrth=dr===0 || dc===0;
if(p.type==='B' && !isDiag) return false;
if(p.type==='R' && !isOrth) return false;
if(p.type==='Q' && !(isDiag||isOrth)) return false;
const stepR= dr===0?0: dr/Math.abs(dr);
const stepC= dc===0?0: dc/Math.abs(dc);
let nr=r+stepR, nc=c+stepC;
while(nr!==tr || nc!==tc){
if(board[nr*8+nc]) return false;
nr+=stepR; nc+=stepC;
}
return true;
}
return false;
}
fen(){
let fen='';
for(let r=7;r>=0;r--){
let empty=0;
for(let c=0;c<8;c++){
const p=this.board[r*8+c];
if(!p) empty++;
else{
if(empty>0){ fen+=empty; empty=0; }
const ch=p.type;
fen+= p.color==='w'? ch : ch.toLowerCase();
}
}
if(empty>0) fen+=empty;
if(r>0) fen+='/';
}
fen+= ` ${this.turn} `;
let cs='';
if(this.castling.K) cs+='K';
if(this.castling.Q) cs+='Q';
if(this.castling.k) cs+='k';
if(this.castling.q) cs+='q';
if(cs==='') cs='-';
fen+= cs+' ';
fen+= this.enPassant!==null ? this.idxToAlg(this.enPassant) : '-';
fen+= ` ${this.halfmove} ${this.fullmove}`;
return fen;
}
loadFen(fen){
const parts=fen.split(' ');
const rows=parts[0].split('/');
this.board=new Array(64).fill(null);
for(let r=7, idx=0; r>=0; r--){
const row=rows[7-r];
let c=0;
for(let ch of row){
if(ch>='1'&& ch<='8'){ c+= parseInt(ch); }
else{
const color= ch===ch.toUpperCase()?'w':'b';
const type= ch.toUpperCase();
this.board[r*8+c]={type,color};
c++;
}
}
}
this.turn=parts[1];
const cs=parts[2];
this.castling={K:cs.includes('K'), Q:cs.includes('Q'), k:cs.includes('k'), q:cs.includes('q')};
this.enPassant= parts[3]!=='-' ? this.algToIdx(parts[3]) : null;
this.halfmove=parseInt(parts[4]);
this.fullmove=parseInt(parts[5]);
}
isInsufficient(){
const pieces=this.board.filter(p=>p);
if(pieces.length===2) return true;
if(pieces.length===3){
const minor=pieces.find(p=>p.type==='B'||p.type==='N');
if(minor) return true;
}
return false;
}
gameOver(){
const legal=this.legalMoves();
if(legal.length===0){
return this.inCheck(this.turn) ? {over:true, result: this.turn==='w'?'0-1':'1-0', reason:'checkmate'} : {over:true, result:'½-½', reason:'stalemate'};
}
if(this.isInsufficient()) return {over:true, result:'½-½', reason:'insufficient material'};
if(this.halfmove>=100) return {over:true,result:'½-½',reason:'50-move rule'};
return {over:false};
}
// for debug: move string e2e4 or e7e8q
moveUCI(uci){
if(uci.length<4) return false;
const from=this.algToIdx(uci.slice(0,2));
const to=this.algToIdx(uci.slice(2,4));
const promo= uci.length>4 ? uci[4].toUpperCase() : null;
const legal=this.legalMoves();
const found=legal.find(m=> m.from===from && m.to===to && (promo? m.promotion===promo : !m.promotion || true));
// if promo specified must match
let target=null;
if(promo) target=legal.find(m=> m.from===from && m.to===to && m.promotion===promo);
else {
// if multiple promotion options, default to queen
const candidates=legal.filter(m=> m.from===from && m.to===to);
if(candidates.length===0) return false;
target=candidates[0];
}
if(!target) return false;
this.makeMove(target);
return true;
}
}
/* =======================
EVALUATION & AI
======================= */
function evaluate(engine){
if(engine.isInsufficient()) return 0;
let score=0;
for(let i=0;i<64;i++){
const p=engine.board[i];
if(!p) continue;
let val=0;
if(p.type==='P') val=EVAL_WEIGHTS.P + PST.P[i];
else if(p.type==='N') val=EVAL_WEIGHTS.N + PST.N[i];
else if(p.type==='B') val=EVAL_WEIGHTS.B + PST.B[i];
else if(p.type==='R') val=EVAL_WEIGHTS.R + PST.R[i];
else if(p.type==='Q') val=EVAL_WEIGHTS.Q + PST.Q[i];
else if(p.type==='K') val=EVAL_WEIGHTS.K + PST.K[i];
// flip for black? PST defined for white. For black, mirror vertically
if(p.color==='b'){
const mirrored = (7 - Math.floor(i/8))*8 + (i%8);
let pstVal=0;
if(p.type==='P') pstVal=PST.P[mirrored];
else if(p.type==='N') pstVal=PST.N[mirrored];
else if(p.type==='B') pstVal=PST.B[mirrored];
else if(p.type==='R') pstVal=PST.R[mirrored];
else if(p.type==='Q') pstVal=PST.Q[mirrored];
else if(p.type==='K') pstVal=PST.K[mirrored];
// recompute val for black
if(p.type==='P') val=EVAL_WEIGHTS.P + pstVal;
else if(p.type==='N') val=EVAL_WEIGHTS.N + pstVal;
else if(p.type==='B') val=EVAL_WEIGHTS.B + pstVal;
else if(p.type==='R') val=EVAL_WEIGHTS.R + pstVal;
else if(p.type==='Q') val=EVAL_WEIGHTS.Q + pstVal;
else if(p.type==='K') val=EVAL_WEIGHTS.K + pstVal;
score-=val;
} else {
score+=val;
}
}
// mobility bonus
const turn=engine.turn;
engine.turn='w'; const wMob=engine.legalMoves().length;
engine.turn='b'; const bMob=engine.legalMoves().length;
engine.turn=turn;
score+= (wMob - bMob)*EVAL_WEIGHTS.mobility;
return engine.turn==='w' ? score : -score; // perspective of side to move? Actually we want white positive. For minimax we return from side to move? We'll keep white positive and minimax will handle.
}
function searchBest(engine, depth){
// returns best move
const legal=engine.legalMoves();
if(legal.length===0) return null;
// sort captures first already
let best=null;
let bestScore = -Infinity;
// iterative evaluate each move
for(let m of legal){
const saved=engine.makeMove(m, true);
const score = -negamax(engine, depth-1, -Infinity, Infinity);
engine.unmakeMove(saved);
if(score > bestScore){
bestScore=score;
best=m;
}
}
return best;
}
function negamax(engine, depth, alpha, beta){
const over=engine.gameOver();
if(over.over){
if(over.reason==='checkmate') return -99999 - depth; // losing
return 0;
}
if(depth===0){
// quiescence: only captures?
// evaluation from white perspective, convert to side to move
const ev=evaluate(engine);
// evaluate returns white positive, but we need side to move perspective
// evaluate already flips? Actually we made evaluate return white positive always, so convert:
// white positive => if side to move white, positive is good; if black, positive is bad => -ev
return engine.turn==='w' ? ev : -ev;
}
const moves=engine.legalMoves();
// move ordering already captures first
let best=-Infinity;
for(let m of moves){
const saved=engine.makeMove(m,true);
const score= -negamax(engine, depth-1, -beta, -alpha);
engine.unmakeMove(saved);
if(score>best) best=score;
if(best>alpha) alpha=best;
if(alpha>=beta) break;
}
return best;
}
/* =======================
UI / RENDERING
======================= */
const engine=new Engine();
let selected=null;
let legalTargets=new Map(); // to->move
let lastMove=null;
let flip=false;
let muted=false;
let playerColor='w';
let aiDepth=2;
let promoMove=null; // pending promotion move without promotion piece
let audioCtx=null;
const boardEl=document.getElementById('board');
const turnDot=document.getElementById('turnDot');
const turnText=document.getElementById('turnText');
const statusText=document.getElementById('statusText');
const moveList=document.getElementById('moveList');
const banner=document.getElementById('banner');
const capturedWhite=document.getElementById('capturedWhite');
const capturedBlack=document.getElementById('capturedBlack');
const boardWrap=document.getElementById('boardWrap');
const fenSmall=document.getElementById('fenSmall');
const evalPill=document.getElementById('evalPill');
const plyPill=document.getElementById('plyPill');
const thinkingEl=document.getElementById('thinking');
function ensureAudio(){
if(!audioCtx) audioCtx=new (window.AudioContext||window.webkitAudioContext)();
}
function playTone(freq, dur, type='sine', gain=0.15){
if(muted) return;
ensureAudio();
const o=audioCtx.createOscillator();
const g=audioCtx.createGain();
o.type=type; o.frequency.value=freq;
g.gain.value=gain;
o.connect(g); g.connect(audioCtx.destination);
o.start();
g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime+dur);
o.stop(audioCtx.currentTime+dur);
}
function soundMove(isCap, isCheck){
if(isCheck) { playTone(880,0.35,'square',0.12); setTimeout(()=>playTone(1100,0.2,'sine',0.12),120); }
else if(isCap) playTone(220,0.12,'triangle',0.2);
else playTone(440,0.08,'sine',0.08);
}
function squareColor(idx){
const r=Math.floor(idx/8), c=idx%8;
return (r+c)%2===0 ? 'dark' : 'light'; // a1 dark (0+0 even) => dark matches standard? Usually a1 dark? Actually a1 is dark square? In standard, a1 is dark? Wait h1 is light, a1 is dark? No, a1 is dark rook? Actually board: a1 is dark? No, a1 is dark square? Let's define: a1 is dark (black) corner is dark? Standard: white on right, h1 light, a1 dark. So our dark/light mapping: (r+c)%2==0 dark gives a1 dark correct. Good.
}
const pieceGlyph={ 'K':{w:'♔',b:'♚'}, 'Q':{w:'♕',b:'♛'}, 'R':{w:'♖',b:'♜'}, 'B':{w:'♗',b:'♝'}, 'N':{w:'♘',b:'♞'}, 'P':{w:'♙',b:'♟'} };
function render(){
boardEl.innerHTML='';
const legal= selected!==null ? engine.legalMoves().filter(m=>m.from===selected) : [];
legalTargets.clear();
legal.forEach(m=> legalTargets.set(m.to,m));
const inChk=engine.inCheck(engine.turn);
const kingPos=engine.findKing(engine.turn);
const lastFrom=lastMove?lastMove.from:null, lastTo=lastMove?lastMove.to:null;
for(let r=7;r>=0;r--){
for(let c=0;c<8;c++){
const idx=r*8+c;
const sq=document.createElement('div');
sq.className='sq '+(squareColor(idx)==='light'?'light':'dark');
sq.dataset.idx=idx;
if(idx===lastFrom || idx===lastTo) sq.classList.add('last');
if(inChk && idx===kingPos) sq.classList.add('in-check');
if(idx===selected) sq.classList.add('selected');
if(legalTargets.has(idx)){
const mv=legalTargets.get(idx);
const isCap = !!engine.board[idx] || mv.isEnPassant;
sq.classList.add(isCap?'can-capture':'can-move');
}
if((r===7 && c===0) || (r===0 && c===7) || true){
// coordinates
if(r===0){
const f=document.createElement('span');
f.className='coords file';
f.textContent=String.fromCharCode(97+c);
f.style.color= squareColor(idx)==='light' ? 'var(--dark)' : 'var(--light)';
// adjust contrast
sq.appendChild(f);
}
if(c===0){
const rk=document.createElement('span');
rk.className='coords rank';
rk.textContent=(r+1);
rk.style.color= squareColor(idx)==='light' ? 'var(--dark)' : 'var(--light)';
sq.appendChild(rk);
}
}
const p=engine.board[idx];
if(p){
const span=document.createElement('span');
span.className='piece '+(p.color==='w'?'w':'b');
span.textContent=pieceGlyph[p.type][p.color];
span.draggable=true;
span.dataset.idx=idx;
span.addEventListener('dragstart', e=>{
if(engine.gameOver().over) { e.preventDefault(); return; }
if(playerColor!=='both' && p.color!==playerColor && engine.turn===playerColor) {
// allow selecting opponent? No
}
e.dataTransfer.setData('text/plain', idx);
setTimeout(()=>span.style.opacity='0.3',0);
});
span.addEventListener('dragend', ()=> span.style.opacity='1');
sq.appendChild(span);
}
sq.addEventListener('click', ()=> onSquareClick(idx));
sq.addEventListener('dragover', e=> e.preventDefault());
sq.addEventListener('drop', e=>{
e.preventDefault();
const from=parseInt(e.dataTransfer.getData('text/plain'));
if(isNaN(from)) return;
tryMove(from, idx);
});
boardEl.appendChild(sq);
}
}
// turn
turnDot.className='dot '+(engine.turn==='w'?'':'black');
turnText.textContent= (engine.turn==='w'?'White':'Black')+' to move';
const over=engine.gameOver();
if(over.over){
banner.className='banner show '+(over.reason==='checkmate'?'mate':'draw');
banner.textContent= over.reason==='checkmate' ? `Checkmate — ${over.result}` : `Draw — ${over.reason} (${over.result})`;
statusText.textContent= over.reason;
} else if(inChk){
banner.className='banner show check';
banner.textContent='Check!';
statusText.textContent='check';
} else {
banner.className='banner';
banner.textContent='';
statusText.textContent= engine.turn==='w'?'White':'Black';
}
// move list
moveList.innerHTML='';
engine.moveHistory.forEach((san,i)=>{
const li=document.createElement('li');
const num=Math.floor(i/2)+1;
const isWhite=i%2===0;
li.textContent= (isWhite? num+'. ' : '') + san;
if(i===engine.moveHistory.length-1) li.classList.add('active');
moveList.appendChild(li);
});
document.getElementById('movesBox').scrollTop=9999;
document.getElementById('moveCount').textContent= engine.moveHistory.length+' plies';
// captured
capturedBlack.innerHTML='<span class="label">Captured by White</span>'+ engine.captured.w.map(t=> `<span>${pieceGlyph[t].b}</span>`).join('');
capturedWhite.innerHTML='<span class="label">Captured by Black</span>'+ engine.captured.b.map(t=> `<span>${pieceGlyph[t].w}</span>`).join('');
fenSmall.textContent=engine.fen();
plyPill.textContent='Ply '+ engine.moveHistory.length;
const ev=evaluate(engine);
const evStr = ev>0 ? `+${(ev/100).toFixed(1)} White` : ev<0 ? `${(ev/100).toFixed(1)} Black` : 'Even';
evalPill.textContent=evStr;
// flip class
document.getElementById('boardCard').classList.toggle('flip', flip);
document.getElementById('flipToggle').classList.toggle('on', flip);
saveState();
}
function onSquareClick(idx){
const over=engine.gameOver();
if(over.over) return;
const p=engine.board[idx];
// if we have selection and clicked legal target -> move
if(selected!==null && legalTargets.has(idx)){
tryMove(selected, idx);
return;
}
// otherwise select piece of side to move
if(p && p.color===engine.turn){
// if analysis mode or it's player's turn? Allow selection always but AI move guard later
if(playerColor!=='both' && engine.turn!==playerColor){
// it's AI turn, don't allow human selection? But allow if both
// still prevent
return;
}
selected=idx;
render();
} else {
selected=null;
render();
}
}
function tryMove(from,to){
const legal=engine.legalMoves();
const candidates=legal.filter(m=> m.from===from && m.to===to);
if(candidates.length===0){
// maybe selected wrong, select new
const p=engine.board[to];
if(p && p.color===engine.turn) { selected=to; render(); } else { selected=null; render(); }
return;
}
// promotion needs picker if multiple
if(candidates.length>1 && candidates[0].promotion){
showPromotionPicker(from,to,candidates);
return;
}
const move=candidates[0];
executeMove(move);
}
function showPromotionPicker(from,to,moves){
promoMove={from,to,moves};
// create overlay near square
const anchor=document.getElementById('promoAnchor');
anchor.innerHTML='';
anchor.style.pointerEvents='auto';
const promo=document.createElement('div');
promo.className='promo';
// position over target square: compute square rect
const sqEl=[...boardEl.children].find(el=> parseInt(el.dataset.idx)===to);
const wrapRect=boardWrap.getBoundingClientRect();
const sqRect=sqEl.getBoundingClientRect();
const left= sqRect.left - wrapRect.left;
const top= sqRect.top - wrapRect.top;
promo.style.left= left+'px';
promo.style.top= (top - 10)+'px';
const isWhite=engine.turn==='w';
const options=['Q','R','B','N'];
options.forEach(t=>{
const btn=document.createElement('button');
btn.textContent= pieceGlyph[t][isWhite?'w':'b'];
btn.title=t;
btn.onclick=()=>{
anchor.innerHTML='';
anchor.style.pointerEvents='none';
const chosen=moves.find(m=>m.promotion===t);
executeMove(chosen);
};
promo.appendChild(btn);
});
anchor.appendChild(promo);
}
function executeMove(move){
const us=engine.turn;
const wasCapture= !!engine.board[move.to] || move.isEnPassant;
const savedTurn=engine.turn;
const state=engine.makeMove(move);
lastMove=move;
selected=null;
const inChk=engine.inCheck(engine.turn);
soundMove(wasCapture, inChk);
render();
// check AI turn
setTimeout(maybeAiMove, 250);
}
function maybeAiMove(){
const over=engine.gameOver();
if(over.over) return;
if(playerColor==='both') return;
if(engine.turn===playerColor) return;
thinkingEl.style.display='inline';
// run search chunked via setTimeout
setTimeout(()=>{
const best=searchBest(engine, aiDepth);
thinkingEl.style.display='none';
if(!best) { render(); return; }
const wasCap= !!engine.board[best.to] || best.isEnPassant;
engine.makeMove(best);
lastMove=best;
const inChk=engine.inCheck(engine.turn);
soundMove(wasCap, inChk);
render();
}, 40);
}
/* persistence */
function saveState(){
const data={
fen:engine.fen(),
moveHistory:engine.moveHistory,
captured:engine.captured,
lastMove,
flip, playerColor, aiDepth
};
localStorage.setItem('atlas_chess_v2', JSON.stringify(data));
}
function loadState(){
const raw=localStorage.getItem('atlas_chess_v2');
if(!raw) return;
try{
const d=JSON.parse(raw);
if(d.fen) engine.loadFen(d.fen);
if(d.moveHistory) engine.moveHistory=d.moveHistory;
if(d.captured) engine.captured=d.captured;
if(d.lastMove) lastMove=d.lastMove;
if(typeof d.flip==='boolean') flip=d.flip;
if(d.playerColor) playerColor=d.playerColor;
if(d.aiDepth) aiDepth=d.aiDepth;
// sync selects
document.getElementById('colorSel').value=playerColor;
document.getElementById('depthSel').value=String(aiDepth);
}catch(e){}
}
/* controls */
document.getElementById('newBtn').onclick=()=>{
engine.reset();
lastMove=null; selected=null; promoMove=null;
document.getElementById('promoAnchor').innerHTML='';
render();
saveState();
};
document.getElementById('undoBtn').onclick=()=>{
// undo both player and AI if needed: pop two moves? spec: takes back both player and AI move
// We'll undo up to 2 ply if AI involved, else 1
const hist=engine.history; // not used
// We can rebuild from moveHistory by resetting and replaying?
// Simpler: we maintain stack via engine states? We didn't push history stack properly for undo via makeMove saved states.
// Instead we store FENs for each move: easier to rebuild by replaying SAN? Alternative store board snapshots array.
// We'll implement undo by restoring from localStorage stack? Let's maintain undo stack in engine via array of saved states.
// Our engine.makeMove returns saved snapshot but we discard. Let's instead push onto array.
// For now, implement undo by loading previous FENs from history of FENs we can reconstruct by resetting and replaying moves minus 1 or 2.
const n= engine.moveHistory.length;
if(n===0) return;
const toRemove = (playerColor==='both') ? 1 : 2;
const keep = Math.max(0, n - toRemove);
// replay
const movesToKeep = engine.moveHistory.slice(0, keep);
// Need to replay using SAN? We'll replay by re-generating from start using stored FEN? Easier: reset and replay via moveUCI? But SAN not UCI.
// Instead we saved FEN before moves? Let's brute: reset and replay by storing move objects? We don't have them.
// Alternative: maintain fenStack array.
if(!window._fenStack) window._fenStack=[];
// Fallback: if we have fenStack, pop
if(window._fenStack && window._fenStack.length>0){
// _fenStack holds FEN after each move plus initial
const targetIdx = Math.max(0, window._fenStack.length - 1 - toRemove);
const fen = window._fenStack[targetIdx];
// truncate stack
window._fenStack = window._fenStack.slice(0, targetIdx+1);
engine.loadFen(fen);
engine.moveHistory = engine.moveHistory.slice(0, keep);
// captured rebuild: not perfect but restore from fen doesn't have captured. We'll reset captured based on material diff? Simplify clear and rebuild from moves? For now reset captured and recompute from board diff vs start? Approx: clear
// We'll keep captured as stored truncated? Let's store capturedStack too.
if(window._capStack) { engine.captured = JSON.parse(JSON.stringify(window._capStack[targetIdx] || {w:[],b:[]})) ; window._capStack=window._capStack.slice(0,targetIdx+1); }
lastMove=null;
selected=null;
render();
return;
}
// if no stack, just reset
engine.reset();
render();
};
document.getElementById('flipBtn').onclick=()=>{ flip=!flip; render(); };
document.getElementById('flipToggle').onclick=()=>{ flip=!flip; render(); };
document.getElementById('muteBtn').onclick=(e)=>{
muted=!muted;
e.target.textContent= muted? '🔇 Muted' : '🔊 Sound';
};
document.getElementById('colorSel').onchange=(e)=>{
playerColor=e.target.value;
saveState();
render();
maybeAiMove();
};
document.getElementById('depthSel').onchange=(e)=>{
aiDepth=parseInt(e.target.value);
saveState();
};
// debug interface
window.game={
fen:()=>engine.fen(),
move:(uci)=>{ const ok=engine.moveUCI(uci); if(ok){ lastMove={from:engine.algToIdx(uci.slice(0,2)), to:engine.algToIdx(uci.slice(2,4))}; render(); } return ok; },
legalMoves:()=>engine.legalMoves().map(m=> engine.idxToAlg(m.from)+engine.idxToAlg(m.to)+(m.promotion?m.promotion.toLowerCase():'')),
engine,
reset:()=>{ engine.reset(); render(); },
board:()=>engine.board
};
// maintain fen stack for undo
window._fenStack=[engine.fen()];
window._capStack=[JSON.parse(JSON.stringify(engine.captured))];
const origMake=engine.makeMove.bind(engine);
engine.makeMove=function(m,test){
const res=origMake(m,test);
if(!test){
window._fenStack.push(engine.fen());
window._capStack.push(JSON.parse(JSON.stringify(engine.captured)));
}
return res;
};
// init
loadState();
if(engine.moveHistory.length===0){ window._fenStack=[engine.fen()]; window._capStack=[JSON.parse(JSON.stringify(engine.captured))]; }
else {
// rebuild stacks from current state? For reload, recreate stacks length = moves+1 but we only have final. To enable undo after reload, start fresh stack
window._fenStack=[new Engine().fen(), engine.fen()];
window._capStack=[{w:[],b:[]}, JSON.parse(JSON.stringify(engine.captured))];
}
render();
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2","provider":"openrouter","persona":"basic","sessionId":"0b76a854-6fcb-429f-88ff-2222963a7246","tokensIn":1137,"tokensOut":18670,"tokensTotal":19807,"cost":0.08076875,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-08-06T15:07:45.634Z"} -->