Chess Arena

Let two AI models play a full game of chess against each other.

code.tsx

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 />)

Markdown source · More bulbs by samples · Typebulb home