Radical Chess — real-time online chess on Cloudflare

Play by shareable link, vs the Radical Bot, or pass-and-play. Server-authoritative
moves (chess.js) in a per-game Durable Object, clocks with idle flag-fall via DO
alarms, draw/resign/rematch/chat, D1 game archive, custom board UI (drag + click).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
maverick 2026-07-23 21:29:02 +10:00
commit 4f2676df1e
11 changed files with 3440 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
public/dist/
.wrangler/
.dev.vars
*.log
.DS_Store

59
README.md Normal file
View file

@ -0,0 +1,59 @@
# ♞ Radical Chess
Real-time online chess, live at **[chess.theradicalparty.com](https://chess.theradicalparty.com)**.
A Radical Party project — *Vibe, Vote, Veto.*
Play a friend by shareable link, take on the homegrown **Radical Bot**, or
pass-and-play on one device. No account, no ads.
## Features
- **Play online** — create a game, pick a time control, share the link. Moves
stream live over WebSockets; reconnect keeps your seat.
- **Server-authoritative** — every move is validated server-side with
[chess.js](https://github.com/jhlywa/chess.js); illegal moves are rejected.
- **Clocks** — bullet → classical presets with increment, plus an unlimited mode.
Flag-fall is detected even when a player is idle (Durable Object alarms).
- **Full ruleset** — castling, en passant, promotion, checkmate, stalemate,
threefold repetition, insufficient material, fifty-move rule.
- **Draw offers, resign, rematch** (colors swap on rematch), and in-game chat.
- **Radical Bot** — a minimax + alpha-beta engine (Easy / Medium / Hard) with
piece-square evaluation, running entirely client-side.
- **Spectate** — anyone with the link watches live once both seats are taken.
- **Game archive** — finished games are stored (PGN) and listed under `/watch`.
## Stack
- **Cloudflare Workers** + [Hono](https://hono.dev) — HTTP + WebSocket routing
- **Durable Objects** — one instance per game holds authoritative state and
broadcasts to all sockets using WebSocket hibernation
- **D1** — finished-game archive and public feed
- **Static assets** — vanilla-JS SPA; the board is rendered from scratch
(drag-and-drop + click-to-move, no external board library)
## Layout
```
src/index.js Worker entry (Hono) — /api/new, /ws/:id, /api/recent
src/game.js Game Durable Object — rules, clocks, chat, archival
client/app.js SPA: router, board UI, online/bot/local views, bot engine
public/ index.html, style.css, built app.js (public/dist)
migrations/ D1 schema
```
## Develop
```bash
npm install
npm run dev # builds client, runs wrangler dev
```
## Deploy
Pushing to `main` auto-deploys via the Forgejo post-receive hook
(`npm run build:client` runs automatically before `wrangler deploy`).
```bash
npm run db:migrate # apply D1 migrations (remote)
npm run deploy # wrangler deploy
```

667
client/app.js Normal file
View file

@ -0,0 +1,667 @@
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(`<div class="toast">${msg}</div>`);
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(`
<div class="home">
<div class="hero">
<h1>Play <span class="g">radical</span> chess.</h1>
<p>Real-time online chess. Challenge a friend by link, take on the bot, or pass-and-play. No account, no fluff.</p>
</div>
<div class="panels">
<div class="card" id="online-card">
<h2>Play a friend</h2>
<div class="sub">Pick a time control, create a game, share the link.</div>
<div class="tc-grid" id="tcgrid"></div>
<div class="seg" id="colorseg">
<button data-c="white" class="on"> White</button>
<button data-c="random"> Random</button>
<button data-c="black"> Black</button>
</div>
<button class="btn primary" id="create">Create game</button>
</div>
<div class="card">
<h2>Play the bot</h2>
<div class="sub">Radical Bot a homegrown engine. Choose your poison.</div>
<div class="diff" id="diff">
<button data-d="1">Easy</button>
<button data-d="2" class="on">Medium</button>
<button data-d="3">Hard</button>
</div>
<div class="seg" id="botcolor">
<button data-c="white" class="on"> White</button>
<button data-c="black"> Black</button>
</div>
<button class="btn primary" id="playbot">Play bot</button>
<div style="height:10px"></div>
<button class="btn ghost" id="playlocal">Pass &amp; play (local)</button>
</div>
</div>
</div>`);
app.appendChild(view);
const grid = view.querySelector('#tcgrid');
TIME_CONTROLS.forEach((tc, i) => {
const b = el(`<div class="tc ${i === tcIdx ? 'on' : ''}"><b>${tc.label}</b><span>${tc.tag}</span></div>`);
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('<div class="board"></div>');
mount.appendChild(this.board);
this._bindDrag();
}
setOrientation(o) { this.orient = o; this.render(); }
load(fen, lastMove) {
try { this.chess.load(fen); } catch {}
this.lastMove = lastMove || null;
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(`<div class="sq ${dark ? 'dark' : 'light'}" data-sq="${sq}"></div>`);
if (this.lastMove && (sq === this.lastMove.from || sq === this.lastMove.to)) cell.classList.add('last');
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(`<span class="coord file">${file}</span>`));
if (col === 0) cell.appendChild(el(`<span class="coord rank">${rank}</span>`));
const p = this.chess.get(sq);
if (p) {
const pieceEl = el(`<div class="piece ${p.color}">${GLYPH[p.color + p.type]}</div>`);
pieceEl.dataset.sq = sq;
cell.appendChild(pieceEl);
}
if (legalTo.has(sq)) cell.appendChild(el(`<div class="hint ${p ? 'cap' : ''}"></div>`));
board.appendChild(cell);
});
if (this.pendingPromo) this._renderPromo();
}
_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('<div class="promo"><div class="promo-inner"></div></div>');
const inner = wrap.querySelector('.promo-inner');
['q', 'r', 'b', 'n'].forEach((t) => {
const pc = el(`<div class="piece ${color}">${GLYPH[color + t]}</div>`);
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(`<div class="piece ${p.color} drag-ghost">${GLYPH[p.color + p.type]}</div>`);
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(`<span class="num">${num}.</span>`));
container.appendChild(el(`<span class="mv ${i === moves.length - 1 ? 'cur' : ''}">${moves[i].san}</span>`));
if (moves[i + 1]) container.appendChild(el(`<span class="mv ${i + 1 === moves.length - 1 ? 'cur' : ''}">${moves[i + 1].san}</span>`));
}
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(`
<div class="game-wrap">
<div class="board-col">
<div class="playerbar" id="pb-top"></div>
<div id="board-mount"></div>
<div class="playerbar" id="pb-bot"></div>
</div>
<div class="side">
<div class="box"><div class="status-msg" id="status">Connecting</div></div>
<div class="box" id="sharebox" style="display:none">
<h3>Invite opponent</h3>
<div class="share"><div class="link" id="sharelink"></div><button class="btn primary sm" id="copybtn">Copy invite link</button></div>
</div>
<div class="box"><h3>Moves</h3><div class="moves" id="moves"></div>
<div class="actions" id="actions"></div>
</div>
<div class="box"><h3>Chat</h3>
<div class="chat"><div class="chat-log" id="chatlog"></div>
<div class="chat-in"><input id="chatin" placeholder="Say something…" maxlength="200" /></div>
</div>
</div>
</div>
</div>`);
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 `<div class="pname"><span class="pdot ${p.seated ? 'on' : ''}"></span>${name} <span style="color:var(--muted);font-size:12px">${side === 'w' ? '♔' : '♚'}</span></div>
<div class="${clockCls}" data-clockside="${side}">${state.tc.base === 0 ? '∞' : fmtClock(state.clock[side])}</div>`;
}
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 ? '<span style="color:var(--red)">Your move</span>' : 'Waiting for opponent…';
if (state.drawOffer && state.drawOffer !== mySeat && mySeat !== 'spec') {
s.innerHTML += `<div class="banner" style="margin-top:10px">Opponent offers a draw
<span><button class="btn sm primary" id="acc">Accept</button> <button class="btn sm ghost" id="dec">Decline</button></span></div>`;
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} <span class="reason">by ${state.reason || 'game over'}</span>`;
}
function renderActions() {
const a = view.querySelector('#actions');
a.innerHTML = '';
if (mySeat === 'spec') { a.innerHTML = '<button class="btn sm ghost" id="flip">Flip board</button><button class="btn sm ghost" id="home">Home</button>'; }
else if (state.status === 'active') {
a.innerHTML = `<button class="btn sm ghost" id="draw">½ Offer draw</button><button class="btn sm ghost" id="resign">⚑ Resign</button>`;
} else if (state.status === 'over') {
const mine = state.rematch[mySeat];
a.innerHTML = `<button class="btn sm primary" id="rematch" ${mine ? 'disabled' : ''}>${mine ? 'Rematch sent…' : '↻ Rematch'}</button><button class="btn sm ghost" id="home">New game</button>`;
}
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(`<div class="cm ${c.seat}"><b>${escapeHtml(c.who)}:</b> ${escapeHtml(c.text)}</div>`));
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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(`
<div class="game-wrap">
<div class="board-col">
<div class="playerbar" id="pb-top"></div>
<div id="board-mount"></div>
<div class="playerbar" id="pb-bot"></div>
</div>
<div class="side">
<div class="box"><div class="status-msg" id="status"></div></div>
<div class="box"><h3>Moves</h3><div class="moves" id="moves"></div>
<div class="actions">
<button class="btn sm ghost" id="flip">Flip</button>
<button class="btn sm ghost" id="undo"> Undo</button>
<button class="btn sm ghost" id="new">New</button>
<button class="btn sm ghost" id="home">Home</button>
</div>
</div>
</div>
</div>`);
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) => `<div class="pname"><span class="pdot on"></span>${names[s]} <span style="color:var(--muted);font-size:12px">${s === 'w' ? '♔' : '♚'}</span></div>`;
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} <span class="reason">by ${reason}</span>`;
} else {
s.innerHTML = `${chess.turn() === 'w' ? 'White' : 'Black'} to move${chess.inCheck() ? ' — <span style="color:var(--red)">check</span>' : ''}`;
}
}
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(`<div class="list"><h1>Recent games</h1><div id="rows"><div class="empty">Loading…</div></div></div>`);
app.appendChild(view);
try {
const { games } = await (await fetch('/api/recent')).json();
const rows = view.querySelector('#rows');
if (!games || !games.length) { rows.innerHTML = '<div class="empty">No finished games yet. Be the first — go play!</div>'; return; }
rows.innerHTML = '';
for (const g of games) {
rows.appendChild(el(`<div class="grow-row">
<span>${escapeHtml(g.white_name || 'White')} <span style="color:var(--muted)">vs</span> ${escapeHtml(g.black_name || 'Black')}</span>
<span><span class="res">${g.result}</span> · ${g.moves} moves · ${escapeHtml(g.time_control || '')}</span>
</div>`));
}
} catch { view.querySelector('#rows').innerHTML = '<div class="empty">Could not load games.</div>'; }
}
// ---------------------------------------------------------------------------
route();

15
migrations/0001_init.sql Normal file
View file

@ -0,0 +1,15 @@
-- Finished-game archive + lightweight stats
CREATE TABLE IF NOT EXISTS games (
id TEXT PRIMARY KEY,
white_name TEXT,
black_name TEXT,
result TEXT, -- '1-0', '0-1', '1/2-1/2', '*'
reason TEXT, -- checkmate, resign, timeout, draw, stalemate...
pgn TEXT,
moves INTEGER DEFAULT 0,
time_control TEXT, -- e.g. '300+3' or 'unlimited'
created_at INTEGER,
ended_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_games_created ON games(created_at DESC);

1991
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "radical-chess",
"version": "0.1.0",
"private": true,
"description": "Radical Chess — real-time online chess on Cloudflare (a Radical Party project)",
"type": "module",
"scripts": {
"build:client": "esbuild client/app.js --bundle --minify --format=esm --outfile=public/dist/app.js",
"dev": "npm run build:client && wrangler dev",
"deploy": "wrangler deploy",
"db:migrate:local": "wrangler d1 migrations apply radical-chess --local",
"db:migrate": "wrangler d1 migrations apply radical-chess --remote"
},
"dependencies": {
"chess.js": "^1.0.0-beta.8",
"hono": "^4.6.14"
},
"devDependencies": {
"esbuild": "^0.24.0",
"wrangler": "^4.96.0"
}
}

29
public/index.html Normal file
View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Radical Chess — play chess online</title>
<meta name="description" content="Free, real-time online chess. Play a friend by link, challenge the bot, or pass-and-play. A Radical Party project." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=IBM+Plex+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/style.css" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ctext y='26' font-size='26'%3E%E2%99%9E%3C/text%3E%3C/svg%3E" />
</head>
<body>
<header class="topbar">
<a class="brand" href="/">
<span class="brand-mark"></span>
<span class="brand-word">RADICAL<span class="brand-accent">CHESS</span></span>
</a>
<nav class="topnav">
<a href="/" data-nav>Play</a>
<a href="/watch" data-nav>Watch</a>
</nav>
</header>
<main id="app"><!-- rendered by app.js --></main>
<footer class="footsig">A <a href="https://theradicalparty.com" target="_blank" rel="noopener">Radical Party</a> project · Vibe, Vote, Veto</footer>
<script type="module" src="/dist/app.js"></script>
</body>
</html>

196
public/style.css Normal file
View file

@ -0,0 +1,196 @@
:root {
--red: #FF2D55;
--red-hot: #FF6B82;
--red-deep: #c81e3a;
--bg: #0B0B09;
--raised: #12100d;
--raised-2: #1b1813;
--cream: #F0EDE6;
--muted: #9c948a;
--gold: #c9a84c;
--line: #2a2620;
--light-sq: #ece5d6;
--dark-sq: #7a6a55;
--sel: #ff2d5566;
--last: #c9a84c55;
--check: #ff2d5599;
--ok: #4caf7d;
--mono: 'IBM Plex Mono', ui-monospace, monospace;
--display: 'Syne', system-ui, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: radial-gradient(1200px 600px at 70% -10%, #17140f 0%, var(--bg) 55%);
color: var(--cream);
font-family: system-ui, 'Segoe UI', Inter, sans-serif;
min-height: 100%;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
/* ---------- top bar ---------- */
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 22px; border-bottom: 1px solid var(--line);
background: rgba(11,11,9,.7); backdrop-filter: blur(8px);
position: sticky; top: 0; z-index: 30;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-mark { font-size: 26px; color: var(--red); filter: drop-shadow(0 0 10px #ff2d5566); }
.brand-word { font-family: var(--display); font-weight: 800; letter-spacing: .5px; font-size: 19px; }
.brand-accent { color: var(--red); }
.topnav { display: flex; gap: 18px; font-family: var(--mono); font-size: 13px; color: var(--muted); }
.topnav a:hover { color: var(--cream); }
main { flex: 1; width: 100%; }
.footsig { text-align: center; color: var(--muted); font-size: 12px; padding: 18px; font-family: var(--mono); }
.footsig a { color: var(--gold); }
/* ---------- home ---------- */
.home { max-width: 1000px; margin: 0 auto; padding: 40px 22px 60px; }
.hero { text-align: center; margin-bottom: 38px; }
.hero h1 { font-family: var(--display); font-weight: 800; font-size: clamp(34px, 6vw, 62px); line-height: 1; }
.hero h1 .g { color: var(--red); }
.hero p { color: var(--muted); margin-top: 14px; font-size: 16px; }
.panels { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 18px; }
.card {
background: linear-gradient(180deg, var(--raised) 0%, var(--raised-2) 100%);
border: 1px solid var(--line); border-radius: 16px; padding: 22px;
}
.card h2 { font-family: var(--display); font-weight: 700; font-size: 20px; margin-bottom: 6px; }
.card .sub { color: var(--muted); font-size: 13px; margin-bottom: 16px; }
.tc-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; }
.tc {
border: 1px solid var(--line); background: #0e0c08; border-radius: 10px;
padding: 12px 6px; text-align: center; cursor: pointer; transition: .12s;
}
.tc:hover { border-color: var(--red); }
.tc.on { border-color: var(--red); background: #1a0d10; box-shadow: 0 0 0 1px var(--red) inset; }
.tc b { display: block; font-family: var(--mono); font-weight: 700; font-size: 18px; }
.tc span { color: var(--muted); font-size: 11px; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
font-family: var(--display); font-weight: 700; font-size: 15px;
padding: 13px 18px; border-radius: 12px; border: 1px solid transparent;
cursor: pointer; width: 100%; transition: .14s; background: var(--raised-2); color: var(--cream);
}
.btn:hover { transform: translateY(-1px); }
.btn.primary { background: linear-gradient(180deg, var(--red) 0%, var(--red-deep) 100%); color: #fff; box-shadow: 0 6px 22px #ff2d5533; }
.btn.ghost { background: transparent; border-color: var(--line); }
.btn.ghost:hover { border-color: var(--red); }
.btn.sm { width: auto; padding: 9px 14px; font-size: 13px; }
.btn:disabled { opacity: .5; cursor: default; transform: none; }
.seg { display: flex; gap: 8px; margin-bottom: 14px; }
.seg button {
flex: 1; padding: 10px; border-radius: 10px; border: 1px solid var(--line);
background: #0e0c08; color: var(--muted); cursor: pointer; font-family: var(--mono); font-size: 13px;
}
.seg button.on { color: #fff; border-color: var(--red); background: #1a0d10; }
.diff { display: flex; gap: 8px; margin-bottom: 14px; }
.diff button { flex: 1; padding: 9px; border-radius: 9px; border: 1px solid var(--line); background: #0e0c08; color: var(--muted); cursor: pointer; font-family: var(--mono); font-size: 12px; }
.diff button.on { color: #fff; border-color: var(--gold); }
/* ---------- game layout ---------- */
.game-wrap { max-width: 1120px; margin: 0 auto; padding: 22px; display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 24px; align-items: start; }
.board-col { display: flex; flex-direction: column; gap: 10px; }
.playerbar { display: flex; align-items: center; justify-content: space-between; padding: 4px 2px; }
.pname { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.pdot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
.pdot.on { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
.clock {
font-family: var(--mono); font-weight: 700; font-size: 26px; letter-spacing: 1px;
background: #0e0c08; border: 1px solid var(--line); border-radius: 10px; padding: 4px 14px; min-width: 108px; text-align: center;
}
.clock.active { border-color: var(--red); color: #fff; box-shadow: 0 0 0 1px var(--red) inset; }
.clock.low { color: var(--red-hot); }
/* board */
.board {
width: min(72vh, 100%); aspect-ratio: 1; margin: 0 auto;
display: grid; grid-template-columns: repeat(8, 1fr); grid-template-rows: repeat(8, 1fr);
border-radius: 10px; overflow: hidden; border: 1px solid var(--line);
box-shadow: 0 18px 60px rgba(0,0,0,.5); touch-action: none; user-select: none;
}
.sq { position: relative; display: flex; align-items: center; justify-content: center; }
.sq.light { background: var(--light-sq); }
.sq.dark { background: var(--dark-sq); }
.sq.last::after { content: ''; position: absolute; inset: 0; background: var(--last); }
.sq.sel::after { content: ''; position: absolute; inset: 0; background: var(--sel); }
.sq.check .piece { filter: drop-shadow(0 0 6px var(--red)); }
.sq .coord { position: absolute; font-size: 10px; font-family: var(--mono); opacity: .5; }
.sq .coord.file { bottom: 2px; right: 3px; }
.sq .coord.rank { top: 2px; left: 3px; }
.sq.light .coord { color: #6b5c46; }
.sq.dark .coord { color: #e6dcc8; }
.piece {
font-size: min(9vh, 12vw); line-height: 1; cursor: grab; z-index: 2;
position: relative; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
}
.piece.dragging { cursor: grabbing; opacity: .35; }
.piece.w { color: #f7f4ec; text-shadow: 0 1px 0 #0007, 0 0 1px #000, 1px 0 0 #0006, -1px 0 0 #0006, 0 2px 4px #0009; }
.piece.b { color: #1a1712; text-shadow: 0 1px 0 #fff5, 0 2px 4px #0008; }
.hint { position: absolute; width: 30%; height: 30%; border-radius: 50%; background: #0003; z-index: 1; }
.sq.light .hint { background: #7a6a5555; }
.hint.cap { width: 100%; height: 100%; background: none; border-radius: 0; box-shadow: inset 0 0 0 6px #0002; }
.drag-ghost { position: fixed; pointer-events: none; z-index: 100; transform: translate(-50%, -50%); }
/* promotion */
.promo { position: absolute; inset: 0; background: #000a; display: flex; align-items: center; justify-content: center; z-index: 40; }
.promo-inner { display: flex; gap: 8px; background: var(--raised); padding: 14px; border-radius: 12px; border: 1px solid var(--red); }
.promo-inner .piece { font-size: 44px; cursor: pointer; width: 56px; height: 56px; border-radius: 8px; }
.promo-inner .piece:hover { background: #1a0d10; }
/* side panel */
.side { display: flex; flex-direction: column; gap: 16px; }
.box { background: var(--raised); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
.box h3 { font-family: var(--mono); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: var(--muted); padding: 12px 14px 0; }
.status-msg { padding: 12px 14px; font-family: var(--display); font-weight: 700; }
.status-msg .reason { font-family: system-ui; font-weight: 400; color: var(--muted); font-size: 13px; display: block; margin-top: 2px; }
.moves { max-height: 260px; overflow-y: auto; padding: 10px 12px; font-family: var(--mono); font-size: 13px; line-height: 1.9; }
.moves .mv { display: inline-block; min-width: 62px; padding: 1px 5px; border-radius: 4px; cursor: default; }
.moves .num { color: var(--muted); display: inline-block; min-width: 26px; }
.moves .mv.cur { background: var(--red); color: #fff; }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 12px 14px; }
.actions .btn.sm { width: 100%; }
.share { padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; }
.share .link { font-family: var(--mono); font-size: 12px; background: #0e0c08; border: 1px solid var(--line); border-radius: 8px; padding: 9px; word-break: break-all; color: var(--red-hot); }
.chat { display: flex; flex-direction: column; height: 220px; }
.chat-log { flex: 1; overflow-y: auto; padding: 10px 12px; font-size: 13px; display: flex; flex-direction: column; gap: 5px; }
.chat-log .cm { line-height: 1.35; }
.chat-log .cm b { color: var(--gold); font-weight: 600; }
.chat-log .cm.w b { color: var(--cream); }
.chat-log .cm.b b { color: var(--muted); }
.chat-in { display: flex; border-top: 1px solid var(--line); }
.chat-in input { flex: 1; background: transparent; border: 0; color: var(--cream); padding: 11px 12px; font-family: var(--mono); font-size: 13px; outline: none; }
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--raised); border: 1px solid var(--red); color: var(--cream); padding: 12px 18px; border-radius: 12px; font-family: var(--mono); font-size: 13px; z-index: 80; box-shadow: 0 10px 30px #000a; }
.banner { background: #1a0d10; border: 1px solid var(--red); border-radius: 10px; padding: 10px 12px; font-size: 13px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
/* watch/list */
.list { max-width: 720px; margin: 0 auto; padding: 30px 22px; }
.list h1 { font-family: var(--display); font-weight: 800; font-size: 30px; margin-bottom: 18px; }
.grow-row { display: flex; justify-content: space-between; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; margin-bottom: 8px; background: var(--raised); font-family: var(--mono); font-size: 13px; }
.grow-row .res { color: var(--gold); }
.empty { color: var(--muted); text-align: center; padding: 40px; font-family: var(--mono); }
@media (max-width: 900px) {
.game-wrap { grid-template-columns: 1fr; }
.board { width: min(94vw, 72vh); }
.side { order: 2; }
}

356
src/game.js Normal file
View file

@ -0,0 +1,356 @@
import { Chess } from 'chess.js';
// One Durable Object instance per game id. Holds authoritative state,
// validates every move with chess.js, and broadcasts to all sockets
// (both players + spectators) using WebSocket hibernation.
const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
export class Game {
constructor(ctx, env) {
this.ctx = ctx;
this.env = env;
this.state = null; // lazily loaded
}
async load() {
if (this.state) return this.state;
this.state = (await this.ctx.storage.get('state')) || null;
return this.state;
}
fresh(id, tc) {
return {
id,
fen: START_FEN,
moves: [], // { san, from, to, color }
status: 'waiting', // waiting | active | over
result: '*', // 1-0 | 0-1 | 1/2-1/2 | *
reason: null,
white: { name: null, sid: null },
black: { name: null, sid: null },
tc: tc || { base: 300, inc: 3 }, // seconds; base:0 => unlimited
clock: { w: (tc?.base ?? 300) * 1000, b: (tc?.base ?? 300) * 1000, lastTs: 0 },
drawOffer: null, // 'w' | 'b'
rematch: { w: false, b: false },
chat: [],
createdAt: Date.now(),
endedAt: null,
};
}
async save() {
await this.ctx.storage.put('state', this.state);
}
unlimited() {
return !this.state.tc || this.state.tc.base === 0;
}
// ---- HTTP: create (init) + websocket upgrade ----
async fetch(request) {
const url = new URL(request.url);
if (url.pathname.endsWith('/init')) {
await this.load();
const body = await request.json().catch(() => ({}));
if (!this.state) {
this.state = this.fresh(body.id, body.tc);
await this.save();
}
return Response.json({ ok: true, tc: this.state.tc, status: this.state.status });
}
if (url.pathname.endsWith('/connect')) {
if (request.headers.get('Upgrade') !== 'websocket') {
return new Response('expected websocket', { status: 426 });
}
await this.load();
if (!this.state) {
// Auto-create with defaults if someone connects to an unknown game
this.state = this.fresh(url.searchParams.get('id') || crypto.randomUUID().slice(0, 8));
await this.save();
}
const sid = url.searchParams.get('sid') || crypto.randomUUID();
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
this.ctx.acceptWebSocket(server);
server.serializeAttachment({ sid });
return new Response(null, { status: 101, webSocket: client });
}
return new Response('not found', { status: 404 });
}
role(sid) {
if (this.state.white.sid === sid) return 'w';
if (this.state.black.sid === sid) return 'b';
return 'spec';
}
remaining(side) {
const c = this.state.clock;
if (this.unlimited()) return null;
const chess = new Chess(this.state.fen);
const toMove = chess.turn();
if (this.state.status === 'active' && side === toMove && c.lastTs) {
return Math.max(0, c[side] - (Date.now() - c.lastTs));
}
return c[side];
}
snapshot(youSid) {
const s = this.state;
return {
t: 'state',
id: s.id,
fen: s.fen,
moves: s.moves,
status: s.status,
result: s.result,
reason: s.reason,
white: { name: s.white.name, seated: !!s.white.sid },
black: { name: s.black.name, seated: !!s.black.sid },
tc: s.tc,
clock: { w: this.remaining('w'), b: this.remaining('b') },
drawOffer: s.drawOffer,
rematch: s.rematch,
chat: s.chat.slice(-50),
you: youSid ? this.role(youSid) : 'spec',
};
}
broadcast() {
for (const ws of this.ctx.getWebSockets()) {
let sid = null;
try { sid = ws.deserializeAttachment()?.sid; } catch {}
try { ws.send(JSON.stringify(this.snapshot(sid))); } catch {}
}
}
send(ws, obj) { try { ws.send(JSON.stringify(obj)); } catch {} }
async webSocketMessage(ws, raw) {
await this.load();
let msg;
try { msg = JSON.parse(raw); } catch { return; }
const att = ws.deserializeAttachment() || {};
const sid = att.sid;
switch (msg.t) {
case 'hello': return this.onHello(ws, sid, msg);
case 'move': return this.onMove(ws, sid, msg);
case 'resign': return this.onResign(sid);
case 'draw-offer': return this.onDrawOffer(sid);
case 'draw-accept': return this.onDrawAccept(sid);
case 'draw-decline': return this.onDrawDecline(sid);
case 'rematch': return this.onRematch(sid);
case 'chat': return this.onChat(sid, msg);
case 'ping': return this.send(ws, { t: 'pong' });
}
}
async onHello(ws, sid, msg) {
const s = this.state;
const name = (msg.name || 'Anonymous').toString().slice(0, 24);
const want = msg.want; // 'white' | 'black' | 'random' | 'watch'
// Restore an existing seat for this sid (reconnect)
if (s.white.sid === sid || s.black.sid === sid) {
return this.send(ws, this.snapshot(sid));
}
if (want && want !== 'watch' && s.status !== 'over') {
let seat = want;
if (want === 'random') {
const openW = !s.white.sid, openB = !s.black.sid;
seat = openW && openB ? (sid.charCodeAt(0) % 2 ? 'white' : 'black')
: openW ? 'white' : openB ? 'black' : null;
}
if (seat === 'white' && !s.white.sid) { s.white = { name, sid }; }
else if (seat === 'black' && !s.black.sid) { s.black = { name, sid }; }
// Start the game once both seats are filled
if (s.white.sid && s.black.sid && s.status === 'waiting') {
s.status = 'active';
s.clock.lastTs = Date.now();
await this.scheduleFlagAlarm();
}
await this.save();
}
this.broadcast();
}
async onMove(ws, sid, msg) {
const s = this.state;
if (s.status !== 'active') return this.send(ws, { t: 'error', m: 'game not active' });
const chess = new Chess(s.fen);
const turn = chess.turn();
const seat = this.role(sid);
if (seat !== turn) return this.send(ws, { t: 'error', m: 'not your move' });
let move;
try {
move = chess.move({ from: msg.from, to: msg.to, promotion: msg.promotion || 'q' });
} catch {
return this.send(ws, { t: 'error', m: 'illegal move' });
}
if (!move) return this.send(ws, { t: 'error', m: 'illegal move' });
// Clock accounting for the side that just moved
if (!this.unlimited()) {
const now = Date.now();
const elapsed = now - s.clock.lastTs;
s.clock[turn] = Math.max(0, s.clock[turn] - elapsed);
if (s.clock[turn] <= 0) {
return this.endGame(turn === 'w' ? '0-1' : '1-0', 'timeout');
}
s.clock[turn] += (s.tc.inc || 0) * 1000;
s.clock.lastTs = now;
}
s.fen = chess.fen();
s.moves.push({ san: move.san, from: move.from, to: move.to, color: turn });
s.drawOffer = null;
// Terminal conditions
if (chess.isCheckmate()) {
return this.endGame(turn === 'w' ? '1-0' : '0-1', 'checkmate');
}
if (chess.isStalemate()) return this.endGame('1/2-1/2', 'stalemate');
if (chess.isInsufficientMaterial()) return this.endGame('1/2-1/2', 'insufficient material');
if (chess.isThreefoldRepetition()) return this.endGame('1/2-1/2', 'threefold repetition');
if (chess.isDraw()) return this.endGame('1/2-1/2', 'fifty-move rule');
await this.scheduleFlagAlarm();
await this.save();
this.broadcast();
}
async onResign(sid) {
const seat = this.role(sid);
if (seat === 'spec' || this.state.status !== 'active') return;
await this.endGame(seat === 'w' ? '0-1' : '1-0', 'resignation');
}
async onDrawOffer(sid) {
const seat = this.role(sid);
if (seat === 'spec' || this.state.status !== 'active') return;
this.state.drawOffer = seat;
await this.save();
this.broadcast();
}
async onDrawDecline(sid) {
const seat = this.role(sid);
if (seat === 'spec') return;
this.state.drawOffer = null;
await this.save();
this.broadcast();
}
async onDrawAccept(sid) {
const seat = this.role(sid);
const s = this.state;
if (seat === 'spec' || !s.drawOffer || s.drawOffer === seat) return;
await this.endGame('1/2-1/2', 'draw agreed');
}
async onRematch(sid) {
const s = this.state;
const seat = this.role(sid);
if (seat === 'spec' || s.status !== 'over') return;
s.rematch[seat] = true;
if (s.rematch.w && s.rematch.b) {
// Swap colors, keep the same players
const w = s.white, b = s.black;
const tc = s.tc;
const id = s.id;
this.state = this.fresh(id, tc);
this.state.white = { name: b.name, sid: b.sid };
this.state.black = { name: w.name, sid: w.sid };
this.state.status = 'active';
this.state.clock.lastTs = Date.now();
await this.scheduleFlagAlarm();
}
await this.save();
this.broadcast();
}
async onChat(sid, msg) {
const seat = this.role(sid);
const s = this.state;
const who = seat === 'w' ? (s.white.name || 'White')
: seat === 'b' ? (s.black.name || 'Black') : 'Spectator';
const text = (msg.text || '').toString().slice(0, 200).trim();
if (!text) return;
s.chat.push({ who, seat, text, ts: Date.now() });
if (s.chat.length > 100) s.chat = s.chat.slice(-100);
await this.save();
this.broadcast();
}
async scheduleFlagAlarm() {
if (this.unlimited() || this.state.status !== 'active') {
await this.ctx.storage.deleteAlarm().catch(() => {});
return;
}
const chess = new Chess(this.state.fen);
const side = chess.turn();
const rem = this.remaining(side);
if (rem != null) {
await this.ctx.storage.setAlarm(Date.now() + rem + 100);
}
}
async alarm() {
await this.load();
if (!this.state || this.state.status !== 'active') return;
const chess = new Chess(this.state.fen);
const side = chess.turn();
if (this.remaining(side) <= 0) {
await this.endGame(side === 'w' ? '0-1' : '1-0', 'timeout');
} else {
await this.scheduleFlagAlarm();
}
}
async endGame(result, reason) {
const s = this.state;
s.status = 'over';
s.result = result;
s.reason = reason;
s.endedAt = Date.now();
s.drawOffer = null;
await this.ctx.storage.deleteAlarm().catch(() => {});
await this.save();
this.broadcast();
await this.archive().catch(() => {});
}
async archive() {
if (!this.env.DB) return;
const s = this.state;
const chess = new Chess();
for (const m of s.moves) {
try { chess.move({ from: m.from, to: m.to, promotion: 'q' }); } catch {}
}
const tc = this.unlimited() ? 'unlimited' : `${s.tc.base}+${s.tc.inc}`;
await this.env.DB.prepare(
`INSERT OR REPLACE INTO games
(id, white_name, black_name, result, reason, pgn, moves, time_control, created_at, ended_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`
).bind(
s.id, s.white.name, s.black.name, s.result, s.reason,
chess.pgn(), s.moves.length, tc, s.createdAt, s.endedAt
).run();
}
async webSocketClose(ws) {
// Players keep their seat on disconnect (allows reconnect); just refresh others.
this.broadcast();
}
async webSocketError() {}
}

60
src/index.js Normal file
View file

@ -0,0 +1,60 @@
import { Hono } from 'hono';
import { Game } from './game.js';
export { Game };
const app = new Hono();
function gameStub(env, id) {
return env.GAME.get(env.GAME.idFromName(id));
}
// Create a new game; returns its id. Time control via ?base=<sec>&inc=<sec> (base=0 => unlimited)
app.post('/api/new', async (c) => {
const body = await c.req.json().catch(() => ({}));
const base = Math.max(0, Math.min(10800, parseInt(body.base ?? 300, 10) || 0));
const inc = Math.max(0, Math.min(180, parseInt(body.inc ?? 3, 10) || 0));
const id = crypto.randomUUID().replace(/-/g, '').slice(0, 8);
const stub = gameStub(c.env, id);
await stub.fetch('https://do/init', {
method: 'POST',
body: JSON.stringify({ id, tc: { base, inc } }),
headers: { 'content-type': 'application/json' },
});
return c.json({ id, tc: { base, inc } });
});
// WebSocket endpoint -> routed to the game's Durable Object
app.get('/ws/:id', async (c) => {
const id = c.req.param('id');
const sid = c.req.query('sid') || crypto.randomUUID();
const stub = gameStub(c.env, id);
const url = new URL('https://do/connect');
url.searchParams.set('id', id);
url.searchParams.set('sid', sid);
return stub.fetch(url, {
headers: c.req.raw.headers,
});
});
// Recent finished games (public feed)
app.get('/api/recent', async (c) => {
if (!c.env.DB) return c.json({ games: [] });
const { results } = await c.env.DB.prepare(
`SELECT id, white_name, black_name, result, reason, moves, time_control, ended_at
FROM games WHERE result != '*' ORDER BY ended_at DESC LIMIT 20`
).all();
return c.json({ games: results || [] });
});
// Single archived game (PGN)
app.get('/api/game/:id', async (c) => {
if (!c.env.DB) return c.json({ error: 'no db' }, 404);
const row = await c.env.DB.prepare(
`SELECT * FROM games WHERE id = ?`
).bind(c.req.param('id')).first();
if (!row) return c.json({ error: 'not found' }, 404);
return c.json(row);
});
export default app;

39
wrangler.jsonc Normal file
View file

@ -0,0 +1,39 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "radical-chess",
"main": "src/index.js",
"compatibility_date": "2025-07-01",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
"build": {
"command": "npm run build:client"
},
"routes": [
{ "pattern": "chess.theradicalparty.com", "custom_domain": true }
],
"assets": {
"directory": "./public",
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": ["/api/*", "/ws/*"]
},
"d1_databases": [
{
"binding": "DB",
"database_name": "radical-chess",
"database_id": "67326dc0-9b5d-4ae7-aaad-0d7cdfffb418",
"migrations_dir": "migrations"
}
],
"durable_objects": {
"bindings": [
{ "name": "GAME", "class_name": "Game" }
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["Game"]
}
]
}