Cross and circle
Tic-tac-toe against an easy or unbeatable CPU, or a friend.
experimental
Try it
Your move (X)
- You
- 0
- Draw
- 0
- CPU
- 0
How it works
- Hard mode uses minimax with alpha-beta pruning, so it never loses; easy plays randomly but takes a win when it sees one.
- The rules live in src/lib/games/tictactoe.ts as pure functions, tested by playing every possible game against the hard CPU.
- Arrow keys move around the board, every cell announces its row, column, and mark, and the result is read out when the game ends.
Source
src/components/demos/TicTacToeLab.tsx
"use client";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { Circle, RotateCcw, X } from "lucide-react";
import { AnimatedIcon, Button, cn } from "@streamui/react";
import { cryptoInt } from "@/lib/games/memory";
import {
cpuMove,
emptyBoard,
isFull,
other,
place,
winner,
type Board,
type Mark,
} from "@/lib/games/tictactoe";
type Mode = "easy" | "hard" | "friend";
const MODES: { id: Mode; label: string }[] = [
{ id: "easy", label: "CPU · easy" },
{ id: "hard", label: "CPU · hard" },
{ id: "friend", label: "2 players" },
];
/** In CPU modes the human is X and the computer is O. */
const CPU: Mark = "O";
export function TicTacToeLab({ cpuDelay = 380 }: { cpuDelay?: number }) {
// Fixed first render (X starts, empty board) keeps hydration stable.
const [mode, setMode] = useState<Mode>("hard");
const [board, setBoard] = useState<Board>(emptyBoard);
const [turn, setTurn] = useState<Mark>("X");
const [round, setRound] = useState(0);
const [scores, setScores] = useState({ X: 0, O: 0, draw: 0 });
const [thinking, setThinking] = useState(false);
const [focus, setFocus] = useState(4);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const cells = useRef<(HTMLButtonElement | null)[]>([]);
useEffect(() => () => clearTimeout(timer.current), []);
const won = winner(board);
const draw = !won && isFull(board);
const over = won !== null || draw;
const vsCpu = mode !== "friend";
function record(next: Board) {
const result = winner(next);
if (result) setScores((s) => ({ ...s, [result.mark]: s[result.mark] + 1 }));
else if (isFull(next)) setScores((s) => ({ ...s, draw: s.draw + 1 }));
return result !== null || isFull(next);
}
function cpuTurn(current: Board, level: Mode) {
setThinking(true);
timer.current = setTimeout(() => {
const next = place(current, cpuMove(current, CPU, level === "hard" ? "hard" : "easy", cryptoInt), CPU);
setBoard(next);
setTurn(other(CPU));
setThinking(false);
record(next);
}, cpuDelay);
}
function play(index: number) {
if (board[index] !== null || over || thinking) return;
const next = place(board, index, turn);
setBoard(next);
if (record(next)) return;
setTurn(other(turn));
if (vsCpu) cpuTurn(next, mode);
}
function newRound(nextMode: Mode = mode, nextRound = round + 1) {
clearTimeout(timer.current);
setThinking(false);
setRound(nextRound);
const fresh = emptyBoard();
setBoard(fresh);
// Alternate who opens, so the CPU gets to start every other round.
const starter: Mark = nextRound % 2 === 0 ? "X" : "O";
setTurn(starter);
if (nextMode !== "friend" && starter === CPU) cpuTurn(fresh, nextMode);
}
function changeMode(next: Mode) {
if (next === mode) return;
setMode(next);
setScores({ X: 0, O: 0, draw: 0 });
newRound(next, 0);
}
// Arrow keys move between cells; only one cell is in the tab order.
function onKeyDown(e: KeyboardEvent) {
const row = Math.floor(focus / 3);
const col = focus % 3;
const target =
e.key === "ArrowRight" ? row * 3 + ((col + 1) % 3)
: e.key === "ArrowLeft" ? row * 3 + ((col + 2) % 3)
: e.key === "ArrowDown" ? ((row + 1) % 3) * 3 + col
: e.key === "ArrowUp" ? ((row + 2) % 3) * 3 + col
: null;
if (target === null) return;
e.preventDefault();
setFocus(target);
cells.current[target]?.focus();
}
const name = (mark: Mark) => (vsCpu ? (mark === CPU ? "CPU" : "You") : mark);
const status = won
? vsCpu
? won.mark === CPU ? "CPU wins." : "You win!"
: `${won.mark} wins!`
: draw
? "Draw."
: thinking
? "CPU is thinking…"
: vsCpu
? "Your move (X)"
: `${turn} to move`;
return (
<div className="flex w-full flex-col items-center gap-5">
<div role="group" aria-label="Opponent" className="flex flex-wrap justify-center border-2 border-ink shadow-brutal-sm">
{MODES.map((m) => (
<button
key={m.id}
type="button"
aria-pressed={mode === m.id}
onClick={() => changeMode(m.id)}
className={cn(
"cursor-pointer px-3 py-1.5 font-mono text-xs font-bold transition-colors duration-150 not-first:border-l-2 not-first:border-ink",
mode === m.id ? "bg-lime text-on-bright" : "bg-paper-raised hover:bg-lime-soft",
)}
>
{m.label}
</button>
))}
</div>
<div
role="group"
aria-label="Board"
onKeyDown={onKeyDown}
className="grid size-60 grid-cols-3 grid-rows-3 gap-0.5 border-2 border-ink bg-ink shadow-brutal"
>
{board.map((cell, i) => {
const winning = won?.line.includes(i);
const playable = cell === null && !over && !thinking;
return (
<button
key={i}
ref={(el) => {
cells.current[i] = el;
}}
type="button"
tabIndex={i === focus ? 0 : -1}
aria-disabled={!playable}
aria-label={`Row ${Math.floor(i / 3) + 1}, column ${(i % 3) + 1}: ${cell ?? "empty"}`}
onFocus={() => setFocus(i)}
onClick={() => play(i)}
className={cn(
"grid place-items-center transition-colors duration-150",
winning ? "bg-lime" : cell === "X" ? "bg-pink-soft" : cell === "O" ? "bg-blue-soft" : "bg-paper-raised",
playable ? "cursor-pointer hover:bg-lime-soft" : "cursor-default",
)}
>
{cell && (
<AnimatedIcon animation="pop" trigger="once" duration={260}>
{cell === "X" ? (
<X aria-hidden className="size-11" strokeWidth={3} />
) : (
<Circle aria-hidden className="size-9" strokeWidth={3.25} />
)}
</AnimatedIcon>
)}
</button>
);
})}
</div>
<p aria-live="polite" className="min-h-6 font-mono text-sm font-bold">
{status}
</p>
<div className="flex w-full max-w-60 items-center justify-between gap-4">
<dl className="flex gap-4 font-mono text-xs tabular-nums">
<div className="flex gap-1.5">
<dt className="text-ink-muted">{name("X")}</dt>
<dd className="font-bold">{scores.X}</dd>
</div>
<div className="flex gap-1.5">
<dt className="text-ink-muted">Draw</dt>
<dd className="font-bold">{scores.draw}</dd>
</div>
<div className="flex gap-1.5">
<dt className="text-ink-muted">{name("O")}</dt>
<dd className="font-bold">{scores.O}</dd>
</div>
</dl>
<Button size="sm" variant="secondary" onClick={() => newRound()}>
<RotateCcw aria-hidden /> New
</Button>
</div>
</div>
);
}