Chess
Qwen 3.6 27b MTP · 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 72,540 bytes · SHA-256 ff7835480d3e
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chess — AI Opponent</title>
<style>
/* ============================================================
CONSTANTS & THEME
============================================================ */
:root {
--sq-light: #f0d9b5;
--sq-dark: #b58863;
--sq-highlight: rgba(255,255,100,0.45);
--sq-lastmove: rgba(155,199,0,0.41);
--sq-check: radial-gradient(circle, #ff0000 0%, transparent 70%);
--dot-color: rgba(0,0,0,0.25);
--ring-color: rgba(0,0,0,0.3);
--panel-bg: #2b2b2b;
--panel-text: #e0d8c8;
--accent: #769656;
--board-border: #4a3728;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #1a1a1a;
color: var(--panel-text);
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
}
#app {
display: flex;
gap: 24px;
max-width: 1100px;
width: 100%;
align-items: flex-start;
}
/* Board area */
#board-container {
position: relative;
flex-shrink: 0;
}
#board {
display: grid;
grid-template-columns: repeat(8, var(--sq-size, 72px));
grid-template-rows: repeat(8, var(--sq-size, 72px));
border: 4px solid var(--board-border);
border-radius: 4px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
user-select: none;
}
.square {
width: var(--sq-size, 72px);
height: var(--sq-size, 72px);
display: flex;
justify-content: center;
align-items: center;
font-size: calc(var(--sq-size, 72px) * 0.75);
cursor: pointer;
position: relative;
}
.square.light { background: var(--sq-light); }
.square.dark { background: var(--sq-dark); }
.square.last-move { background: var(--sq-lastmove) !important; }
.square.selected { background: var(--sq-highlight) !important; }
.square.check-glow::after {
content: '';
position: absolute;
inset: 0;
background: var(--sq-check);
pointer-events: none;
}
/* Legal move indicators */
.legal-dot::before {
content: '';
position: absolute;
width: 28%;
height: 28%;
border-radius: 50%;
background: var(--dot-color);
pointer-events: none;
}
.legal-ring::after {
content: '';
position: absolute;
inset: 4%;
border-radius: 50%;
border: calc(var(--sq-size,72px)*0.08) solid var(--ring-color);
pointer-events: none;
}
/* Piece styling */
.piece {
z-index: 2;
line-height: 1;
cursor: grab;
transition: transform 0.05s;
filter: drop-shadow(1px 1px 1px rgba(0,0,0,0.3));
}
.piece.white-piece { color: #fff; text-shadow: 0 0 2px #000, 0 0 2px #000; }
.piece.black-piece { color: #222; text-shadow: 0 0 1px rgba(255,255,255,0.3); }
/* Dragging */
.piece.dragging { opacity: 0.4; cursor: grabbing; }
#drag-ghost {
position: fixed;
pointer-events: none;
z-index: 1000;
font-size: calc(var(--sq-size,72px)*0.8);
filter: drop-shadow(2px 4px 6px rgba(0,0,0,0.5));
}
/* Coordinates */
.coord {
position: absolute;
font-size: 10px;
font-weight: bold;
pointer-events: none;
opacity: 0.7;
}
.coord-file { bottom: 2px; right: 4px; }
.coord-rank { top: 2px; left: 4px; }
.square.light .coord { color: var(--sq-dark); }
.square.dark .coord { color: var(--sq-light); }
/* Side panel */
#panel {
flex: 1;
min-width: 260px;
max-width: 340px;
display: flex;
flex-direction: column;
gap: 12px;
}
.panel-section {
background: var(--panel-bg);
border-radius: 8px;
padding: 14px;
}
.panel-title {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 1px;
color: #999;
margin-bottom: 8px;
}
/* Turn indicator */
#turn-indicator {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
}
.turn-dot {
width: 18px; height: 18px;
border-radius: 50%;
border: 2px solid #555;
}
.turn-dot.white { background: #fff; }
.turn-dot.black { background: #333; }
/* Captured pieces */
.captured-row {
display: flex;
flex-wrap: wrap;
min-height: 28px;
font-size: 20px;
gap: 1px;
}
/* Move list */
#move-list-container {
max-height: 300px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #555 transparent;
}
#move-list {
display: grid;
grid-template-columns: 32px 1fr 1fr;
gap: 0 6px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
.move-num { color: #777; text-align: right; }
.move-w, .move-b { padding: 2px 4px; border-radius: 3px; cursor: default; }
.move-w:hover, .move-b:hover { background: rgba(255,255,255,0.1); }
/* Controls */
#controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.btn {
padding: 8px 14px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: background 0.2s, transform 0.1s;
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn-primary { background: var(--accent); color: #fff; }
.btn-secondary { background: #444; color: #ccc; }
.btn-danger { background: #c0392b; color: #fff; }
select, input[type=range] {
background: #444;
color: #ddd;
border: 1px solid #555;
padding: 6px 8px;
border-radius: 4px;
font-size: 13px;
}
.control-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.control-label { font-size: 12px; color: #999; min-width: 70px; }
/* Promotion overlay */
#promotion-overlay {
display: none;
position: absolute;
z-index: 100;
background: rgba(0,0,0,0.85);
border-radius: 8px;
padding: 6px;
gap: 4px;
}
#promotion-overlay.active { display: flex; }
.promo-piece {
width: calc(var(--sq-size,72px)*0.7);
height: calc(var(--sq-size,72px)*0.7);
font-size: calc(var(--sq-size,72px)*0.65);
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.promo-piece:hover { background: rgba(255,255,255,0.3); }
/* Game over banner */
#game-over-banner {
display: none;
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.92);
color: #fff;
padding: 24px 36px;
border-radius: 12px;
text-align: center;
z-index: 200;
box-shadow: 0 8px 40px rgba(0,0,0,0.7);
}
#game-over-banner.active { display: block; }
#game-over-banner h2 { margin-bottom: 6px; font-size: 22px; }
#game-over-banner p { color: #aaa; font-size: 14px; }
/* Responsive */
@media (max-width: 800px) {
#app { flex-direction: column; align-items: center; }
:root { --sq-size: min(11vw, 64px); }
#panel { max-width: 95vw; width: 100%; }
}
</style>
</head>
<body>
<div id="app">
<div id="board-container">
<div id="board"></div>
<div id="promotion-overlay"></div>
<div id="game-over-banner">
<h2 id="go-title"></h2>
<p id="go-subtitle"></p>
<br>
<button class="btn btn-primary" onclick="newGame()">New Game</button>
</div>
</div>
<div id="panel">
<div class="panel-section">
<div id="turn-indicator">
<span class="turn-dot white"></span>
<span id="turn-text">White to move</span>
</div>
</div>
<div class="panel-section">
<div class="panel-title">Captured</div>
<div style="margin-bottom:4px"><small>Captured by White:</small></div>
<div id="captured-by-white" class="captured-row"></div>
<div style="margin-top:6px;margin-bottom:4px"><small>Captured by Black:</small></div>
<div id="captured-by-black" class="captured-row"></div>
</div>
<div class="panel-section">
<div class="panel-title">Moves</div>
<div id="move-list-container">
<div id="move-list"></div>
</div>
</div>
<div class="panel-section" id="controls">
<button class="btn btn-primary" onclick="newGame()">New Game</button>
<button class="btn btn-secondary" onclick="undoMove()" id="undo-btn">Undo</button>
<button class="btn btn-secondary" onclick="flipBoard()">Flip Board</button>
<div class="control-row">
<span class="control-label">Difficulty:</span>
<select id="depth-select">
<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 class="control-row">
<span class="control-label">Play as:</span>
<select id="side-select">
<option value="white" selected>White</option>
<option value="black">Black</option>
<option value="both">Both (Analysis)</option>
</select>
</div>
<div class="control-row">
<span class="control-label">Sound:</span>
<button class="btn btn-secondary" id="mute-btn" onclick="toggleMute()">🔊 On</button>
</div>
</div>
</div>
</div>
<div id="drag-ghost"></div>
<script>
/* ================================================================
SECTION 1: CONSTANTS & CONFIGURATION
================================================================ */
const CFG = {
// Board colors (CSS vars override these)
sqLight: '#f0d9b5',
sqDark: '#b58863',
// AI search depths available
aiDepths: [1, 2, 3],
// Piece values for evaluation
pieceValues: { P: 100, N: 320, B: 330, R: 500, Q: 900, K: 20000 },
// Sound enabled by default
soundEnabled: true,
};
// Unicode chess pieces (by piece value index: 0=empty, 1-6=white, 7-12=black)
const PIECE_CHAR = [
'', '\u2654','\u2655','\u2656','\u2657','\u2658','\u2659', // white K,Q,R,B,N,P — wait, wrong order
];
// Correct: WP=1,WQ=5,WR=4,WB=3,WN=2,WK=6 → indices by piece value
const PIECE_SYMBOL = [
'', '\u2659','\u2658','\u2657','\u2656','\u2655','\u2654', // white P,N,B,R,Q,K
'', '\u265F','\u265E','\u265D','\u265C','\u265B','\u265A', // black p,n,b,r,q,k
];
// Legacy map for compatibility
const PIECE_UNICODE = {
'K': '\u2654', 'Q': '\u2655', 'R': '\u2656', 'B': '\u2657', 'N': '\u2658', 'P': '\u2659',
'k': '\u265A', 'q': '\u265B', 'r': '\u265C', 'b': '\u265D', 'n': '\u265E', 'p': '\u265F',
};
// Piece-square tables (from white's perspective; mirrored for black)
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, 10, 10, 10, 10, 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 REPRESENTATION (0x88)
================================================================ */
// 0x88 board: 128-element array. Square = row*16+col. Valid if sq&8===0.
const EMPTY = 0;
const WP=1, WN=2, WB=3, WR=4, WQ=5, WK=6;
const BP=7, BN=8, BB=9, BR=10, BQ=11, BK=12;
// Piece type codes (for move generation)
const PIECE_TYPE = { P:1, N:2, B:3, R:4, Q:5, K:6 };
function pieceColor(p) { return p >= 1 && p <= 6 ? 'w' : p >= 7 && p <= 12 ? 'b' : null; }
function isWhite(p) { return p >= 1 && p <= 6; }
function isBlack(p) { return p >= 7 && p <= 12; }
function pieceType(p) { return p <= 6 ? p : p - 6; }
// Direction offsets for sliding and stepping pieces (from white perspective)
const NORTH = -16, SOUTH = 16, WEST = -1, EAST = 1;
const KNIGHT_DIRS = [-33, -31, 31, 33, -18, -14, 14, 18]; // ±2ranks±1file, ±1rank±2files in 0x88
const KING_DIRS = [NORTH, SOUTH, WEST, EAST, NORTH+WEST, NORTH+EAST, SOUTH+WEST, SOUTH+EAST];
const PAWN_CAPTURE_DIRS_W = [NORTH+WEST, NORTH+EAST]; // white captures northward (toward row 0)
// Wait — let me reconsider. In standard chess notation, White starts at ranks 7-8 (rows 6-7 in 0-indexed).
// Let's use: row 0 = rank 1 (white home), row 7 = rank 8 (black home).
// So white pawns move toward higher rows (SOUTH = +16), black pawns toward lower rows (NORTH = -16).
const WP_MOVE_DIRS = [SOUTH]; // white pawns go south (+16)
const BP_MOVE_DIRS = [NORTH]; // black pawns go north (-16)
const PAWN_CAPTURE_W = [SOUTH+WEST, SOUTH+EAST];
const PAWN_CAPTURE_B = [NORTH+WEST, NORTH+EAST];
function sqToFile(sq) { return sq & 7; }
function sqToRank(sq) { return sq >> 4; }
function isOffBoard(sq) { return sq < 0 || sq >= 128 || (sq & 8) !== 0; }
// Algebraic notation helpers
const FILES = 'abcdefgh';
const RANKS = '12345678';
function sqToAlgebraic(sq) { return FILES[sqToFile(sq)] + RANKS[sqToRank(sq)]; }
function algebraicToSq(s) { return (s.charCodeAt(1)-49)*16 + (s.charCodeAt(0)-97); }
/* ================================================================
SECTION 3: GAME STATE
================================================================ */
class GameState {
constructor() {
this.board = new Array(128).fill(EMPTY);
this.turn = 'w'; // 'w' or 'b'
this.castlingRights = { K: true, Q: true, k: true, q: true };
this.enPassantSq = -1; // 0x88 square index, or -1
this.halfMoveClock = 0;
this.fullMoveNumber = 1;
this.moveHistory = []; // array of { from, to, piece, captured, promotion, castling, ep }
this.capturedPieces = []; // for display: [{color:'w'|'b', type}]
this.gameOver = false;
this.result = null; // '1-0','0-1','½-½' or null
this.resultReason = '';
this.initBoard();
}
initBoard() {
const backRow = [WR, WN, WB, WQ, WK, WB, WN, WR];
for (let c = 0; c < 8; c++) {
this.board[c] = backRow[c]; // row 0 = rank 1
this.board[7*16+c] = BP; // row 7 = rank 8... wait
}
// Actually let me reconsider the layout. Standard: White pieces on ranks 1,2 (rows 0,1). Black on ranks 7,8 (rows 6,7).
// Row 0 = rank 1, Row 7 = rank 8.
const whiteBack = [WR, WN, WB, WQ, WK, WB, WN, WR];
const blackBack = [BR, BN, BB, BQ, BK, BB, BN, BR];
for (let c = 0; c < 8; c++) {
this.board[0*16+c] = whiteBack[c]; // rank 1
this.board[1*16+c] = WP; // rank 2 (white pawns)
this.board[6*16+c] = BP; // rank 7 (black pawns)
this.board[7*16+c] = blackBack[c]; // rank 8
}
}
clone() {
const g = new GameState();
g.board = [...this.board];
g.turn = this.turn;
g.castlingRights = {...this.castlingRights};
g.enPassantSq = this.enPassantSq;
g.halfMoveClock = this.halfMoveClock;
g.fullMoveNumber = this.fullMoveNumber;
g.moveHistory = [...this.moveHistory];
g.capturedPieces = [...this.capturedPieces];
g.gameOver = this.gameOver;
g.result = this.result;
g.resultReason = this.resultReason;
return g;
}
// Make a move on the board (assumes legal)
makeMove(from, to, promotion='Q') {
const piece = this.board[from];
const captured = this.board[to];
const color = pieceColor(piece);
const type = pieceType(piece);
let isCastling = false;
let isEP = false;
let epCapturedSq = -1;
let promoPiece = null;
// En passant capture
if (type === PIECE_TYPE.P && to === this.enPassantSq) {
const capturedPawnSq = color === 'w' ? to - 16 : to + 16;
isEP = true;
epCapturedSq = capturedPawnSq;
// Record the captured pawn
this.capturedPieces.push({ color: pieceColor(this.board[capturedPawnSq]), type: PIECE_TYPE.P });
}
// Castling
if (type === PIECE_TYPE.K && Math.abs(to - from) === 2) {
isCastling = true;
const dir = to > from ? 1 : -1;
const rookFrom = from + dir * 3;
const rookTo = from + dir;
this.board[rookTo] = this.board[rookFrom];
this.board[rookFrom] = EMPTY;
}
// Record capture (non-EP)
if (captured !== EMPTY && !isEP) {
this.capturedPieces.push({ color: pieceColor(captured), type: pieceType(captured) });
}
// Move the piece
this.board[to] = piece;
this.board[from] = EMPTY;
// Promotion
if (type === PIECE_TYPE.P && (sqToRank(to) === 0 || sqToRank(to) === 7)) {
const promoMap = { 'Q': color==='w'?WQ:BQ, 'R': color==='w'?WR:BR, 'B': color==='w'?WB:BB, 'N': color==='w'?WN:BN };
this.board[to] = promoMap[promotion];
promoPiece = promotion;
}
// Update en passant square
if (type === PIECE_TYPE.P && Math.abs(sqToRank(to) - sqToRank(from)) === 2) {
this.enPassantSq = color === 'w' ? from + 16 : from - 16;
} else {
this.enPassantSq = -1;
}
// Update castling rights
if (piece === WK) { this.castlingRights.K = false; this.castlingRights.Q = false; }
if (piece === BK) { this.castlingRights.k = false; this.castlingRights.q = false; }
if (to === 0*16+7 || from === 0*16+7) this.castlingRights.K = false; // h1
if (to === 0*16+0 || from === 0*16+0) this.castlingRights.Q = false; // a1
if (to === 7*16+7 || from === 7*16+7) this.castlingRights.k = false; // h8
if (to === 7*16+0 || from === 7*16+0) this.castlingRights.q = false; // a8
// Half-move clock
if (type === PIECE_TYPE.P || captured !== EMPTY || isEP) {
this.halfMoveClock = 0;
} else {
this.halfMoveClock++;
}
// Full move number increments after black moves
if (this.turn === 'b') this.fullMoveNumber++;
// Record move for undo
const moveRecord = {
from, to, piece, captured: captured || (isEP ? this.board[epCapturedSq] : EMPTY),
promotion: promoPiece, castling: isCastling, ep: isEP, epCapturedSq,
prevEnPassant: this.enPassantSq === -1 && !((type===PIECE_TYPE.P&&Math.abs(sqToRank(to)-sqToRank(from))===2) ? true : false) ? -1 : (color==='w'&&type===PIECE_TYPE.P&&Math.abs(sqToRank(to)-sqToRank(from))===2 ? from+16 : type===PIECE_TYPE.P&&Math.abs(sqToRank(to)-sqToRank(from))===2 ? from-16 : -1),
prevCastling: {...this.castlingRights}, // We'll fix this below
};
// Actually, let me store the state before the move for proper undo
// Store previous en passant and castling rights BEFORE they were updated
// Hmm, I already modified them. Let me restructure.
}
// Better approach: makeMove returns info needed for unmake
makeMove2(from, to, promotion='Q') {
const piece = this.board[from];
const captured = this.board[to];
const color = pieceColor(piece);
const type = pieceType(piece);
let isCastling = false;
let isEP = false;
let epCapturedSq = -1;
let promoPiece = null;
let rookFrom = -1, rookTo = -1;
// Save state for undo
const undoInfo = {
from, to, piece, captured, isCastling: false, isEP: false,
epCapturedSq: -1, promoPiece: null, rookFrom: -1, rookTo: -1,
prevEnPassant: this.enPassantSq,
prevCastlingRights: {...this.castlingRights},
prevHalfMoveClock: this.halfMoveClock,
};
// En passant capture
if (type === PIECE_TYPE.P && to === this.enPassantSq) {
isEP = true;
epCapturedSq = color === 'w' ? to - 16 : to + 16;
const epPiece = this.board[epCapturedSq];
undoInfo.captured = epPiece; // override captured with the EP pawn
this.board[epCapturedSq] = EMPTY;
this.capturedPieces.push({ color: pieceColor(epPiece), type: PIECE_TYPE.P });
}
// Castling
if (type === PIECE_TYPE.K && Math.abs(to - from) === 2) {
isCastling = true;
const dir = to > from ? 1 : -1;
rookFrom = from + dir * 3;
rookTo = from + dir;
this.board[rookTo] = this.board[rookFrom];
this.board[rookFrom] = EMPTY;
}
// Record capture (non-EP)
if (captured !== EMPTY && !isEP) {
this.capturedPieces.push({ color: pieceColor(captured), type: pieceType(captured) });
}
// Move the piece
this.board[to] = piece;
this.board[from] = EMPTY;
// Promotion
if (type === PIECE_TYPE.P && (sqToRank(to) === 0 || sqToRank(to) === 7)) {
const promoMap = { 'Q': color==='w'?WQ:BQ, 'R': color==='w'?WR:BR, 'B': color==='w'?WB:BB, 'N': color==='w'?WN:BN };
this.board[to] = promoMap[promotion];
promoPiece = promotion;
}
// Update en passant square
if (type === PIECE_TYPE.P && Math.abs(sqToRank(to) - sqToRank(from)) === 2) {
this.enPassantSq = color === 'w' ? from + 16 : from - 16;
} else {
this.enPassantSq = -1;
}
// Update castling rights
if (piece === WK) { this.castlingRights.K = false; this.castlingRights.Q = false; }
if (piece === BK) { this.castlingRights.k = false; this.castlingRights.q = false; }
if (to === 0*16+7 || from === 0*16+7) this.castlingRights.K = false;
if (to === 0*16+0 || from === 0*16+0) this.castlingRights.Q = false;
if (to === 7*16+7 || from === 7*16+7) this.castlingRights.k = false;
if (to === 7*16+0 || from === 7*16+0) this.castlingRights.q = false;
// Half-move clock
if (type === PIECE_TYPE.P || captured !== EMPTY || isEP) {
this.halfMoveClock = 0;
} else {
this.halfMoveClock++;
}
// Full move number
if (this.turn === 'b') this.fullMoveNumber++;
// Switch turn
this.turn = this.turn === 'w' ? 'b' : 'w';
undoInfo.isCastling = isCastling;
undoInfo.isEP = isEP;
undoInfo.epCapturedSq = epCapturedSq;
undoInfo.promoPiece = promoPiece;
undoInfo.rookFrom = rookFrom;
undoInfo.rookTo = rookTo;
return undoInfo;
}
unmakeMove(undoInfo) {
// Restore piece positions
this.board[undoInfo.from] = undoInfo.piece;
this.board[undoInfo.to] = EMPTY;
// Restore captured piece
if (undoInfo.isEP) {
this.board[undoInfo.epCapturedSq] = undoInfo.captured;
this.capturedPieces.pop();
} else if (undoInfo.captured !== EMPTY) {
this.capturedPieces.pop();
}
// Restore castling rook
if (undoInfo.isCastling) {
this.board[undoInfo.rookFrom] = this.board[undoInfo.rookTo];
this.board[undoInfo.rookTo] = EMPTY;
}
// Undo promotion
if (undoInfo.promoPiece) {
const color = pieceColor(undoInfo.piece);
this.board[undoInfo.to] = color === 'w' ? WP : BP;
}
// Restore state
this.enPassantSq = undoInfo.prevEnPassant;
this.castlingRights = undoInfo.prevCastlingRights;
this.halfMoveClock = undoInfo.prevHalfMoveClock;
if (this.turn === 'w') this.fullMoveNumber--;
this.turn = this.turn === 'w' ? 'b' : 'w';
// Remove from move history
this.moveHistory.pop();
}
// Generate all pseudo-legal moves, then filter for legality
generateMoves(color) {
const moves = [];
color = color || this.turn;
for (let sq = 0; sq < 128; sq++) {
if ((sq & 8) !== 0) continue; // off board
const piece = this.board[sq];
if (piece === EMPTY || pieceColor(piece) !== color) continue;
const type = pieceType(piece);
switch(type) {
case PIECE_TYPE.P: this.genPawnMoves(sq, color, moves); break;
case PIECE_TYPE.N: this.genKnightMoves(sq, color, moves); break;
case PIECE_TYPE.B: this.genBishopMoves(sq, color, moves); break;
case PIECE_TYPE.R: this.genRookMoves(sq, color, moves); break;
case PIECE_TYPE.Q: this.genQueenMoves(sq, color, moves); break;
case PIECE_TYPE.K: this.genKingMoves(sq, color, moves); break;
}
}
return moves;
}
genPawnMoves(sq, color, moves) {
const dir = color === 'w' ? 16 : -16; // white goes toward higher rank (south), black toward lower (north)
const startRank = color === 'w' ? 1 : 6;
const promoRank = color === 'w' ? 7 : 0;
const captureDirs = color === 'w' ? [16-1, 16+1] : [-16-1, -16+1];
// Single push
let to = sq + dir;
if (!isOffBoard(to) && this.board[to] === EMPTY) {
moves.push({ from: sq, to });
// Double push
if (sqToRank(sq) === startRank) {
const to2 = sq + 2*dir;
if (this.board[to2] === EMPTY) {
moves.push({ from: sq, to: to2 });
}
}
}
// Captures
for (const cd of captureDirs) {
const capTo = sq + cd;
if (isOffBoard(capTo)) continue;
const target = this.board[capTo];
if (target !== EMPTY && pieceColor(target) !== color) {
// Regular capture
if (sqToRank(capTo) === promoRank) {
for (const p of ['Q','R','B','N']) moves.push({ from: sq, to: capTo, promotion: p });
} else {
moves.push({ from: sq, to: capTo });
}
}
// En passant — for black pawns the EP landing is one more step toward white's side
const epTarget = color === 'w' ? capTo : capTo - 16;
if (!isOffBoard(epTarget) && epTarget === this.enPassantSq) {
moves.push({ from: sq, to: this.enPassantSq, ep: true });
}
}
}
genKnightMoves(sq, color, moves) {
for (const d of KNIGHT_DIRS) {
const to = sq + d;
if (isOffBoard(to)) continue;
const target = this.board[to];
if (target === EMPTY || pieceColor(target) !== color) {
moves.push({ from: sq, to });
}
}
}
genBishopMoves(sq, color, moves) {
this.genSlidingMoves(sq, color, moves, [16-1, 16+1, -16-1, -16+1]);
}
genRookMoves(sq, color, moves) {
this.genSlidingMoves(sq, color, moves, [16, -16, 1, -1]);
}
genQueenMoves(sq, color, moves) {
this.genSlidingMoves(sq, color, moves, [16, -16, 1, -1, 16-1, 16+1, -16-1, -16+1]);
}
genSlidingMoves(sq, color, moves, dirs) {
for (const d of dirs) {
let to = sq + d;
while (!isOffBoard(to)) {
const target = this.board[to];
if (target === EMPTY) {
moves.push({ from: sq, to });
} else {
if (pieceColor(target) !== color) moves.push({ from: sq, to });
break;
}
to += d;
}
}
}
genKingMoves(sq, color, moves) {
for (const d of KING_DIRS) {
const to = sq + d;
if (isOffBoard(to)) continue;
const target = this.board[to];
if (target === EMPTY || pieceColor(target) !== color) {
moves.push({ from: sq, to });
}
}
// Castling
const enemy = color === 'w' ? 'b' : 'w';
if (!this.isSquareAttacked(sq, enemy)) {
// Kingside
const ksRight = color === 'w' ? this.castlingRights.K : this.castlingRights.k;
if (ksRight) {
const rFrom = color === 'w' ? 0*16+7 : 7*16+7;
const rTo = color === 'w' ? 0*16+5 : 7*16+5;
const kTo = color === 'w' ? 0*16+6 : 7*16+6;
if (this.board[rFrom] !== EMPTY && this.board[rTo] === EMPTY && this.board[kTo] === EMPTY) {
if (!this.isSquareAttacked(rTo, enemy) && !this.isSquareAttacked(kTo, enemy)) {
moves.push({ from: sq, to: kTo });
}
}
}
// Queenside
const qsRight = color === 'w' ? this.castlingRights.Q : this.castlingRights.q;
if (qsRight) {
const rFrom = color === 'w' ? 0*16+0 : 7*16+0;
const rTo = color === 'w' ? 0*16+3 : 7*16+3;
const kTo = color === 'w' ? 0*16+2 : 7*16+2;
if (this.board[rFrom] !== EMPTY && this.board[rTo] === EMPTY && this.board[kTo] === EMPTY && this.board[color==='w'?0*16+1:7*16+1] === EMPTY) {
if (!this.isSquareAttacked(rTo, enemy) && !this.isSquareAttacked(kTo, enemy)) {
moves.push({ from: sq, to: kTo });
}
}
}
}
}
// Check if a square is attacked by the given color
isSquareAttacked(sq, byColor) {
// Pawn attacks
const pawnDir = byColor === 'w' ? -16 : 16; // white pawns attack from below (lower rank toward higher)
// Wait: if we're checking if sq is attacked BY white, white pawns are at lower ranks attacking upward.
// White pawn at rank r attacks squares at rank r+1. So to check if sq is attacked by white pawns,
// look for white pawns at sq - 16 ± 1 (one rank below).
const pawnAttackFrom = byColor === 'w' ? [sq - 16 - 1, sq - 16 + 1] : [sq + 16 - 1, sq + 16 + 1];
for (const paf of pawnAttackFrom) {
if (!isOffBoard(paf)) {
const p = this.board[paf];
if (p !== EMPTY && pieceColor(p) === byColor && pieceType(p) === PIECE_TYPE.P) return true;
}
}
// Knight attacks
for (const d of KNIGHT_DIRS) {
const to = sq + d;
if (!isOffBoard(to)) {
const p = this.board[to];
if (p !== EMPTY && pieceColor(p) === byColor && pieceType(p) === PIECE_TYPE.N) return true;
}
}
// King attacks
for (const d of KING_DIRS) {
const to = sq + d;
if (!isOffBoard(to)) {
const p = this.board[to];
if (p !== EMPTY && pieceColor(p) === byColor && pieceType(p) === PIECE_TYPE.K) return true;
}
}
// Sliding pieces: bishop/queen diagonals, rook/queen straights
const diagDirs = [16-1, 16+1, -16-1, -16+1];
const straightDirs = [16, -16, 1, -1];
for (const d of diagDirs) {
let to = sq + d;
while (!isOffBoard(to)) {
const p = this.board[to];
if (p !== EMPTY) {
if (pieceColor(p) === byColor && (pieceType(p) === PIECE_TYPE.B || pieceType(p) === PIECE_TYPE.Q)) return true;
break;
}
to += d;
}
}
for (const d of straightDirs) {
let to = sq + d;
while (!isOffBoard(to)) {
const p = this.board[to];
if (p !== EMPTY) {
if (pieceColor(p) === byColor && (pieceType(p) === PIECE_TYPE.R || pieceType(p) === PIECE_TYPE.Q)) return true;
break;
}
to += d;
}
}
return false;
}
// Find king position for a color
findKing(color) {
const target = color === 'w' ? WK : BK;
for (let sq = 0; sq < 128; sq++) {
if ((sq & 8) === 0 && this.board[sq] === target) return sq;
}
return -1;
}
// Is the given color in check?
isInCheck(color) {
const kingSq = this.findKing(color);
if (kingSq === -1) return false;
const enemy = color === 'w' ? 'b' : 'w';
return this.isSquareAttacked(kingSq, enemy);
}
// Generate legal moves only
generateLegalMoves(color) {
color = color || this.turn;
const pseudoMoves = this.generateMoves(color);
const legal = [];
for (const move of pseudoMoves) {
if (this.isMoveLegal2(move, color)) {
legal.push(move);
}
}
return legal;
}
// Check if a specific move is legal (doesn't leave own king in check)
isMoveLegal(move, color) {
const piece = this.board[move.from];
const captured = this.board[move.to];
const type = pieceType(piece);
const enemy = color === 'w' ? 'b' : 'w';
// Make the move temporarily
let epCapturedSq = -1;
let rookFrom = -1, rookTo = -1;
if (type === PIECE_TYPE.P && move.to === this.enPassantSq) {
epCapturedSq = color === 'w' ? move.to - 16 : move.to + 16;
const epPiece = this.board[epCapturedSq];
this.board[epCapturedSq] = EMPTY;
}
if (type === PIECE_TYPE.K && Math.abs(move.to - move.from) === 2) {
const dir = move.to > move.from ? 1 : -1;
rookFrom = move.from + dir * 3;
rookTo = move.from + dir;
this.board[rookTo] = this.board[rookFrom];
this.board[rookFrom] = EMPTY;
}
this.board[move.to] = piece;
this.board[move.from] = EMPTY;
const legal = !this.isSquareAttacked(this.findKing(color), enemy);
// Undo
this.board[move.from] = piece;
this.board[move.to] = captured;
if (epCapturedSq !== -1) {
this.board[epCapturedSq] = this.board[epCapturedSq]; // already restored by setting move.to
// Actually we need to restore the EP pawn. Let me fix:
// The EP pawn was at epCapturedSq, we set it to EMPTY. We need to put it back.
// But captured holds the piece that was at move.to (which was EMPTY for EP).
// So we need a different approach.
}
if (rookFrom !== -1) {
this.board[rookFrom] = this.board[rookTo];
this.board[rookTo] = EMPTY;
}
return legal;
}
// Better isMoveLegal that properly handles undo
isMoveLegal2(move, color) {
const piece = this.board[move.from];
const captured = this.board[move.to];
const type = pieceType(piece);
const enemy = color === 'w' ? 'b' : 'w';
let epCapturedSq = -1;
let rookFrom = -1, rookTo = -1;
let epPiece = EMPTY;
// En passant capture
if (type === PIECE_TYPE.P && move.to === this.enPassantSq) {
epCapturedSq = color === 'w' ? move.to - 16 : move.to + 16;
epPiece = this.board[epCapturedSq];
this.board[epCapturedSq] = EMPTY;
}
// Castling rook move
if (type === PIECE_TYPE.K && Math.abs(move.to - move.from) === 2) {
const dir = move.to > move.from ? 1 : -1;
rookFrom = move.from + dir * 3;
rookTo = move.from + dir;
this.board[rookTo] = this.board[rookFrom];
this.board[rookFrom] = EMPTY;
}
// Move piece
this.board[move.to] = piece;
this.board[move.from] = EMPTY;
const kingSq = this.findKing(color);
const legal = !this.isSquareAttacked(kingSq, enemy);
// Undo
this.board[move.from] = piece;
this.board[move.to] = captured;
if (epCapturedSq !== -1) {
this.board[epCapturedSq] = epPiece;
}
if (rookFrom !== -1) {
this.board[rookFrom] = this.board[rookTo];
this.board[rookTo] = EMPTY;
}
return legal;
}
// Check game state: checkmate, stalemate, draws
getGameState() {
const legalMoves = this.generateLegalMoves(this.turn);
if (legalMoves.length === 0) {
if (this.isInCheck(this.turn)) {
return 'checkmate';
} else {
return 'stalemate';
}
}
// Insufficient material
if (this.isInsufficientMaterial()) return 'insufficient_material';
// 50-move rule
if (this.halfMoveClock >= 100) return 'fifty_move_rule';
return 'ongoing';
}
isInsufficientMaterial() {
const pieces = [];
for (let sq = 0; sq < 128; sq++) {
if ((sq & 8) === 0 && this.board[sq] !== EMPTY) {
pieces.push({ color: pieceColor(this.board[sq]), type: pieceType(this.board[sq]) });
}
}
if (pieces.length === 2) return true; // K vs K
// K+B vs K or K+N vs K
if (pieces.length === 3) {
const nonKing = pieces.find(p => p.type !== PIECE_TYPE.K);
if (nonKing && (nonKing.type === PIECE_TYPE.B || nonKing.type === PIECE_TYPE.N)) return true;
}
// K+B vs K+B with same-colored bishops
if (pieces.length === 4) {
const bishops = pieces.filter(p => p.type === PIECE_TYPE.B);
if (bishops.length === 2) {
// Check if bishops are on the same color squares
const b1Sq = this.findPieceByType('w', PIECE_TYPE.B);
const b2Sq = this.findPieceByType('b', PIECE_TYPE.B);
if (b1Sq !== -1 && b2Sq !== -1) {
const sameColor = ((sqToFile(b1Sq) + sqToRank(b1Sq)) % 2) === ((sqToFile(b2Sq) + sqToRank(b2Sq)) % 2);
if (sameColor) return true;
}
}
}
return false;
}
findPieceByType(color, type) {
const target = color === 'w' ? type : type + 6;
for (let sq = 0; sq < 128; sq++) {
if ((sq & 8) === 0 && this.board[sq] === target) return sq;
}
return -1;
}
// Convert to FEN string
fen() {
let fen = '';
for (let row = 7; row >= 0; row--) {
let empty = 0;
for (let col = 0; col < 8; col++) {
const sq = row * 16 + col;
if ((sq & 8) !== 0) continue;
const p = this.board[sq];
if (p === EMPTY) {
empty++;
} else {
if (empty > 0) { fen += empty; empty = 0; }
const typeChar = 'PNBRQK'[pieceType(p)-1];
fen += isWhite(p) ? typeChar : typeChar.toLowerCase();
}
}
if (empty > 0) fen += empty;
if (row > 0) fen += '/';
}
fen += ' ' + this.turn;
// Castling
let castling = '';
if (this.castlingRights.K) castling += 'K';
if (this.castlingRights.Q) castling += 'Q';
if (this.castlingRights.k) castling += 'k';
if (this.castlingRights.q) castling += 'q';
fen += ' ' + (castling || '-');
// En passant
fen += ' ' + (this.enPassantSq >= 0 ? sqToAlgebraic(this.enPassantSq) : '-');
fen += ' ' + this.halfMoveClock;
fen += ' ' + this.fullMoveNumber;
return fen;
}
// Load from FEN
loadFEN(fen) {
const parts = fen.split(' ');
const placement = parts[0];
this.board = new Array(128).fill(EMPTY);
const rows = placement.split('/');
for (let row = 0; row < 8; row++) {
let col = 0;
for (const ch of rows[row]) {
if (ch >= '1' && ch <= '8') {
col += parseInt(ch);
} else {
const isUpper = ch === ch.toUpperCase();
const typeIdx = 'PNBRQK'.indexOf(isUpper ? ch : ch.toUpperCase());
const pieceVal = typeIdx + 1;
const val = isUpper ? pieceVal : pieceVal + 6;
this.board[(7-row)*16+col] = val;
col++;
}
}
}
this.turn = parts[1];
const castlingStr = parts[2];
this.castlingRights = { K: false, Q: false, k: false, q: false };
if (castlingStr !== '-') {
if (castlingStr.includes('K')) this.castlingRights.K = true;
if (castlingStr.includes('Q')) this.castlingRights.Q = true;
if (castlingStr.includes('k')) this.castlingRights.k = true;
if (castlingStr.includes('q')) this.castlingRights.q = true;
}
const epStr = parts[3];
this.enPassantSq = epStr !== '-' ? algebraicToSq(epStr) : -1;
this.halfMoveClock = parseInt(parts[4]) || 0;
this.fullMoveNumber = parseInt(parts[5]) || 1;
this.moveHistory = [];
this.capturedPieces = [];
this.gameOver = false;
this.result = null;
this.resultReason = '';
}
// Convert move to algebraic notation
moveToAlgebraic(move) {
const piece = this.board[move.from];
const type = pieceType(piece);
const color = pieceColor(piece);
const captured = this.board[move.to] !== EMPTY || (type === PIECE_TYPE.P && move.ep);
// Castling
if (type === PIECE_TYPE.K && Math.abs(move.to - move.from) === 2) {
return move.to > move.from ? 'O-O' : 'O-O-O';
}
let notation = '';
// Piece letter (not for pawns)
if (type !== PIECE_TYPE.P) {
notation += 'PNBRQK'[type-1];
// Disambiguation: check if another piece of same type can move to the same square
const legalMoves = this.generateLegalMoves(color);
const ambiguous = legalMoves.filter(m =>
m.to === move.to &&
this.board[m.from] !== EMPTY &&
pieceType(this.board[m.from]) === type &&
m.from !== move.from
);
if (ambiguous.length > 0) {
const sameFile = ambiguous.some(m => sqToFile(m.from) === sqToFile(move.from));
const sameRank = ambiguous.some(m => sqToRank(m.from) === sqToRank(move.from));
if (!sameFile) {
notation += FILES[sqToFile(move.from)];
} else if (!sameRank) {
notation += RANKS[sqToRank(move.from)];
} else {
notation += FILES[sqToFile(move.from)] + RANKS[sqToRank(move.from)];
}
}
}
// Capture indicator
if (captured) {
if (type === PIECE_TYPE.P) {
notation += FILES[sqToFile(move.from)];
}
notation += 'x';
}
// Destination square
notation += sqToAlgebraic(move.to);
// Promotion
if (move.promotion) {
notation += '=' + move.promotion;
}
// En passant annotation
if (type === PIECE_TYPE.P && move.ep) {
notation += ' e.p.';
}
return notation.trim();
}
// Add check/mate symbols to algebraic notation
moveToFullAlgebraic(move) {
let notation = this.moveToAlgebraic(move);
// Make the move temporarily to check for check/mate
const undoInfo = this.makeMove2(move.from, move.to, move.promotion || 'Q');
const enemyTurn = this.turn; // turn has switched
const state = this.getGameState();
if (state === 'checkmate') {
notation += '#';
} else if (this.isInCheck(enemyTurn)) {
notation += '+';
}
this.unmakeMove(undoInfo);
return notation;
}
}
/* ================================================================
SECTION 4: AI ENGINE — Minimax with Alpha-Beta Pruning
================================================================ */
class ChessAI {
constructor(game) {
this.game = game;
this.nodesSearched = 0;
this.maxNodes = 500000; // limit to keep responsive
}
evaluate() {
let score = 0;
for (let sq = 0; sq < 128; sq++) {
if ((sq & 8) !== 0) continue;
const piece = this.game.board[sq];
if (piece === EMPTY) continue;
const type = pieceType(piece);
const color = pieceColor(piece);
const val = CFG.pieceValues['PNBRQK'[type-1]];
// Material
score += isWhite(piece) ? val : -val;
// Piece-square tables
if (PST[type === 1 ? 'P' : type === 2 ? 'N' : type === 3 ? 'B' : type === 4 ? 'R' : type === 5 ? 'Q' : 'K']) {
const table = PST['PNBRQK'[type-1]];
let idx;
if (isWhite(piece)) {
idx = sq; // white perspective
} else {
// Mirror for black: flip rank and file
const r = 7 - sqToRank(sq);
const f = 7 - sqToFile(sq);
idx = r * 8 + f;
}
score += isWhite(piece) ? table[idx] : -table[idx];
}
}
// Mobility bonus (simplified)
const wMoves = this.game.generateLegalMoves('w').length;
const bMoves = this.game.generateLegalMoves('b').length;
score += (wMoves - bMoves) * 3;
return score; // positive = white advantage
}
search(depth, alpha, beta, maximizingPlayer) {
if (++this.nodesSearched > this.maxNodes) return this.evaluate();
if (depth === 0) return this.quiesce(alpha, beta, maximizingPlayer);
const color = maximizingPlayer ? 'w' : 'b';
const moves = this.game.generateLegalMoves(color);
// Move ordering: captures first, then others
this.orderMoves(moves);
if (moves.length === 0) {
if (this.game.isInCheck(color)) {
return maximizingPlayer ? -99999 + (3 - depth) : 99999 - (3 - depth); // checkmate
}
return 0; // stalemate
}
if (maximizingPlayer) {
let maxEval = -Infinity;
for (const move of moves) {
const undoInfo = this.game.makeMove2(move.from, move.to, move.promotion || 'Q');
this.game.moveHistory.push({ from: move.from, to: move.to });
const eval_ = this.search(depth - 1, alpha, beta, false);
this.game.unmakeMove(undoInfo);
maxEval = Math.max(maxEval, eval_);
alpha = Math.max(alpha, eval_);
if (beta <= alpha) break;
}
return maxEval;
} else {
let minEval = Infinity;
for (const move of moves) {
const undoInfo = this.game.makeMove2(move.from, move.to, move.promotion || 'Q');
this.game.moveHistory.push({ from: move.from, to: move.to });
const eval_ = this.search(depth - 1, alpha, beta, true);
this.game.unmakeMove(undoInfo);
minEval = Math.min(minEval, eval_);
beta = Math.min(beta, eval_);
if (beta <= alpha) break;
}
return minEval;
}
}
// Quiescence search to avoid horizon effect
quiesce(alpha, beta, maximizingPlayer) {
const standPat = this.evaluate();
if (maximizingPlayer) {
if (standPat >= beta) return beta;
if (alpha < standPat) alpha = standPat;
const color = 'w';
const moves = this.game.generateLegalMoves(color).filter(m => {
// Only consider captures and promotions in quiescence
const piece = this.game.board[m.from];
return this.game.board[m.to] !== EMPTY || m.promotion || (pieceType(piece) === 1 && m.ep);
});
for (const move of moves) {
const undoInfo = this.game.makeMove2(move.from, move.to, move.promotion || 'Q');
this.game.moveHistory.push({ from: move.from, to: move.to });
const eval_ = this.quiesce(alpha, beta, false);
this.game.unmakeMove(undoInfo);
if (eval_ >= beta) return beta;
if (eval_ > alpha) alpha = eval_;
}
return alpha;
} else {
if (standPat <= alpha) return alpha;
if (beta > standPat) beta = standPat;
const color = 'b';
const moves = this.game.generateLegalMoves(color).filter(m => {
const piece = this.game.board[m.from];
return this.game.board[m.to] !== EMPTY || m.promotion || (pieceType(piece) === 1 && m.ep);
});
for (const move of moves) {
const undoInfo = this.game.makeMove2(move.from, move.to, move.promotion || 'Q');
this.game.moveHistory.push({ from: move.from, to: move.to });
const eval_ = this.quiesce(alpha, beta, true);
this.game.unmakeMove(undoInfo);
if (eval_ <= alpha) return alpha;
if (eval_ < beta) beta = eval_;
}
return beta;
}
}
orderMoves(moves) {
// Simple MVV-LVA style ordering: captures of high-value pieces first
moves.sort((a, b) => {
const aCaptured = this.game.board[a.to];
const bCaptured = this.game.board[b.to];
const aVal = aCaptured !== EMPTY ? CFG.pieceValues['PNBRQK'[pieceType(aCaptured)-1]] : 0;
const bVal = bCaptured !== EMPTY ? CFG.pieceValues['PNBRQK'[pieceType(bCaptured)-1]] : 0;
// Promotion bonus
if (a.promotion) aVal += 800;
if (b.promotion) bVal += 800;
return bVal - aVal;
});
}
getBestMove(depth, color) {
this.nodesSearched = 0;
const moves = this.game.generateLegalMoves(color);
if (moves.length === 0) return null;
this.orderMoves(moves);
const maximizingPlayer = color === 'w';
let bestMove = moves[0];
let bestEval = maximizingPlayer ? -Infinity : Infinity;
for (const move of moves) {
if (this.nodesSearched > this.maxNodes) break;
const undoInfo = this.game.makeMove2(move.from, move.to, move.promotion || 'Q');
this.game.moveHistory.push({ from: move.from, to: move.to });
const eval_ = this.search(depth - 1, -Infinity, Infinity, !maximizingPlayer);
this.game.unmakeMove(undoInfo);
if (maximizingPlayer) {
if (eval_ > bestEval) { bestEval = eval_; bestMove = move; }
} else {
if (eval_ < bestEval) { bestEval = eval_; bestMove = move; }
}
}
return bestMove;
}
}
/* ================================================================
SECTION 5: SOUND ENGINE (Web Audio API)
================================================================ */
class SoundEngine {
constructor() {
this.ctx = null;
this.enabled = true;
}
init() {
if (!this.ctx) {
try {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
} catch(e) {}
}
}
playMove() {
if (!this.enabled || !this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.frequency.setValueAtTime(600, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(300, this.ctx.currentTime + 0.08);
gain.gain.setValueAtTime(0.15, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.1);
osc.start(this.ctx.currentTime);
osc.stop(this.ctx.currentTime + 0.1);
}
playCapture() {
if (!this.enabled || !this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(400, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(150, this.ctx.currentTime + 0.12);
gain.gain.setValueAtTime(0.12, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.15);
osc.start(this.ctx.currentTime);
osc.stop(this.ctx.currentTime + 0.15);
}
playCheck() {
if (!this.enabled || !this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.type = 'square';
osc.frequency.setValueAtTime(800, this.ctx.currentTime);
osc.frequency.setValueAtTime(600, this.ctx.currentTime + 0.1);
gain.gain.setValueAtTime(0.1, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.25);
osc.start(this.ctx.currentTime);
osc.stop(this.ctx.currentTime + 0.25);
}
playGameOver() {
if (!this.enabled || !this.ctx) return;
[400, 500, 600].forEach((freq, i) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.frequency.setValueAtTime(freq, this.ctx.currentTime + i*0.15);
gain.gain.setValueAtTime(0.12, this.ctx.currentTime + i*0.15);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + i*0.15 + 0.3);
osc.start(this.ctx.currentTime + i*0.15);
osc.stop(this.ctx.currentTime + i*0.15 + 0.3);
});
}
toggle() {
this.enabled = !this.enabled;
return this.enabled;
}
}
/* ================================================================
SECTION 6: UI RENDERER & INTERACTION
================================================================ */
class ChessUI {
constructor(game, ai) {
this.game = game;
this.ai = ai;
this.sound = new SoundEngine();
this.boardEl = document.getElementById('board');
this.promoOverlay = document.getElementById('promotion-overlay');
this.dragGhost = document.getElementById('drag-ghost');
this.selectedSquare = -1;
this.legalMovesForSelected = [];
this.flipped = false;
this.lastMove = null; // {from, to}
this.playerColor = 'w';
this.analysisMode = false;
this.aiThinking = false;
this.buildBoard();
this.render();
this.setupInteraction();
}
buildBoard() {
this.boardEl.innerHTML = '';
for (let displayRow = 0; displayRow < 8; displayRow++) {
for (let displayCol = 0; displayCol < 8; displayCol++) {
const row = this.flipped ? 7 - displayRow : displayRow;
const col = this.flipped ? 7 - displayCol : displayCol;
const sq = row * 16 + col;
const div = document.createElement('div');
div.className = 'square';
div.dataset.sq = sq;
div.classList.add((row + col) % 2 === 0 ? 'light' : 'dark');
// Coordinates
if (displayCol === 0) {
const rankLabel = document.createElement('span');
rankLabel.className = 'coord coord-rank';
rankLabel.textContent = RANKS[row];
div.appendChild(rankLabel);
}
if (displayRow === 7) {
const fileLabel = document.createElement('span');
fileLabel.className = 'coord coord-file';
fileLabel.textContent = FILES[col];
div.appendChild(fileLabel);
}
this.boardEl.appendChild(div);
}
}
}
render() {
this.buildBoard();
const allSquares = this.boardEl.querySelectorAll('.square');
const kingInCheckSq = this.game.isInCheck(this.game.turn) ? this.game.findKing(this.game.turn) : -1;
for (const sqEl of allSquares) {
const sq = parseInt(sqEl.dataset.sq);
if ((sq & 8) !== 0) continue;
const row = sqToRank(sq);
const col = sqToFile(sq);
const isLight = (row + col) % 2 === 0;
sqEl.classList.add(isLight ? 'light' : 'dark');
// Last move highlight
if (this.lastMove && (sq === this.lastMove.from || sq === this.lastMove.to)) {
sqEl.classList.add('last-move');
}
// Selected square
if (sq === this.selectedSquare) {
sqEl.classList.add('selected');
}
// Check glow on king
if (sq === kingInCheckSq) {
sqEl.classList.add('check-glow');
}
// Legal move indicators
const legalMove = this.legalMovesForSelected.find(m => m.to === sq);
if (legalMove) {
if (this.game.board[sq] !== EMPTY) {
sqEl.classList.add('legal-ring');
} else {
sqEl.classList.add('legal-dot');
}
}
// Render piece
const piece = this.game.board[sq];
if (piece !== EMPTY) {
const pieceSpan = document.createElement('span');
pieceSpan.className = 'piece ' + (isWhite(piece) ? 'white-piece' : 'black-piece');
pieceSpan.textContent = PIECE_SYMBOL[piece] || '?';
sqEl.appendChild(pieceSpan);
}
// Coordinate labels
const displayRow = this.flipped ? 7 - row : row;
const displayCol = this.flipped ? 7 - col : col;
if (displayCol === 0) {
const rankLabel = document.createElement('span');
rankLabel.className = 'coord coord-rank';
rankLabel.textContent = RANKS[row];
sqEl.appendChild(rankLabel);
}
if (displayRow === 7) {
const fileLabel = document.createElement('span');
fileLabel.className = 'coord coord-file';
fileLabel.textContent = FILES[col];
sqEl.appendChild(fileLabel);
}
}
this.updatePanel();
this.checkGameOver();
}
updatePanel() {
// Turn indicator
const turnDot = document.querySelector('.turn-dot');
const turnText = document.getElementById('turn-text');
if (this.game.turn === 'w') {
turnDot.className = 'turn-dot white';
turnText.textContent = this.game.isInCheck(this.game.turn) ? 'White in check!' : 'White to move';
} else {
turnDot.className = 'turn-dot black';
turnText.textContent = this.game.isInCheck(this.game.turn) ? 'Black in check!' : 'Black to move';
}
// Captured pieces
const capturedByWhite = document.getElementById('captured-by-white');
const capturedByBlack = document.getElementById('captured-by-black');
capturedByWhite.innerHTML = '';
capturedByBlack.innerHTML = '';
const pieceOrder = { Q: 0, R: 1, B: 2, N: 3, P: 4 };
const sortedCaptures = [...this.game.capturedPieces].sort((a,b) => (pieceOrder[a.type]||5)-(pieceOrder[b.type]||5));
for (const cp of sortedCaptures) {
const span = document.createElement('span');
// Captured by white means black pieces captured
if (cp.color === 'b') {
span.textContent = PIECE_UNICODE[Object.keys(PIECE_UNICODE).find(k => {
const map = {'k':BK,'q':BQ,'r':BR,'b':BB,'n':BN,'p':BP};
return map[k] === cp.type + 6;
})];
capturedByWhite.appendChild(span);
} else {
span.textContent = PIECE_UNICODE[Object.keys(PIECE_UNICODE).find(k => {
const map = {'K':WK,'Q':WQ,'R':WR,'B':WB,'N':WN,'P':WP};
return map[k] === cp.type;
})];
capturedByBlack.appendChild(span);
}
}
// Move list
this.renderMoveList();
}
renderMoveList() {
const moveListEl = document.getElementById('move-list');
moveListEl.innerHTML = '';
for (let i = 0; i < this.game.moveHistory.length; i += 2) {
const moveNum = Math.floor(i/2) + 1;
const numSpan = document.createElement('span');
numSpan.className = 'move-num';
numSpan.textContent = moveNum + '.';
const wMove = document.createElement('span');
wMove.className = 'move-w';
wMove.textContent = this.game.moveHistory[i]?.notation || '';
const bMove = document.createElement('span');
bMove.className = 'move-b';
bMove.textContent = (i+1 < this.game.moveHistory.length) ? (this.game.moveHistory[i+1]?.notation || '') : '';
moveListEl.appendChild(numSpan);
moveListEl.appendChild(wMove);
moveListEl.appendChild(bMove);
}
// Auto-scroll
const container = document.getElementById('move-list-container');
container.scrollTop = container.scrollHeight;
}
checkGameOver() {
if (this.game.gameOver) return;
const state = this.game.getGameState();
if (state !== 'ongoing') {
this.game.gameOver = true;
this.sound.playGameOver();
let title, subtitle;
switch(state) {
case 'checkmate':
const winner = this.game.turn === 'w' ? 'Black' : 'White';
this.game.result = this.game.turn === 'w' ? '0-1' : '1-0';
title = `${winner} wins!`;
subtitle = 'by checkmate';
break;
case 'stalemate':
this.game.result = '½-½';
title = 'Draw';
subtitle = 'by stalemate';
break;
case 'insufficient_material':
this.game.result = '½-½';
title = 'Draw';
subtitle = 'insufficient material';
break;
case 'fifty_move_rule':
this.game.result = '½-½';
title = 'Draw';
subtitle = '50-move rule';
break;
}
document.getElementById('go-title').textContent = title;
document.getElementById('go-subtitle').textContent = subtitle;
document.getElementById('game-over-banner').classList.add('active');
this.saveState();
}
}
setupInteraction() {
let dragPiece = null;
let dragFromSq = -1;
let isDragging = false;
const handleSquareClick = (sq) => {
if ((sq & 8) !== 0 || this.game.gameOver || this.aiThinking) return;
// Check if it's a legal move destination for selected piece
const legalMove = this.legalMovesForSelected.find(m => m.to === sq);
if (this.selectedSquare >= 0 && legalMove) {
this.executePlayerMove(legalMove);
return;
}
// Select a piece
const piece = this.game.board[sq];
if (piece !== EMPTY && pieceColor(piece) === this.playerColor && !this.analysisMode || this.analysisMode && piece !== EMPTY && pieceColor(piece) === this.game.turn) {
this.selectedSquare = sq;
this.legalMovesForSelected = this.game.generateLegalMoves(this.game.turn).filter(m => m.from === sq);
} else if (this.analysisMode && piece !== EMPTY && pieceColor(piece) === this.game.turn) {
this.selectedSquare = sq;
this.legalMovesForSelected = this.game.generateLegalMoves(this.game.turn).filter(m => m.from === sq);
} else {
this.selectedSquare = -1;
this.legalMovesForSelected = [];
}
this.render();
};
// Click handler
this.boardEl.addEventListener('click', (e) => {
const sqEl = e.target.closest('.square');
if (!sqEl) return;
const sq = parseInt(sqEl.dataset.sq);
handleSquareClick(sq);
});
// Drag and drop
this.boardEl.addEventListener('mousedown', (e) => {
const sqEl = e.target.closest('.square');
if (!sqEl) return;
const sq = parseInt(sqEl.dataset.sq);
if ((sq & 8) !== 0 || this.game.gameOver || this.aiThinking) return;
const piece = this.game.board[sq];
const canMove = (this.analysisMode ? pieceColor(piece) === this.game.turn : pieceColor(piece) === this.playerColor) && piece !== EMPTY;
if (!canMove) return;
dragFromSq = sq;
isDragging = false; // will be set true on mousemove
const ghost = document.getElementById('drag-ghost');
const key = Object.keys(PIECE_UNICODE).find(k => {
const map = {'K':WK,'Q':WQ,'R':WR,'B':WB,'N':WN,'P':WP,'k':BK,'q':BQ,'r':BR,'b':BB,'n':BN,'p':BP};
return map[k] === piece;
});
ghost.textContent = PIECE_UNICODE[key];
ghost.style.color = isWhite(piece) ? '#fff' : '#222';
if (isWhite(piece)) {
ghost.style.textShadow = '0 0 2px #000, 0 0 2px #000';
} else {
ghost.style.textShadow = '0 0 1px rgba(255,255,255,0.3)';
}
ghost.style.display = 'block';
const moveGhost = (ev) => {
ghost.style.left = (ev.clientX - 24) + 'px';
ghost.style.top = (ev.clientY - 24) + 'px';
};
moveGhost(e);
const onMouseMove = (ev) => {
if (!isDragging) {
isDragging = true;
// Hide the piece on the board
sqEl.querySelector('.piece')?.classList.add('dragging');
}
moveGhost(ev);
};
const onMouseUp = (ev) => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
ghost.style.display = 'none';
if (!isDragging) return; // It was a click, not a drag — handled by click handler
// Find the square under the mouse
const targetEl = document.elementFromPoint(ev.clientX, ev.clientY)?.closest('.square');
if (targetEl) {
const toSq = parseInt(targetEl.dataset.sq);
if ((toSq & 8) === 0 && toSq !== dragFromSq) {
const legalMove = this.game.generateLegalMoves(this.analysisMode ? this.game.turn : this.playerColor).find(m => m.from === dragFromSq && m.to === toSq);
if (legalMove) {
this.executePlayerMove(legalMove);
return;
}
}
}
// If no valid drop, re-render to restore piece appearance
this.render();
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
// Touch support for mobile
let touchStartSq = -1;
this.boardEl.addEventListener('touchstart', (e) => {
const touch = e.touches[0];
const sqEl = document.elementFromPoint(touch.clientX, touch.clientY)?.closest('.square');
if (!sqEl) return;
touchStartSq = parseInt(sqEl.dataset.sq);
}, { passive: true });
this.boardEl.addEventListener('touchend', (e) => {
if (touchStartSq >= 0) {
handleSquareClick(touchStartSq);
touchStartSq = -1;
}
}, { passive: true });
}
executePlayerMove(move) {
this.sound.init(); // Initialize audio context on user gesture
const notation = this.game.moveToFullAlgebraic(move);
const undoInfo = this.game.makeMove2(move.from, move.to, move.promotion || 'Q');
this.game.moveHistory.push({ ...undoInfo, notation });
this.lastMove = { from: move.from, to: move.to };
// Sound
if (this.game.board[move.to] !== EMPTY || (pieceType(this.game.board[move.to]) === PIECE_TYPE.P && move.ep)) {
this.sound.playCapture();
} else {
this.sound.playMove();
}
if (this.game.isInCheck(this.game.turn)) {
this.sound.playCheck();
}
this.selectedSquare = -1;
this.legalMovesForSelected = [];
this.render();
this.saveState();
// Check game over before AI move
if (this.game.getGameState() !== 'ongoing') return;
// AI's turn
if (!this.analysisMode && this.game.turn !== this.playerColor) {
this.aiThinking = true;
this.render();
// Use requestAnimationFrame to allow UI to update before blocking
requestAnimationFrame(() => {
setTimeout(() => {
const depth = parseInt(document.getElementById('depth-select').value);
const aiMove = this.ai.getBestMove(depth, this.game.turn);
if (aiMove) {
const aiNotation = this.game.moveToFullAlgebraic(aiMove);
const aiUndoInfo = this.game.makeMove2(aiMove.from, aiMove.to, aiMove.promotion || 'Q');
this.game.moveHistory.push({ ...aiUndoInfo, notation: aiNotation });
this.lastMove = { from: aiMove.from, to: aiMove.to };
if (this.game.board[aiMove.to] !== EMPTY) {
this.sound.playCapture();
} else {
this.sound.playMove();
}
if (this.game.isInCheck(this.game.turn)) {
this.sound.playCheck();
}
}
this.aiThinking = false;
this.render();
this.saveState();
}, 50);
});
} else if (!this.analysisMode) {
// Player plays both sides or it's player's turn already handled
}
}
showPromotionPicker(from, to) {
// This is called when a pawn reaches the promotion rank
const sqEl = this.boardEl.querySelector(`[data-sq="${to}"]`);
if (!sqEl) return;
const rect = sqEl.getBoundingClientRect();
const boardRect = this.boardEl.getBoundingClientRect();
this.promoOverlay.style.left = (rect.left - boardRect.left + 4) + 'px';
this.promoOverlay.style.top = (rect.top - boardRect.top - 4) + 'px';
this.promoOverlay.innerHTML = '';
const pieces = ['Q', 'R', 'B', 'N'];
for (const p of pieces) {
const btn = document.createElement('div');
btn.className = 'promo-piece';
const key = isWhite(this.game.board[from]) ? p : p.toLowerCase();
btn.textContent = PIECE_UNICODE[key];
btn.addEventListener('click', () => {
this.promoOverlay.classList.remove('active');
// Execute the move with promotion
const legalMove = this.legalMovesForSelected.find(m => m.to === to && m.promotion === p);
if (legalMove) {
this.executePlayerMove(legalMove);
}
});
this.promoOverlay.appendChild(btn);
}
this.promoOverlay.classList.add('active');
}
setPlayerColor(color, analysisMode = false) {
this.playerColor = color;
this.analysisMode = analysisMode;
this.selectedSquare = -1;
this.legalMovesForSelected = [];
this.render();
// If player chose black and it's white's turn (AI goes first), trigger AI move
if (!analysisMode && color === 'b' && this.game.turn === 'w' && !this.game.gameOver) {
this.aiThinking = true;
requestAnimationFrame(() => {
setTimeout(() => {
const depth = parseInt(document.getElementById('depth-select').value);
const aiMove = this.ai.getBestMove(depth, 'w');
if (aiMove) {
const notation = this.game.moveToFullAlgebraic(aiMove);
const undoInfo = this.game.makeMove2(aiMove.from, aiMove.to, aiMove.promotion || 'Q');
this.game.moveHistory.push({ ...undoInfo, notation });
this.lastMove = { from: aiMove.from, to: aiMove.to };
this.sound.playMove();
}
this.aiThinking = false;
this.render();
this.saveState();
}, 50);
});
}
}
flipBoard() {
this.flipped = !this.flipped;
this.buildBoard();
this.render();
}
undoMove() {
if (this.game.moveHistory.length < 2 || this.aiThinking) return;
// Undo AI move and player move (two moves back)
const aiUndoInfo = this.game.moveHistory[this.game.moveHistory.length - 1];
this.game.unmakeMove(aiUndoInfo);
const playerUndoInfo = this.game.moveHistory[this.game.moveHistory.length - 1];
this.game.unmakeMove(playerUndoInfo);
// Update last move highlight
if (this.game.moveHistory.length > 0) {
const last = this.game.moveHistory[this.game.moveHistory.length - 1];
this.lastMove = { from: last.from, to: last.to };
} else {
this.lastMove = null;
}
this.selectedSquare = -1;
this.legalMovesForSelected = [];
this.render();
this.saveState();
}
saveState() {
try {
const state = {
fen: this.game.fen(),
moveHistory: this.game.moveHistory.map(m => ({ from: m.from, to: m.to, notation: m.notation })),
lastMove: this.lastMove,
capturedPieces: this.game.capturedPieces,
gameOver: this.game.gameOver,
result: this.game.result,
resultReason: this.game.resultReason,
};
localStorage.setItem('chessGameState', JSON.stringify(state));
} catch(e) {}
}
loadState() {
try {
const saved = localStorage.getItem('chessGameState');
if (!saved) return false;
const state = JSON.parse(saved);
this.game.loadFEN(state.fen);
this.game.moveHistory = state.moveHistory || [];
this.lastMove = state.lastMove || null;
this.game.capturedPieces = state.capturedPieces || [];
this.game.gameOver = state.gameOver || false;
this.game.result = state.result || null;
if (this.game.gameOver) {
document.getElementById('go-title').textContent = this.game.result === '1-0' ? 'White wins!' : this.game.result === '0-1' ? 'Black wins!' : 'Draw';
document.getElementById('game-over-banner').classList.add('active');
}
return true;
} catch(e) {
return false;
}
}
}
/* ================================================================
SECTION 7: MAIN INITIALIZATION & DEBUG INTERFACE
================================================================ */
let game, ai, ui;
function initGame() {
game = new GameState();
ai = new ChessAI(game);
ui = new ChessUI(game, ai);
// Try to load saved state
if (!ui.loadState()) {
const sideSelect = document.getElementById('side-select').value;
if (sideSelect === 'both') {
ui.setPlayerColor('w', true);
} else {
ui.setPlayerColor(sideSelect === 'black' ? 'b' : 'w');
}
} else {
// Restore side selection from saved state
const turn = game.turn;
if (game.moveHistory.length > 0) {
// Game was in progress, figure out player color from move count
const lastMoveByWhite = game.turn === 'b'; // If it's black's turn, white just moved
ui.setPlayerColor(lastMoveByWhite ? 'w' : 'b');
}
}
// Side selector change handler
document.getElementById('side-select').addEventListener('change', (e) => {
const val = e.target.value;
if (val === 'both') {
ui.setPlayerColor('w', true);
} else {
newGame();
setTimeout(() => ui.setPlayerColor(val === 'black' ? 'b' : 'w'), 100);
}
});
// Expose debug interface
window.game = game;
window.ai = ai;
window.ui = ui;
}
function newGame() {
document.getElementById('game-over-banner').classList.remove('active');
localStorage.removeItem('chessGameState');
game = new GameState();
ai = new ChessAI(game);
ui = new ChessUI(game, ai);
const sideSelect = document.getElementById('side-select').value;
if (sideSelect === 'both') {
ui.setPlayerColor('w', true);
} else {
ui.setPlayerColor(sideSelect === 'black' ? 'b' : 'w');
}
window.game = game;
window.ai = ai;
window.ui = ui;
}
function undoMove() {
if (ui) ui.undoMove();
}
function flipBoard() {
if (ui) ui.flipBoard();
}
function toggleMute() {
if (!ui) return;
const enabled = ui.sound.toggle();
document.getElementById('mute-btn').textContent = enabled ? '🔊 On' : '🔇 Off';
}
// Debug helpers on window
window.debug = {
legalMoves: (color) => game.generateLegalMoves(color || game.turn),
move: (from, to, promotion) => {
const moves = game.generateLegalMoves(game.turn);
const m = typeof from === 'string' ?
moves.find(mv => sqToAlgebraic(mv.from) === from && sqToAlgebraic(mv.to) === to) :
moves.find(mv => mv.from === from && mv.to === to);
if (!m) return { ok: false, reason: 'Illegal move' };
const notation = game.moveToFullAlgebraic(m);
const undoInfo = game.makeMove2(m.from, m.to, m.promotion || promotion || 'Q');
game.moveHistory.push({ ...undoInfo, notation });
if (ui) { ui.lastMove = { from: m.from, to: m.to }; ui.render(); }
return { ok: true, notation, fen: game.fen() };
},
fen: () => game.fen(),
evaluate: () => ai.evaluate(),
};
// Start the game
initGame();
</script>
</body>
</html>