import { Chess } from 'chess.js'; // --------------------------------------------------------------------------- // Radical Chess — client SPA // --------------------------------------------------------------------------- const GLYPH = { wk: '♚', wq: '♛', wr: '♜', wb: '♝', wn: '♞', wp: '♟', bk: '♚', bq: '♛', br: '♜', bb: '♝', bn: '♞', bp: '♟', }; const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']; const app = document.getElementById('app'); function el(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstChild; } function fmtClock(ms) { if (ms == null) return '∞'; ms = Math.max(0, ms); const s = Math.floor(ms / 1000); const m = Math.floor(s / 60); const sec = s % 60; if (m >= 60) { const h = Math.floor(m / 60); return `${h}:${String(m % 60).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; } const tenths = ms < 10000 ? '.' + Math.floor((ms % 1000) / 100) : ''; return `${m}:${String(sec).padStart(2, '0')}${tenths}`; } function toast(msg) { document.querySelectorAll('.toast').forEach((t) => t.remove()); const t = el(`
${msg}
`); document.body.appendChild(t); setTimeout(() => t.remove(), 2600); } function sidFor(id) { const key = 'rc_sid_' + id; let v = localStorage.getItem(key); if (!v) { v = (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)) + 'x'; localStorage.setItem(key, v); } return v; } const myName = () => localStorage.getItem('rc_name') || 'Anonymous'; // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- function navigate(path) { history.pushState({}, '', path); route(); } window.addEventListener('popstate', route); document.addEventListener('click', (e) => { const a = e.target.closest('a[href^="/"]'); if (a && !a.target) { e.preventDefault(); navigate(a.getAttribute('href')); } }); function route() { const p = location.pathname; if (p.startsWith('/g/')) return GameView(p.slice(3).split('?')[0]); if (p === '/bot') return LocalView('bot'); if (p === '/local') return LocalView('local'); if (p === '/watch') return WatchView(); return HomeView(); } // --------------------------------------------------------------------------- // Home // --------------------------------------------------------------------------- const TIME_CONTROLS = [ { base: 60, inc: 0, label: '1+0', tag: 'Bullet' }, { base: 120, inc: 1, label: '2+1', tag: 'Bullet' }, { base: 180, inc: 0, label: '3+0', tag: 'Blitz' }, { base: 300, inc: 3, label: '5+3', tag: 'Blitz' }, { base: 600, inc: 5, label: '10+5', tag: 'Rapid' }, { base: 900, inc: 10, label: '15+10', tag: 'Rapid' }, { base: 1800, inc: 0, label: '30+0', tag: 'Classical' }, { base: 0, inc: 0, label: '∞', tag: 'Unlimited' }, ]; function HomeView() { let tcIdx = 3; let color = 'random'; let botDiff = 2; app.innerHTML = ''; const view = el(`

Play radical chess.

Real-time online chess. Challenge a friend by link, take on the bot, or pass-and-play. No account, no fluff.

Play a friend

Pick a time control, create a game, share the link.

Play the bot

Radical Bot — a homegrown engine. Choose your poison.
`); app.appendChild(view); const grid = view.querySelector('#tcgrid'); TIME_CONTROLS.forEach((tc, i) => { const b = el(`
${tc.label}${tc.tag}
`); b.onclick = () => { tcIdx = i; grid.querySelectorAll('.tc').forEach((x) => x.classList.remove('on')); b.classList.add('on'); }; grid.appendChild(b); }); view.querySelector('#colorseg').onclick = (e) => { const b = e.target.closest('button'); if (!b) return; view.querySelectorAll('#colorseg button').forEach((x) => x.classList.remove('on')); b.classList.add('on'); color = b.dataset.c; }; view.querySelector('#diff').onclick = (e) => { const b = e.target.closest('button'); if (!b) return; view.querySelectorAll('#diff button').forEach((x) => x.classList.remove('on')); b.classList.add('on'); botDiff = +b.dataset.d; }; let botColor = 'white'; view.querySelector('#botcolor').onclick = (e) => { const b = e.target.closest('button'); if (!b) return; view.querySelectorAll('#botcolor button').forEach((x) => x.classList.remove('on')); b.classList.add('on'); botColor = b.dataset.c; }; view.querySelector('#create').onclick = async (e) => { e.target.disabled = true; e.target.textContent = 'Creating…'; const tc = TIME_CONTROLS[tcIdx]; try { const r = await fetch('/api/new', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ base: tc.base, inc: tc.inc }) }); const { id } = await r.json(); let c = color; if (c === 'random') c = Math.random() < 0.5 ? 'white' : 'black'; navigate(`/g/${id}?c=${c}&host=1`); } catch { toast('Could not create game'); e.target.disabled = false; e.target.textContent = 'Create game'; } }; view.querySelector('#playbot').onclick = () => { sessionStorage.setItem('rc_bot', JSON.stringify({ diff: botDiff, human: botColor })); navigate('/bot'); }; view.querySelector('#playlocal').onclick = () => navigate('/local'); } // --------------------------------------------------------------------------- // Board rendering (shared by all views) // --------------------------------------------------------------------------- class BoardUI { constructor(mount, onMove) { this.mount = mount; this.onMove = onMove; this.orient = 'w'; this.selected = null; this.chess = new Chess(); this.interactive = true; this.lastMove = null; this.pendingPromo = null; this.board = el('
'); mount.appendChild(this.board); this._bindDrag(); } setOrientation(o) { this.orient = o; this.render(); } load(fen, lastMove) { try { this.chess.load(fen); } catch {} const prev = this.lastMove; this.lastMove = lastMove || null; // flag a genuinely new move so render() can pulse the destination square this._pulse = !!(this.lastMove && (!prev || prev.from !== this.lastMove.from || prev.to !== this.lastMove.to)); this.selected = null; this.render(); } squares() { const rows = this.orient === 'w' ? [8, 7, 6, 5, 4, 3, 2, 1] : [1, 2, 3, 4, 5, 6, 7, 8]; const cols = this.orient === 'w' ? FILES : [...FILES].reverse(); const out = []; for (const r of rows) for (const f of cols) out.push(f + r); return out; } render() { const board = this.board; board.innerHTML = ''; const kingSq = this.chess.inCheck() ? this._kingSquare(this.chess.turn()) : null; let legal = []; if (this.selected) legal = this.chess.moves({ square: this.selected, verbose: true }); const legalTo = new Set(legal.map((m) => m.to)); this.squares().forEach((sq, i) => { const file = sq[0], rank = +sq[1]; const dark = (FILES.indexOf(file) + rank) % 2 === 0; const cell = el(`
`); if (this.lastMove && (sq === this.lastMove.from || sq === this.lastMove.to)) cell.classList.add('last'); if (this.lastMove && sq === this.lastMove.to) { cell.classList.add('lastto'); if (this._pulse) cell.classList.add('pulse'); } if (sq === this.selected) cell.classList.add('sel'); if (sq === kingSq) cell.classList.add('check'); const col = i % 8, row = Math.floor(i / 8); if (row === 7) cell.appendChild(el(`${file}`)); if (col === 0) cell.appendChild(el(`${rank}`)); const p = this.chess.get(sq); if (p) { const pieceEl = el(`
${GLYPH[p.color + p.type]}
`); pieceEl.dataset.sq = sq; cell.appendChild(pieceEl); } if (legalTo.has(sq)) cell.appendChild(el(`
`)); board.appendChild(cell); }); this._drawLastArrow(); this._pulse = false; if (this.pendingPromo) this._renderPromo(); } // column/row (0..7) of a square in the CURRENT board orientation _cellPos(sq) { const f = FILES.indexOf(sq[0]); const r = +sq[1]; return this.orient === 'w' ? { col: f, row: 8 - r } : { col: 7 - f, row: r - 1 }; } // draw a lichess-style arrow from the last move's origin to its destination _drawLastArrow() { const m = this.lastMove; if (!m || !m.from || !m.to) return; const a = this._cellPos(m.from), b = this._cellPos(m.to); const x1 = a.col + 0.5, y1 = a.row + 0.5; const x2 = b.col + 0.5, y2 = b.row + 0.5; const dx = x2 - x1, dy = y2 - y1; const len = Math.hypot(dx, dy) || 1; const ux = dx / len, uy = dy / len; const head = 0.42; // arrowhead length (in square units) const start = 0.34; // pull the tail out from the piece center const sx = x1 + ux * start, sy = y1 + uy * start; const tipX = x2 - ux * 0.06, tipY = y2 - uy * 0.06; const baseX = tipX - ux * head, baseY = tipY - uy * head; const px = -uy, py = ux; // perpendicular const hw = 0.24; // half-width of arrowhead base const svg = el(``); svg.innerHTML = `` + ``; this.board.appendChild(svg); } _kingSquare(color) { for (const sq of this.squares()) { const p = this.chess.get(sq); if (p && p.type === 'k' && p.color === color) return sq; } return null; } _clickSquare(sq) { if (!this.interactive) return; const p = this.chess.get(sq); if (this.selected) { if (sq === this.selected) { this.selected = null; this.render(); return; } const moves = this.chess.moves({ square: this.selected, verbose: true }); const m = moves.find((x) => x.to === sq); if (m) { this._tryMove(this.selected, sq, m); return; } if (p && p.color === this.chess.turn()) { this.selected = sq; this.render(); return; } this.selected = null; this.render(); return; } if (p && p.color === this.chess.turn()) { this.selected = sq; this.render(); } } _tryMove(from, to, moveInfo) { if (moveInfo && moveInfo.promotion) { this.pendingPromo = { from, to }; this.render(); return; } this.selected = null; this.onMove(from, to, null); } _renderPromo() { const color = this.chess.turn(); const wrap = el('
'); const inner = wrap.querySelector('.promo-inner'); ['q', 'r', 'b', 'n'].forEach((t) => { const pc = el(`
${GLYPH[color + t]}
`); pc.onclick = () => { const { from, to } = this.pendingPromo; this.pendingPromo = null; this.selected = null; this.onMove(from, to, t); }; inner.appendChild(pc); }); this.board.appendChild(wrap); } _bindDrag() { let ghost = null, fromSq = null; const pointToSq = (x, y) => { const els = document.elementsFromPoint(x, y); const cell = els.find((e) => e.classList && e.classList.contains('sq')); return cell ? cell.dataset.sq : null; }; this.board.addEventListener('pointerdown', (e) => { if (!this.interactive) return; const pieceEl = e.target.closest('.piece'); const cell = e.target.closest('.sq'); if (!cell) return; const sq = cell.dataset.sq; const p = this.chess.get(sq); // click-move first this._clickSquare(sq); if (!pieceEl || !p || p.color !== this.chess.turn() || !this.selected) return; fromSq = this.selected; e.preventDefault(); ghost = el(`
${GLYPH[p.color + p.type]}
`); ghost.style.width = cell.offsetWidth + 'px'; ghost.style.height = cell.offsetHeight + 'px'; ghost.style.fontSize = getComputedStyle(pieceEl).fontSize; ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px'; document.body.appendChild(ghost); pieceEl.classList.add('dragging'); this._dragPiece = pieceEl; }); window.addEventListener('pointermove', (e) => { if (!ghost) return; ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px'; }); window.addEventListener('pointerup', (e) => { if (!ghost) return; const target = pointToSq(e.clientX, e.clientY); ghost.remove(); ghost = null; if (this._dragPiece) this._dragPiece.classList.remove('dragging'); if (target && fromSq && target !== fromSq) { const moves = this.chess.moves({ square: fromSq, verbose: true }); const m = moves.find((x) => x.to === target); if (m) this._tryMove(fromSq, target, m); else { this.selected = null; this.render(); } } fromSq = null; }); this.board.addEventListener('click', (e) => { /* handled in pointerdown */ }); } } // --------------------------------------------------------------------------- // Move-list rendering // --------------------------------------------------------------------------- function renderMoves(container, moves) { container.innerHTML = ''; for (let i = 0; i < moves.length; i += 2) { const num = i / 2 + 1; container.appendChild(el(`${num}.`)); container.appendChild(el(`${moves[i].san}`)); if (moves[i + 1]) container.appendChild(el(`${moves[i + 1].san}`)); } container.scrollTop = container.scrollHeight; } // --------------------------------------------------------------------------- // Online game view (WebSocket) // --------------------------------------------------------------------------- function GameView(id) { const params = new URLSearchParams(location.search); const wantColor = params.get('c'); // white|black (host) or null const isHost = params.get('host') === '1'; const sid = sidFor(id); app.innerHTML = ''; const view = el(`
Connecting…

Moves

Chat

`); app.appendChild(view); let state = null; let mySeat = 'spec'; const board = new BoardUI(view.querySelector('#board-mount'), sendMove); // websocket const proto = location.protocol === 'https:' ? 'wss' : 'ws'; let ws, closed = false, clockTimer; function connect() { ws = new WebSocket(`${proto}://${location.host}/ws/${id}?sid=${sid}`); ws.onopen = () => { const want = wantColor ? wantColor : 'random'; ws.send(JSON.stringify({ t: 'hello', name: myName(), want })); }; ws.onmessage = (ev) => onServer(JSON.parse(ev.data)); ws.onclose = () => { if (!closed) { setStatus('Reconnecting…'); setTimeout(connect, 1200); } }; } function send(o) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); } function sendMove(from, to, promo) { if (mySeat !== state?.you || state?.status !== 'active') return; send({ t: 'move', from, to, promotion: promo || 'q' }); } function onServer(m) { if (m.t === 'error') { toast(m.m); if (state) board.load(state.fen, lastMoveOf(state)); return; } if (m.t === 'pong') return; if (m.t !== 'state') return; state = m; mySeat = m.you; board.orient = mySeat === 'b' ? 'b' : 'w'; board.interactive = (mySeat === 'w' || mySeat === 'b') && m.status === 'active'; board.load(m.fen, lastMoveOf(m)); renderMoves(view.querySelector('#moves'), m.moves); renderPlayers(); renderStatus(); renderActions(); renderChat(); renderShare(); startClockTick(); } function lastMoveOf(m) { return m.moves.length ? m.moves[m.moves.length - 1] : null; } function renderShare() { const box = view.querySelector('#sharebox'); const waiting = state.status === 'waiting' && (mySeat === 'w' || mySeat === 'b'); box.style.display = waiting ? 'block' : 'none'; if (waiting) { const link = `${location.origin}/g/${id}`; view.querySelector('#sharelink').textContent = link; view.querySelector('#copybtn').onclick = () => { navigator.clipboard.writeText(link); toast('Link copied — send it to your opponent'); }; } } function pbHTML(side, top) { const p = state[side === 'w' ? 'white' : 'black']; const name = p.name || (p.seated ? (side === 'w' ? 'White' : 'Black') : 'Waiting…'); const clockCls = 'clock' + (clockActive(side) ? ' active' : ''); return `
${name} ${side === 'w' ? '♔' : '♚'}
${state.tc.base === 0 ? '∞' : fmtClock(state.clock[side])}
`; } function renderPlayers() { const topSide = board.orient === 'w' ? 'b' : 'w'; const botSide = board.orient === 'w' ? 'w' : 'b'; view.querySelector('#pb-top').innerHTML = pbHTML(topSide, true); view.querySelector('#pb-bot').innerHTML = pbHTML(botSide, false); } function clockActive(side) { if (state.status !== 'active') return false; const turn = state.fen.split(' ')[1]; return side === turn; } function startClockTick() { clearInterval(clockTimer); if (state.status !== 'active' || state.tc.base === 0) return; const base = { w: state.clock.w, b: state.clock.b }; const turn = state.fen.split(' ')[1]; const t0 = Date.now(); clockTimer = setInterval(() => { const elapsed = Date.now() - t0; const live = { w: base.w, b: base.b }; live[turn] = Math.max(0, base[turn] - elapsed); for (const s of ['w', 'b']) { const elc = view.querySelector(`.clock[data-clockside="${s}"]`); if (elc) { elc.textContent = fmtClock(live[s]); elc.classList.toggle('low', live[s] < 20000); } } }, 100); } function renderStatus() { const s = view.querySelector('#status'); if (state.status === 'waiting') { s.innerHTML = mySeat === 'spec' ? 'Waiting for players…' : 'Waiting for your opponent to join…'; return; } if (state.status === 'active') { const turn = state.fen.split(' ')[1]; const yours = turn === mySeat; s.innerHTML = mySeat === 'spec' ? `${turn === 'w' ? 'White' : 'Black'} to move` : yours ? 'Your move' : 'Waiting for opponent…'; if (state.drawOffer && state.drawOffer !== mySeat && mySeat !== 'spec') { s.innerHTML += ``; setTimeout(() => { view.querySelector('#acc').onclick = () => send({ t: 'draw-accept' }); view.querySelector('#dec').onclick = () => send({ t: 'draw-decline' }); }, 0); } return; } // over const label = state.result === '1-0' ? 'White wins' : state.result === '0-1' ? 'Black wins' : 'Draw'; s.innerHTML = `${label} by ${state.reason || 'game over'}`; } function renderActions() { const a = view.querySelector('#actions'); a.innerHTML = ''; if (mySeat === 'spec') { a.innerHTML = ''; } else if (state.status === 'active') { a.innerHTML = ``; } else if (state.status === 'over') { const mine = state.rematch[mySeat]; a.innerHTML = ``; } const q = (s) => view.querySelector(s); q('#flip') && (q('#flip').onclick = () => { board.setOrientation(board.orient === 'w' ? 'b' : 'w'); renderPlayers(); }); q('#home') && (q('#home').onclick = () => { closed = true; ws && ws.close(); navigate('/'); }); q('#draw') && (q('#draw').onclick = () => { send({ t: 'draw-offer' }); toast('Draw offered'); }); q('#resign') && (q('#resign').onclick = () => { if (confirm('Resign this game?')) send({ t: 'resign' }); }); q('#rematch') && (q('#rematch').onclick = () => { send({ t: 'rematch' }); toast('Rematch offer sent'); }); } function renderChat() { const log = view.querySelector('#chatlog'); log.innerHTML = ''; for (const c of state.chat) log.appendChild(el(`
${escapeHtml(c.who)}: ${escapeHtml(c.text)}
`)); log.scrollTop = log.scrollHeight; } const chatin = view.querySelector('#chatin'); chatin.addEventListener('keydown', (e) => { if (e.key === 'Enter' && chatin.value.trim()) { send({ t: 'chat', text: chatin.value.trim() }); chatin.value = ''; } }); function setStatus(t) { const s = view.querySelector('#status'); if (s) s.textContent = t; } window.addEventListener('popstate', () => { closed = true; ws && ws.close(); clearInterval(clockTimer); }, { once: true }); connect(); } function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } // --------------------------------------------------------------------------- // Local + Bot view (no server) // --------------------------------------------------------------------------- function LocalView(mode) { const cfg = mode === 'bot' ? JSON.parse(sessionStorage.getItem('rc_bot') || '{"diff":2,"human":"white"}') : null; const humanColor = cfg ? cfg.human[0] : null; // 'w' or 'b' ; null => both (pass&play) app.innerHTML = ''; const view = el(`

Moves

`); app.appendChild(view); const chess = new Chess(); const board = new BoardUI(view.querySelector('#board-mount'), onMove); board.orient = humanColor === 'b' ? 'b' : 'w'; let thinking = false; function sync() { board.load(chess.fen(), lastMv()); renderMoves(view.querySelector('#moves'), chess.history({ verbose: true })); renderPlayers(); renderStatus(); } function lastMv() { const h = chess.history({ verbose: true }); return h.length ? h[h.length - 1] : null; } function onMove(from, to, promo) { if (thinking) return; if (mode === 'bot' && chess.turn() !== humanColor) return; try { chess.move({ from, to, promotion: promo || 'q' }); } catch { return; } sync(); if (mode === 'bot' && !chess.isGameOver() && chess.turn() !== humanColor) { thinking = true; board.interactive = false; setTimeout(() => { botMove(chess, cfg.diff); thinking = false; board.interactive = true; sync(); }, 350); } } function renderPlayers() { const names = mode === 'bot' ? { w: humanColor === 'w' ? myName() : 'Radical Bot', b: humanColor === 'b' ? myName() : 'Radical Bot' } : { w: 'White', b: 'Black' }; const top = board.orient === 'w' ? 'b' : 'w', bot = board.orient === 'w' ? 'w' : 'b'; const html = (s) => `
${names[s]} ${s === 'w' ? '♔' : '♚'}
`; view.querySelector('#pb-top').innerHTML = html(top); view.querySelector('#pb-bot').innerHTML = html(bot); } function renderStatus() { const s = view.querySelector('#status'); if (chess.isGameOver()) { let label = 'Draw', reason = 'draw'; if (chess.isCheckmate()) { label = (chess.turn() === 'w' ? 'Black' : 'White') + ' wins'; reason = 'checkmate'; } else if (chess.isStalemate()) reason = 'stalemate'; else if (chess.isInsufficientMaterial()) reason = 'insufficient material'; else if (chess.isThreefoldRepetition()) reason = 'repetition'; else if (chess.isDraw()) reason = 'fifty-move rule'; s.innerHTML = `${label} by ${reason}`; } else { s.innerHTML = `${chess.turn() === 'w' ? 'White' : 'Black'} to move${chess.inCheck() ? ' — check' : ''}`; } } view.querySelector('#flip').onclick = () => { board.setOrientation(board.orient === 'w' ? 'b' : 'w'); renderPlayers(); }; view.querySelector('#undo').onclick = () => { chess.undo(); if (mode === 'bot' && chess.turn() !== humanColor) chess.undo(); sync(); }; view.querySelector('#new').onclick = () => { chess.reset(); sync(); if (mode === 'bot' && humanColor === 'b') { thinking = true; setTimeout(() => { botMove(chess, cfg.diff); thinking = false; sync(); }, 300); } }; view.querySelector('#home').onclick = () => navigate('/'); sync(); if (mode === 'bot' && humanColor === 'b') { thinking = true; board.interactive = false; setTimeout(() => { botMove(chess, cfg.diff); thinking = false; board.interactive = true; sync(); }, 400); } } // --------------------------------------------------------------------------- // Radical Bot — minimax + alpha-beta with material/position eval // --------------------------------------------------------------------------- const PVAL = { p: 100, n: 320, b: 330, r: 500, q: 900, k: 0 }; const PST_PAWN = [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 PST_KNIGHT = [-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 PST_CENTER = [-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]; function sqIndex(sq) { return (8 - +sq[1]) * 8 + FILES.indexOf(sq[0]); } function pstFor(type) { return type === 'p' ? PST_PAWN : type === 'n' ? PST_KNIGHT : PST_CENTER; } function evaluate(chess) { if (chess.isCheckmate()) return chess.turn() === 'w' ? -100000 : 100000; if (chess.isDraw() || chess.isStalemate()) return 0; let score = 0; const b = chess.board(); for (let r = 0; r < 8; r++) for (let c = 0; c < 8; c++) { const p = b[r][c]; if (!p) continue; const idx = r * 8 + c; const pst = pstFor(p.type); const posVal = p.color === 'w' ? pst[idx] : pst[63 - idx]; const v = PVAL[p.type] + posVal; score += p.color === 'w' ? v : -v; } return score; } function search(chess, depth, alpha, beta) { if (depth === 0 || chess.isGameOver()) return evaluate(chess); const moves = chess.moves({ verbose: true }); const maximizing = chess.turn() === 'w'; let best = maximizing ? -Infinity : Infinity; // order captures first moves.sort((a, b) => (b.captured ? 1 : 0) - (a.captured ? 1 : 0)); for (const m of moves) { chess.move(m); const val = search(chess, depth - 1, alpha, beta); chess.undo(); if (maximizing) { if (val > best) best = val; alpha = Math.max(alpha, val); } else { if (val < best) best = val; beta = Math.min(beta, val); } if (beta <= alpha) break; } return best; } function botMove(chess, difficulty) { const moves = chess.moves({ verbose: true }); if (!moves.length) return; // Easy: mostly random with capture bias if (difficulty === 1) { if (Math.random() < 0.35) { chess.move(moves[Math.floor(Math.random() * moves.length)]); return; } } const depth = difficulty === 1 ? 1 : difficulty === 2 ? 2 : 3; const maximizing = chess.turn() === 'w'; let best = null, bestVal = maximizing ? -Infinity : Infinity; moves.sort((a, b) => (b.captured ? 1 : 0) - (a.captured ? 1 : 0)); for (const m of moves) { chess.move(m); let val = search(chess, depth - 1, -Infinity, Infinity); chess.undo(); val += (Math.random() - 0.5) * (difficulty === 3 ? 4 : 24); // jitter to vary play if (maximizing ? val > bestVal : val < bestVal) { bestVal = val; best = m; } } chess.move(best || moves[0]); } // --------------------------------------------------------------------------- // Watch view — recent finished games // --------------------------------------------------------------------------- async function WatchView() { app.innerHTML = ''; const view = el(`

Recent games

Loading…
`); app.appendChild(view); try { const { games } = await (await fetch('/api/recent')).json(); const rows = view.querySelector('#rows'); if (!games || !games.length) { rows.innerHTML = '
No finished games yet. Be the first — go play!
'; return; } rows.innerHTML = ''; for (const g of games) { rows.appendChild(el(`
${escapeHtml(g.white_name || 'White')} vs ${escapeHtml(g.black_name || 'Black')} ${g.result} · ${g.moves} moves · ${escapeHtml(g.time_control || '')}
`)); } } catch { view.querySelector('#rows').innerHTML = '
Could not load games.
'; } } // --------------------------------------------------------------------------- route();