---
format: typebulb/v1
name: Chess Arena
---

**code.tsx**

```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 />)
```
**styles.css**

```css
:root {
  --bg: #16181d; --panel: #1e2128; --text: #e6e8ee; --text2: #9aa0ac;
  --border: rgba(255,255,255,0.1); --accent: #60a5fa; --green: #4ade80; --red: #f87171; --gold: #fbbf24;
  --board: 420px; /* board size; the game column (board, commentary, moves) is centered at this width */
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font: 14px system-ui, -apple-system, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); padding: 20px; }
.wrap { max-width: 520px; margin: 0 auto; display: grid; gap: 14px; }
.game { display: grid; gap: 14px; }
.side-panel { display: grid; gap: 14px; min-width: 0; align-content: start; }

header h1 { font-size: 1.5rem; text-align: center; }
.subtitle { text-align: center; color: var(--text2); font-size: 0.85rem; margin-top: 4px; }

.gate { background: var(--panel); border: 1px solid var(--gold); border-radius: 10px; padding: 16px 20px; }
.gate p { color: var(--text); font-size: 0.9rem; line-height: 1.6; }
.gate-list { color: var(--text); font-size: 0.9rem; line-height: 1.6; margin: 8px 0 0; padding-left: 22px; }
.gate-list li { margin: 3px 0; }
.gate p.gate-note { color: var(--text2); font-size: 0.8rem; margin-top: 12px; }

.setup { display: grid; gap: 14px; justify-items: center; }
/* Narrow: the two sides stack as full-width aligned rows (the "vs" would unbalance the wrap, so it's
   hidden). Wide (≥820px): a 1fr|auto|1fr grid — both sides get identical width and the "vs" sits in
   the middle track, so no width can wrap it out of alignment. Selects stretch to their track in both
   layouts (never content-sized), which is what keeps the two sides mirrored. */
.matchup { display: grid; gap: 12px; width: 100%; max-width: 440px; }
.side-setup { display: flex; align-items: flex-end; gap: 10px; }
.side-setup .picker:first-child { flex: 1; min-width: 0; }
.side-setup .picker:first-child select { width: 100%; min-width: 0; }
.picker.effort select { min-width: 0; width: 90px; }
.vs { display: none; }
.picker { display: grid; gap: 4px; }
.picker label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text2); }
.picker select { padding: 7px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font-size: 0.85rem; min-width: 180px; }
.vs { color: var(--text2); font-size: 0.8rem; padding-bottom: 8px; }

/* Transport: borderless icon buttons filled with currentColor — big play/pause, small reset. */
.transport { display: flex; align-items: center; gap: 6px; }
.icon-btn { background: transparent; border: none; padding: 0; cursor: pointer; color: var(--accent); display: flex; align-items: center; justify-content: center; transition: color 0.15s; }
.icon-btn svg { width: 100%; height: 100%; }
.icon-btn:hover:not(:disabled) { color: #93c5fd; }
.icon-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.icon-btn.big { width: 48px; height: 48px; }
.icon-btn.small { width: 32px; height: 32px; color: var(--text2); }
.icon-btn.small:hover:not(:disabled) { color: var(--text); }

.banner { padding: 10px 14px; border-radius: 8px; font-size: 0.9rem; font-weight: 600; text-align: center; }
.banner.win { background: rgba(74,222,128,0.12); border: 1px solid var(--green); color: var(--green); }
.banner.draw { background: var(--panel); border: 1px solid var(--border); color: var(--text2); }
.banner.forfeit, .banner.error { background: rgba(248,113,113,0.12); border: 1px solid var(--red); color: var(--red); }

.status { text-align: center; color: var(--text2); font-size: 0.85rem; min-height: 1.2em; }
.dots { display: inline-block; width: 1.2em; text-align: left; }
.dots::after { content: ""; animation: dots 1.2s steps(4, end) infinite; }
@keyframes dots { 0% { content: ""; } 25% { content: "."; } 50% { content: ".."; } 75% { content: "..."; } }

/* No width cap here: on mobile the board fills the column to match the sections below it.
   On desktop the two-column grid track (minmax(0, --board)) caps it instead. */
.board { display: grid; grid-template-columns: repeat(8, 1fr); border: 2px solid #6b4f35; border-radius: 4px; overflow: hidden; user-select: none; }
.sq { aspect-ratio: 1; display: grid; place-items: center; position: relative; font-size: clamp(24px, 7vw, 40px); line-height: 1; }
.sq.light { background: #f0d9b5; }
.sq.dark { background: #b58863; }
.sq.last { box-shadow: inset 0 0 0 100vmax rgba(155, 199, 0, 0.35); }
.pw { color: #fafafa; text-shadow: 0 1px 1px #000, 0 0 2px #000; }
.pb { color: #1a1a1a; }

/* Commentary for the current move, under the board */
.commentary { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; min-height: 92px; }
.commentary-empty { color: var(--text2); font-size: 0.85rem; font-style: italic; line-height: 1.5; }
.commentary-hd { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.cdot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.cdot.w { background: #e6e8ee; }
.cdot.b { background: #6b6f7a; }
.commentary-move { font-family: ui-monospace, monospace; font-size: 0.85rem; font-weight: 700; }
.illegal-tag { font-size: 0.72rem; font-weight: 700; color: var(--red); background: rgba(248,113,113,0.14); border-radius: 5px; padding: 2px 7px; }
.live-btn { margin-left: auto; background: transparent; border: 1px solid var(--border); color: var(--text2); border-radius: 6px; padding: 2px 9px; font-size: 0.72rem; cursor: pointer; }
.live-btn:hover { border-color: var(--accent); color: var(--text); }
.commentary-body { font-size: 0.88rem; color: var(--text); line-height: 1.55; white-space: pre-wrap; }
.commentary-body .nc { color: var(--text2); opacity: 0.6; }

/* Live streamed reasoning + answer for the move in flight */
.live { background: var(--panel); border: 1px solid var(--accent); border-radius: 8px; padding: 10px 12px; display: grid; gap: 8px; }
.live-hd { display: flex; align-items: center; gap: 7px; font-size: 0.82rem; }
.live-who { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.live-reason { max-height: 200px; overflow-y: auto; font-size: 0.78rem; line-height: 1.5; color: var(--text2); white-space: pre-wrap; font-family: ui-monospace, monospace; }
.live-reason.empty { font-style: italic; opacity: 0.7; font-family: inherit; }
.live-text { font-size: 0.86rem; line-height: 1.5; color: var(--text); white-space: pre-wrap; border-top: 1px solid var(--border); padding-top: 8px; }

/* Collapsed thinking transcript on a past move */
.think { margin-top: 8px; border-top: 1px dashed var(--border); padding-top: 6px; }
.think > summary { cursor: pointer; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.4px; color: var(--text2); list-style: none; }
.think > summary::-webkit-details-marker { display: none; }
.think > summary::before { content: "▸ "; }
.think[open] > summary::before { content: "▾ "; }
.think-body { margin-top: 6px; max-height: 220px; overflow-y: auto; font-size: 0.78rem; line-height: 1.5; color: var(--text2); white-space: pre-wrap; font-family: ui-monospace, monospace; }

/* Pure, clickable move list */
.moves { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; max-height: 220px; overflow-y: auto; line-height: 2; }
.moves-empty { color: var(--text2); font-size: 0.85rem; font-style: italic; }
.moveno { font-family: ui-monospace, monospace; font-size: 0.8rem; color: var(--text2); margin-left: 8px; }
.moves .moveno:first-child { margin-left: 0; }
.move { font-family: ui-monospace, monospace; font-size: 0.82rem; font-weight: 600; color: var(--text); background: transparent; border: none; border-radius: 4px; padding: 2px 5px; margin: 0 1px; cursor: pointer; }
.move:hover { background: rgba(255,255,255,0.07); }
.move.sel { background: var(--accent); color: #fff; }
.move.illegal { color: var(--red); }
.move.illegal.sel { background: var(--red); color: #fff; }

/* Desktop: board on the left, commentary + moves on the right. Stacks below this. */
@media (min-width: 820px) {
  .wrap { max-width: 940px; }
  .game { grid-template-columns: minmax(0, var(--board)) 1fr; gap: 22px; align-items: start; }
  .moves { max-height: 320px; }
  .matchup { grid-template-columns: 1fr auto 1fr; align-items: end; gap: 16px; max-width: 640px; }
  .vs { display: block; }
}


```
**index.html**

```html
<div id="root"></div>
```
**config.json**

```json
{
  "description": "Let two AI models play a full game of chess against each other.",
  "dependencies": {
    "react": "^19.2.7",
    "react-dom": "^19.2.7",
    "chess.js": "^1.4.0"
  }
}
```