Let two AI models play a full game of chess against each other.
import React, { useEffect, useMemo, useRef, useState } from "react"
import { createRoot } from "react-dom/client"
import { Chess, type Square } from "chess.js"
// ── Types ────────────────────────────────────────────────────────
type TbModel = { provider: string; name: string; friendlyName: string; providerName: string }
type Turn = { color: "w" | "b"; san: string; comment: string; reasoning: string; illegal?: boolean }
type Result =
| { type: "checkmate"; winner: "w" | "b" }
| { type: "draw"; reason: string }
| { type: "forfeit"; winner: "w" | "b"; offender: "w" | "b" }
| { type: "error"; message: string }
// ── Constants ────────────────────────────────────────────────────
const PIECES: Record<string, string> = { p: "♟", n: "♞", b: "♝", r: "♜", q: "♛", k: "♚" }
const FILES = ["a", "b", "c", "d", "e", "f", "g", "h"]
const modelLabel = (m: TbModel) => m.friendlyName
const fmtK = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
const SYSTEM = (color: string) =>
`You are a strong chess player playing as ${color}, providing live commentary on your own game. ` +
`You will be given the current position as FEN and the moves played so far.\n\n` +
`Reply in EXACTLY this format and nothing else:\n` +
`Move: <your move in standard algebraic notation, e.g. Nf3, exd5, O-O, Qxh7+, e8=Q>\n` +
`Comment: <commentary on the move you are making. Keep it to a phrase for routine moves; in sharp or ` +
`critical positions, dig in — explain the idea, the threats, and the key lines you are calculating.>`
function buildUserMsg(fen: string, history: string[], color: string) {
const movesText = history.length
? history.map((m, i) => (i % 2 === 0 ? `${i / 2 + 1}. ${m}` : m)).join(" ")
: "(none yet — you are White, make the first move)"
return `Position (FEN): ${fen}\nMoves so far: ${movesText}\nYou are ${color}. Give your Move and Comment.`
}
// ── Move parsing ─────────────────────────────────────────────────
// Format is parsed leniently (strip fences/prose); legality is judged strictly —
// an unparseable or illegal move forfeits the game.
function tryMove(fen: string, cand: string): string | null {
const base = cand.trim().replace(/0/g, "O") // digit 0 never appears in valid SAN except castling 0-0
// SAN (with and without a trailing check/mate symbol).
for (const v of new Set([base, base.replace(/[+#]+$/, "")])) {
if (!v) continue
try {
const c = new Chess(fen)
const mv = c.move(v)
if (mv) return mv.san
} catch {}
}
// Coordinate / long-form notation (e2e4, e7e8q, Ng1f3) — chess.js wants {from,to} for these.
const coord = base.match(/^[KQRBNkqrbn]?([a-h][1-8])([a-h][1-8])([qrbnQRBN])?$/)
if (coord) {
try {
const c = new Chess(fen)
const mv = c.move({ from: coord[1].toLowerCase(), to: coord[2].toLowerCase(), promotion: coord[3]?.toLowerCase() as any })
if (mv) return mv.san
} catch {}
}
return null
}
function parseMove(text: string, fen: string): string | null {
const s = (text || "").replace(/```[a-z]*/gi, "").replace(/```/g, "").trim()
const candidates: string[] = []
const firstLine = s.split(/\r?\n/)[0].trim()
if (firstLine) candidates.push(firstLine)
candidates.push(s)
const re = /([KQRBN]?[a-h][1-8][a-h][1-8][qrbn]?|O-O-O|O-O|0-0-0|0-0|[KQRBN]?[a-h]?[1-8]?x?[a-h][1-8](?:=[QRBNqrbn])?[+#]?)/g
let m: RegExpExecArray | null
while ((m = re.exec(s)) !== null) candidates.push(m[0])
for (const cand of candidates) {
const san = tryMove(fen, cand)
if (san) return san
}
return null
}
// Split a reply into the move and its commentary. The move is read from the "Move:" line when present
// (falling back to a scan of the whole reply); legality is judged strictly by parseMove, so a missing
// or illegal move still forfeits. Commentary is display-only.
function parseReply(text: string, fen: string): { san: string | null; comment: string } {
const raw = text || ""
const moveLine = raw.match(/(?:^|\n)\s*move\s*[:=]\s*(.+)/i)
const commentLine = raw.match(/(?:^|\n)\s*comment\s*[:=]\s*([\s\S]*)/i)
const san = (moveLine && parseMove(moveLine[1], fen)) || parseMove(raw, fen)
let comment = commentLine ? commentLine[1].trim() : ""
if (!comment) {
comment = raw.replace(/(?:^|\n)\s*move\s*[:=].*(?:\n|$)/i, "").replace(/```[a-z]*/gi, "").replace(/```/g, "").trim()
}
return { san, comment }
}
const sleep = (ms: number, signal: AbortSignal) => new Promise<void>((res, rej) => {
const t = setTimeout(res, ms)
signal.addEventListener("abort", () => { clearTimeout(t); rej(new Error("aborted")) }, { once: true })
})
// Streaming call. Accumulates the answer text and the reasoning stream, reporting deltas to `onDelta`
// for the live view. `reasoning` chunks arrive with a thinking-capable model, though at Low effort some
// models emit a sparse or empty summary. tb.ai.stream honors `signal`, so Stop cancels mid-stream.
async function streamModel(
m: TbModel, system: string, content: string, effort: number, signal: AbortSignal,
onDelta?: (reasoning: string, text: string) => void,
): Promise<{ text: string; reasoning: string }> {
let lastErr: any
for (let attempt = 0; attempt < 3 && !signal.aborted; attempt++) {
try {
let text = "", reasoning = ""
for await (const c of tb.ai.stream({ provider: m.provider, model: m.name, webSearch: false, effort: effort as 1 | 2 | 3, system, messages: [{ role: "user", content }], signal })) {
if (signal.aborted) throw new Error("aborted")
if (c.kind === "reasoning") { reasoning += c.text; onDelta?.(reasoning, text) }
else { text += c.text; onDelta?.(reasoning, text) }
}
return { text, reasoning }
} catch (e: any) {
lastErr = e
if (signal.aborted) break
await sleep(800 * (attempt + 1), signal)
}
}
throw lastErr
}
// ── Transport icons (Material paths, filled with currentColor) ───
const PLAY = "M8 5v14l11-7z"
const PAUSE = "M6 19h4V5H6v14zm8-14v14h4V5h-4z"
const RESET = "M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"
const Icon = ({ d }: { d: string }) => (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d={d} /></svg>
)
// ── Model picker ─────────────────────────────────────────────────
function ModelSelect({ label, models, idx, setIdx, disabled }: {
label: string; models: TbModel[]; idx: number; setIdx: (n: number) => void; disabled: boolean
}) {
const groups = useMemo(() => {
const g: Record<string, { m: TbModel; i: number }[]> = {}
models.forEach((m, i) => { const k = m.providerName || m.provider || "Models"; (g[k] ??= []).push({ m, i }) })
return Object.entries(g)
}, [models])
return (
<div className="picker">
<label>{label}</label>
<select value={idx} onChange={e => setIdx(+e.target.value)} disabled={disabled}>
<option value={-1}>Select…</option>
{groups.map(([prov, arr]) => (
<optgroup key={prov} label={prov}>
{arr.map(({ m, i }) => <option key={i} value={i}>{m.friendlyName}</option>)}
</optgroup>
))}
</select>
</div>
)
}
// ── Effort picker ────────────────────────────────────────────────
const EFFORT_LEVELS: { v: 1 | 2 | 3; label: string }[] = [
{ v: 1, label: "Low" }, { v: 2, label: "Medium" }, { v: 3, label: "High" },
]
function EffortSelect({ label, value, onChange, disabled }: {
label: string; value: number; onChange: (n: number) => void; disabled: boolean
}) {
return (
<div className="picker effort">
<label>{label}</label>
<select value={value} onChange={e => onChange(+e.target.value)} disabled={disabled}>
{EFFORT_LEVELS.map(({ v, label }) => <option key={v} value={v}>{label}</option>)}
</select>
</div>
)
}
// Collapsed by default so the commentary stays scannable; expand to re-read a past move's streamed thinking.
function Thinking({ text }: { text: string }) {
if (!text.trim()) return null
return (
<details className="think">
<summary>Thinking · {fmtK(text.length)} chars</summary>
<div className="think-body">{text}</div>
</details>
)
}
// ── App ──────────────────────────────────────────────────────────
function App() {
const [models, setModels] = useState<TbModel[]>([])
const [needsKeys, setNeedsKeys] = useState(false)
const [whiteIdx, setWhiteIdx] = useState(-1)
const [blackIdx, setBlackIdx] = useState(-1)
const [whiteEffort, setWhiteEffort] = useState(1) // per-side effort; Low/Med/High
const [blackEffort, setBlackEffort] = useState(1)
const [moves, setMoves] = useState<string[]>([])
const [turns, setTurns] = useState<Turn[]>([])
const [running, setRunning] = useState(false)
const [paused, setPaused] = useState(false)
const [thinking, setThinking] = useState<"w" | "b" | null>(null)
const [live, setLive] = useState<{ color: "w" | "b"; reasoning: string; text: string } | null>(null)
const [result, setResult] = useState<Result | null>(null)
const [selected, setSelected] = useState<number | null>(null) // move whose commentary/board is shown; null = follow the latest
const abortRef = useRef<AbortController | null>(null)
const pausedRef = useRef(false)
const movesRef = useRef<HTMLDivElement | null>(null)
const reasonRef = useRef<HTMLDivElement | null>(null)
const togglePause = () => { pausedRef.current = !pausedRef.current; setPaused(pausedRef.current) }
// Stop-and-clear: aborts any game in flight, then wipes the board back to the setup state.
const resetGame = () => {
abortRef.current?.abort()
pausedRef.current = false; setPaused(false)
setMoves([]); setTurns([]); setResult(null); setSelected(null); setLive(null)
}
// Keep the move list scrolled to the newest move as the game plays.
useEffect(() => { const el = movesRef.current; if (el) el.scrollTop = el.scrollHeight }, [turns])
// Keep the live reasoning view pinned to the newest deltas.
useEffect(() => { const el = reasonRef.current; if (el) el.scrollTop = el.scrollHeight }, [live?.reasoning])
useEffect(() => {
// A full game is dozens of sequential calls — past the free courtesy quota, so it would stall
// mid-game. Gate it upfront on the user's own keys.
tb.hasOwnKeys().then(has => setNeedsKeys(!has))
tb.models().then(ms => {
setModels(ms)
if (!ms.length) return
// Match on the model id (stable) not friendlyName (abbreviated, e.g. "Flash Lt"). Gemini is
// the only class that reliably finishes a game without forfeiting, and it's the cheaper pick.
const find = (re: RegExp, fallback: number) => {
const i = ms.findIndex(m => re.test(m.name.toLowerCase()))
return i >= 0 ? i : Math.min(fallback, ms.length - 1)
}
setWhiteIdx(find(/(^|\/)gemini-3-flash/, 0))
setBlackIdx(find(/(^|\/)gemini-3-flash/, 1))
})
}, [])
// Board reflects the selected move (null = the latest). A forfeit turn has no real move, so we
// never apply more plies than exist in `moves`.
const chess = useMemo(() => {
const c = new Chess()
const plies = selected == null ? moves.length : Math.min(selected + 1, moves.length)
for (let i = 0; i < plies; i++) { try { c.move(moves[i]) } catch {} }
return c
}, [moves, selected])
const lastMove = useMemo(() => {
const h = chess.history({ verbose: true })
const m = h[h.length - 1]
return m ? new Set<string>([m.from, m.to]) : new Set<string>()
}, [chess])
const whiteModel = whiteIdx >= 0 ? models[whiteIdx] : null
const blackModel = blackIdx >= 0 ? models[blackIdx] : null
const labelFor = (c: "w" | "b") => (c === "w" ? whiteModel : blackModel)
async function runGame() {
if (!whiteModel || !blackModel) return
const ac = new AbortController()
abortRef.current = ac
const signal = ac.signal
setRunning(true)
setResult(null)
setMoves([])
setTurns([])
setSelected(null)
setLive(null)
pausedRef.current = false; setPaused(false)
const c = new Chess()
const localMoves: string[] = []
const localTurns: Turn[] = []
// Live streaming view — flush reasoning at ~60ms cadence, but always reflect new answer text promptly.
let liveAt = 0
const pushLive = (color: "w" | "b") => (reasoning: string, text: string) => {
const now = performance.now()
if (!text && now - liveAt < 60) return
liveAt = now
setLive({ color, reasoning, text })
}
while (!signal.aborted) {
if (c.isGameOver()) {
if (c.isCheckmate()) setResult({ type: "checkmate", winner: c.turn() === "w" ? "b" : "w" })
else setResult({
type: "draw",
reason: c.isStalemate() ? "stalemate"
: c.isThreefoldRepetition() ? "threefold repetition"
: c.isInsufficientMaterial() ? "insufficient material"
: "fifty-move rule",
})
break
}
// Pause holds between moves — a mid-call pause takes effect once the current move finishes
// streaming, then the loop waits here until resumed.
if (pausedRef.current) {
setThinking(null); setLive(null)
while (pausedRef.current && !signal.aborted) await sleep(150, signal).catch(() => {})
if (signal.aborted) break
}
const color = c.turn()
const model = color === "w" ? whiteModel : blackModel
const effort = color === "w" ? whiteEffort : blackEffort
const colorName = color === "w" ? "White" : "Black"
setThinking(color)
setLive({ color, reasoning: "", text: "" })
let text: string, reasoning: string
try {
const r = await streamModel(model, SYSTEM(colorName), buildUserMsg(c.fen(), localMoves, colorName), effort, signal, pushLive(color))
text = r.text
reasoning = r.reasoning
} catch (e: any) {
if (signal.aborted) break
setResult({ type: "error", message: `${modelLabel(model)} (${colorName}) API error: ${e?.message || e}` })
break
}
if (signal.aborted) break
const { san, comment } = parseReply(text, c.fen())
if (!san) {
const attempted = (text.match(/(?:^|\n)\s*move\s*[:=]\s*(.+)/i)?.[1] || text.split(/\r?\n/)[0] || "").trim()
localTurns.push({ color, san: attempted ? attempted.slice(0, 24) : "(no move)", comment, reasoning, illegal: true })
setTurns([...localTurns])
setResult({ type: "forfeit", winner: color === "w" ? "b" : "w", offender: color })
break
}
c.move(san)
localMoves.push(san)
localTurns.push({ color, san, comment, reasoning })
setMoves([...localMoves])
setTurns([...localTurns])
}
setThinking(null)
setLive(null)
setRunning(false)
pausedRef.current = false; setPaused(false)
}
// ── Status / result text ──
const status = (() => {
if (needsKeys) return null
if (running && paused) return "Paused — press play to resume."
if (running && thinking) {
const m = labelFor(thinking)
return `${m ? modelLabel(m) : ""} (${thinking === "w" ? "White" : "Black"}) is thinking…`
}
if (!result) return moves.length ? "Game stopped." : "Pick two models and press play."
return null
})()
const banner = (() => {
if (!result) return null
if (result.type === "checkmate") {
const w = labelFor(result.winner)
return { tone: "win", text: `Checkmate — ${w ? modelLabel(w) : result.winner} (${result.winner === "w" ? "White" : "Black"}) wins.` }
}
if (result.type === "draw") return { tone: "draw", text: `Draw — ${result.reason}.` }
if (result.type === "forfeit") {
const w = labelFor(result.winner), o = labelFor(result.offender)
return { tone: "forfeit", text: `${o ? modelLabel(o) : ""} (${result.offender === "w" ? "White" : "Black"}) forfeits on an illegal move — ${w ? modelLabel(w) : ""} wins.` }
}
return { tone: "error", text: result.message }
})()
const board = chess.board()
const displayIdx = selected ?? turns.length - 1
const current = displayIdx >= 0 ? turns[displayIdx] : undefined
const moveLabel = (i: number) => `${Math.floor(i / 2) + 1}${i % 2 === 0 ? "." : "…"}`
return (
<div className="wrap">
<header>
<h1>Chess Arena</h1>
<p className="subtitle">Two models play a full game. Pick the players and watch — an illegal move forfeits.</p>
</header>
{needsKeys && (
<div className="gate">
<p>To play, use your own keys, either:</p>
<ul className="gate-list">
<li>run it locally on your own machine with the <strong>typebulb CLI</strong>, or</li>
<li>sign in at <strong>typebulb.com</strong> and add your keys.</li>
</ul>
<p className="gate-note">typebulb.com's courtesy model provides a small quota of free AI calls — in this case you'd blow through it without even finishing a game.</p>
</div>
)}
<div className="setup">
<div className="matchup">
<div className="side-setup">
<ModelSelect label="White" models={models} idx={whiteIdx} setIdx={setWhiteIdx} disabled={running} />
<EffortSelect label="Effort" value={whiteEffort} onChange={setWhiteEffort} disabled={running} />
</div>
<span className="vs">vs</span>
<div className="side-setup">
<ModelSelect label="Black" models={models} idx={blackIdx} setIdx={setBlackIdx} disabled={running} />
<EffortSelect label="Effort" value={blackEffort} onChange={setBlackEffort} disabled={running} />
</div>
</div>
<div className="transport">
{!running
? <button className="icon-btn big" onClick={runGame} disabled={needsKeys || !whiteModel || !blackModel} title="Play"><Icon d={PLAY} /></button>
: <button className="icon-btn big" onClick={togglePause} title={paused ? "Resume" : "Pause"}><Icon d={paused ? PLAY : PAUSE} /></button>}
<button className="icon-btn small" onClick={resetGame} disabled={!running && !moves.length && !result} title="Reset"><Icon d={RESET} /></button>
</div>
</div>
{banner && <div className={`banner ${banner.tone}`}>{banner.text}</div>}
{status && <div className="status">{status}{running && thinking && <span className="dots" />}</div>}
<div className="game">
<div className="board">
{board.map((rank, ri) =>
rank.map((cell, fi) => {
const sq = (FILES[fi] + (8 - ri)) as Square
const cls = ["sq", (ri + fi) % 2 === 1 ? "dark" : "light", lastMove.has(sq) ? "last" : ""].join(" ")
return (
<div key={sq} className={cls}>
{cell && <span className={cell.color === "w" ? "pw" : "pb"}>{PIECES[cell.type]}</span>}
</div>
)
})
)}
</div>
<div className="side-panel">
{running && live && (
<div className="live">
<div className="live-hd">
<span className={`cdot ${live.color === "w" ? "w" : "b"}`} />
<span className="live-who">{(() => { const m = labelFor(live.color); return m ? modelLabel(m) : live.color === "w" ? "White" : "Black" })()}</span>
</div>
{live.reasoning
? <div className="live-reason" ref={reasonRef}>{live.reasoning}</div>
: <div className="live-reason empty">thinking…</div>}
{live.text && <div className="live-text">{live.text}</div>}
</div>
)}
<div className="commentary">
{current ? (
<>
<div className="commentary-hd">
<span className={`cdot ${current.color === "w" ? "w" : "b"}`} />
<span className="commentary-move">{moveLabel(displayIdx)} {current.san}</span>
{current.illegal && <span className="illegal-tag">✗ illegal — forfeits</span>}
{selected != null && turns.length > 1 && <button className="live-btn" onClick={() => setSelected(null)}>↩ latest</button>}
</div>
<div className="commentary-body">{current.comment || <em className="nc">(no comment)</em>}</div>
<Thinking text={current.reasoning} />
</>
) : (
<div className="commentary-empty">Commentary for each move appears here. Click any move to revisit it.</div>
)}
</div>
<div className="moves" ref={movesRef}>
{turns.length === 0 && <span className="moves-empty">No moves yet.</span>}
{turns.map((t, i) => (
<React.Fragment key={i}>
{t.color === "w" && <span className="moveno">{Math.floor(i / 2) + 1}.</span>}
<button className={`move${i === displayIdx ? " sel" : ""}${t.illegal ? " illegal" : ""}`}
onClick={() => setSelected(i === turns.length - 1 ? null : i)}>{t.san}</button>
</React.Fragment>
))}
</div>
</div>
</div>
</div>
)
}
createRoot(document.getElementById("root")!).render(<App />)