Chess — the rules from chess.js, the opponent from Stockfish, the board drawn your way
prompts/pkg/llms/chess.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
Load this for any chess app: play against the computer, play a friend from a shared link, a puzzle or opening trainer, a blunder coach, a game review, a club ladder, a chess variant on the standard rules. It stands on the board-games skill, which loads with it and owns the board's geometry, its look, turns, seats and who sees what. This doc owns the three things chess gets wrong when an app writes them itself:
- The rules come from
chess.js. Castling (and losing the right to castle), en passant, promotion to any piece, check, pins, checkmate, stalemate, threefold repetition, the fifty-move rule and insufficient material are all the library's. An app never writes its own move generator for chess: a hand-written one ships without castling or en passant and promotes every pawn to a queen. - The computer opponent is Stockfish, running in a Web Worker, never
callAIand never a hand-written search. It plays at any strength from beginner to far beyond a grandmaster, answers in under a second, and keeps the page responsive while it thinks. - The board is drawn whichever way the app's look asks for, from the same
chess.jsgame: the board-games SVG board (the default),react-chessboardfor drag-and-drop with classic piece art, or a 3D stage.
The rules — chess.js
chess.js is the board-games rule functions, written for chess: game.moves() is legalMoves,
game.move() is applyMove, and isGameOver() with its reasons is winner.
jsimport { Chess } from "chess.js";
const game = new Chess(); // or new Chess(fen) for a puzzle or a set position
game.moves({ square: "e2", verbose: true }); // [{ from, to, san, promotion?, flags }, …] the legal moves from e2
game.move({ from: "e2", to: "e4" }); // throws on an illegal move
game.move({ from: "e7", to: "e8", promotion: "n" }); // promotion names its piece: q, r, b or n
game.move("Nf3"); // SAN works too
game.turn(); // "w" | "b"
game.fen(); // the position, one line
game.history(); // ["e4", "e5", "Nf3"] in SAN
game.board(); // 8 rows (rank 8 first) of 8 cells, each { square, type, color } or null
game.get("e1"); // { type: "k", color: "w" } or undefined
game.findPiece({ type: "k", color: "w" }); // ["e1"]
game.inCheck(); game.isCheckmate(); game.isStalemate(); game.isDraw();
game.isThreefoldRepetition(); game.isInsufficientMaterial(); game.isGameOver();
game.undo(); // takes back the last move
game.load(fen); game.loadPgn(pgnText);
- Store the SAN move list; derive the game. The record holds
moves: ["e4", "e5", …], and the page rebuilds the game withnew Chess()and onemoveper entry. The list carries the history that threefold repetition and the move list need, which a FEN alone drops. A game that starts from a set position stores itsstartFentoo and replays fromnew Chess(startFen). - A move is legal when
chess.jsaccepts it. Wrapmoveintry/catch: a throw means the board stays as it was. The board's highlights, a hint, the engine's reply and a friend's move from another phone all go through the samemovecall. - Promotion asks. When the chosen square's legal moves carry
promotion, show a four-button picker (Queen, Rook, Bishop, Knight) and send the pick aspromotion. A drop that auto-queens takes an underpromotion away from the player. pgn()starts with a block of placeholder headers ([Event "?"]…). For an on-screen move list, number the SAN list yourself (1. e4 e5 2. Nf3); keeppgn()for a Download PGN button, aftersetHeader("White", …)and its siblings.- The game is over when
isGameOver()says so, and the reason is the first ofisCheckmate,isStalemate,isThreefoldRepetition,isInsufficientMaterial, then the fifty-move rule, the remainingisDraw.
The opponent — Stockfish in a Web Worker
lib/engine.js below is the whole engine module. It loads Stockfish 19's lite single-threaded
build (a 1.8 MB .wasm) from unpkg, version-pinned. Three details are load-bearing:
- The worker is a blob that
importScriptsthe engine. A page cannot start a Worker from another origin's URL, so the module makes a tiny same-origin blob whose one line imports it. - The
.wasmaddress rides in the worker URL's#hash. stockfish.js finds its.wasmnext to its own script URL, which inside a blob isblob:, so the fetch fails. Stockfish reads the hash first:new Worker(${blobUrl}#${encodeURIComponent(wasmUrl)}). - Use the
-lite-singlebuild from unpkg. The multi-threaded builds needSharedArrayBuffer, which a vibe's page does not have, and the full builds carry a 99 MB network file. jsDelivr refuses thestockfishpackage outright for its size.
Strength is two knobs: Skill Level (0 to 20) and the thinking time. Level 0 at 150 ms is a
gentle beginner; 20 at 1 s is far stronger than any human. Every reply is UCI (e7e8q): the
first four letters are the from and to squares, a fifth is the promotion piece, and the reply
goes through chess.js move like any other.
- Scores are from the side to move, in centipawns (100 is a pawn), and a forced mate is
±10000. A blunder check evaluates the position before and after the player's move: the
player's loss is
before.score + after.score(the second is from the opponent's side), and 200 or more is a blunder, 100 a mistake.evaluatereturns the engine's best reply too, which is the hint and the better move to show. callAIexplains, Stockfish chooses. A coach that talks sendscallAIthe position, the move played and the engine's better move and score, and asks for one or two plain sentences on why. The language model never picks or judges a chess move itself.- When the engine cannot load (offline, a blocked CDN) the promise rejects. Keep the game playable with a random legal move and say "Engine offline" in the status line.
- Credit the engine. Stockfish is GPL-3 and runs unmodified, straight from unpkg, in its own worker; a muted line under the board says "Engine: Stockfish".
Drawing the board — three ways, one game
The board is a view of game.board(); tapping a square asks game.moves({ square, verbose: true })
for the targets. Pick the drawing that suits the app's look:
- The board-games SVG board (the default). An 8×8 grid of
<rect>s infill-cardandfill-muted, dressed by the board surface (an inlaid wood board, a park table, the request's own world). Pieces are the solid Unicode glyphs ♚♛♜♝♞♟ in<text>, both sides from the same glyph: White isfill-card stroke-foreground, Blackfill-foreground stroke-foreground, so both sides follow the palette. Append︎to each glyph, or iOS draws the pawn as an emoji. Tap a piece, then a target; the last move isfill-accent opacity-40, the king in checkfill-destructive. The worked example below draws this board. react-chessboardfor drag-and-drop and classic piece art.import { Chessboard } from "react-chessboard"(v5: oneoptionsprop). Passposition: game.fen(),onPieceDrop: ({ sourceSquare, targetSquare }) => tryMove(sourceSquare, targetSquare)(returnfalseto snap the piece back),onSquareClick: ({ square }) => …for tap-to-move,boardOrientation: "black"to flip, andsquareStylesto mark targets. It styles with inline styles, so give its squares the room's colours through CSS variables:darkSquareStyle: { backgroundColor: "var(--muted, var(--border))" }andlightSquareStyle: { backgroundColor: "var(--card, var(--surface))" }.- A 3D board is the three-js skill's stage over the same
chess.jsgame: the squares are a plane of tiles, the pieces lathe geometry, and a raycast tap calls the sameonSquare.
Flip the board for the player on Black: draw rank 1 at the top and file h on the left, and keep the square names unchanged, since every rule speaks in square names.
Two players, puzzles and clocks
- A friend on their own phone is the board-games turn rule: one game doc holds
moves,whiteandblackhandles, and only the seat whose colour isturnwrites. Each phone checks the move withchess.jsbefore writing;access.jscannot import the library, so it checks the seat, the turn and thatmovesgrew by exactly one. - A puzzle is a
startFenplus the solution as UCI moves. The player's move is right when it matches the next solution move or when the engine'sevaluatesays it is as good; the reply move plays itself. - An opening trainer stores each line as SAN moves and plays the book reply until the player leaves the line, then hands the position to the engine.
- A clock stores each side's remaining milliseconds and the time the turn started on the game doc; every phone counts down from those, so both screens agree.
Worked example — play Stockfish, games saved
You play White at three strengths, every move saves the game where only its player reads it, and finished games are public like a club scoresheet. The board is the board-games SVG board.
lib/engine.js
js// Stockfish 19 lite: the single-threaded WASM build (1.8 MB), which needs no cross-origin isolation.
// A Worker must be same-origin, so a blob worker importScripts the engine, and the .wasm address
// rides in the worker URL's #hash, which is where stockfish.js looks for it.
const SF = "https://unpkg.com/[email protected]/bin/stockfish-19-lite-single";
export const LEVELS = [
{ id: "easy", label: "Easy", skill: 0, ms: 150 },
{ id: "club", label: "Club", skill: 8, ms: 400 },
{ id: "strong", label: "Strong", skill: 20, ms: 1200 },
];
export function startEngine() {
const boot = URL.createObjectURL(new Blob([`importScripts("${SF}.js");`], { type: "application/javascript" }));
const worker = new Worker(`${boot}#${encodeURIComponent(`${SF}.wasm`)}`);
let current = null; // the search in flight: { resolve, reject, score }
let queue = Promise.resolve(); // one search at a time, in the order asked
let broken = null;
worker.onmessage = (e) => {
const line = String(e.data);
const s = line.match(/ score (cp|mate) (-?\d+)/);
if (s && current) current.score = s[1] === "mate" ? (Number(s[2]) > 0 ? 10000 : -10000) : Number(s[2]);
if (line.startsWith("bestmove") && current) {
const uci = line.split(" ")[1];
const done = current;
current = null;
done.resolve({ uci: uci && uci !== "(none)" ? uci : null, score: done.score });
}
};
worker.onerror = () => {
broken = new Error("engine unavailable");
if (current) current.reject(broken);
current = null;
};
worker.postMessage("uci");
// `score` is in centipawns from the side to move's point of view; a mate is ±10000.
function search(fen, go, skill) {
const run = () =>
new Promise((resolve, reject) => {
if (broken) return reject(broken);
current = { resolve, reject, score: 0 };
worker.postMessage(`setoption name Skill Level value ${skill}`);
worker.postMessage(`position fen ${fen}`);
worker.postMessage(`go ${go}`);
});
const next = queue.then(run, run);
queue = next.catch(() => {});
return next;
}
return {
bestMove: (fen, level) => search(fen, `movetime ${level.ms}`, level.skill),
evaluate: (fen, depth = 12) => search(fen, `depth ${depth}`, 20),
stop: () => worker.terminate(),
};
}
App.jsx
jsximport React, { useEffect, useMemo, useRef, useState } from "react";
import { Chess } from "chess.js";
import { useFireproof } from "use-fireproof";
import { useVibe, useViewer } from "use-vibes";
import { Badge, Button, Card, Heading, Row, Skeleton, Stack, Text } from "@vibes.diy/ui";
import { LEVELS, startEngine } from "./lib/engine.js";
const S = 100; // one square, in board units
const FILES = "abcdefgh";
// Solid glyphs for both sides, coloured by class; ︎ keeps iOS from drawing the pawn as an emoji.
const GLYPH = { k: "♚", q: "♛", r: "♜", b: "♝", n: "♞", p: "♟" };
const PROMOTE = [
{ piece: "q", label: "Queen" },
{ piece: "r", label: "Rook" },
{ piece: "b", label: "Bishop" },
{ piece: "n", label: "Knight" },
];
// The game is its SAN move list; chess.js replays it, so every rule (castling, en passant,
// promotion, check, repetition) is the library's and nothing here re-derives one.
function replay(moves) {
const game = new Chess();
for (const san of moves) game.move(san);
return game;
}
// "1. e4 e5 2. Nf3": the move list without the PGN header block chess.js puts in front of pgn().
const moveText = (moves) => moves.map((san, i) => (i % 2 ? san : `${i / 2 + 1}. ${san}`)).join(" ");
function outcome(game) {
if (game.isCheckmate()) return game.turn() === "w" ? "Black wins by checkmate" : "White wins by checkmate";
if (game.isStalemate()) return "Draw by stalemate";
if (game.isThreefoldRepetition()) return "Draw by repetition";
if (game.isInsufficientMaterial()) return "Draw, not enough pieces to mate";
if (game.isDraw()) return "Draw by the fifty-move rule";
return null;
}
function ChessBoard({ game, flip, selected, lastMove, onSquare }) {
const targets = selected ? game.moves({ square: selected, verbose: true }).map((m) => m.to) : [];
const kingInCheck = game.inCheck() ? game.findPiece({ type: "k", color: game.turn() })[0] : null;
const cells = [];
game.board().forEach((rank, r) =>
rank.forEach((piece, f) => {
const square = FILES[f] + (8 - r);
const x = (flip ? 7 - f : f) * S;
const y = (flip ? 7 - r : r) * S;
const dark = (r + f) % 2 === 1;
const name = piece ? `${square}, ${piece.color === "w" ? "white" : "black"} ${piece.type}` : square;
cells.push(
<g
key={square}
role="button"
tabIndex={0}
aria-label={name}
aria-pressed={selected === square}
onClick={() => onSquare(square)}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && (e.preventDefault(), onSquare(square))}
className="cursor-pointer"
>
<rect x={x} y={y} width={S} height={S} className={dark ? "fill-muted" : "fill-card"} />
{lastMove?.includes(square) && <rect x={x} y={y} width={S} height={S} className="fill-accent opacity-40" />}
{kingInCheck === square && <rect x={x} y={y} width={S} height={S} className="fill-destructive opacity-50" />}
{selected === square && <rect x={x + 4} y={y + 4} width={S - 8} height={S - 8} strokeWidth="8" className="fill-none stroke-primary" />}
{piece && (
<text x={x + S / 2} y={y + S / 2 + 4} textAnchor="middle" dominantBaseline="central" fontSize="78" strokeWidth="3" className={piece.color === "w" ? "fill-card stroke-foreground" : "fill-foreground stroke-foreground"}>
{GLYPH[piece.type] + "︎"}
</text>
)}
{targets.includes(square) && <circle cx={x + S / 2} cy={y + S / 2} r={piece ? 44 : 14} strokeWidth={piece ? 6 : 0} className={piece ? "fill-none stroke-primary" : "fill-primary opacity-70"} />}
</g>
);
})
);
return (
<svg viewBox={`0 0 ${8 * S} ${8 * S}`} className="w-full max-w-md aspect-square touch-manipulation select-none" role="group" aria-label="Chess board">
{cells}
</svg>
);
}
export default function App() {
const { database, useLiveQuery } = useFireproof("chess");
const { me, can, ready } = useVibe("chess");
const { requestLogin } = useViewer();
const { docs: games } = useLiveQuery("type", { key: "game" });
const [moves, setMoves] = useState([]);
const [gameId, setGameId] = useState(() => `game:${Date.now()}`);
const [level, setLevel] = useState(LEVELS[1]);
const [selected, setSelected] = useState(null);
const [promotion, setPromotion] = useState(null); // { from, to } waiting for a piece
const [thinking, setThinking] = useState(false);
const [engineDown, setEngineDown] = useState(false);
const engine = useRef(null);
useEffect(() => {
engine.current = startEngine();
return () => engine.current.stop();
}, []);
const game = useMemo(() => replay(moves), [moves]);
const verbose = game.history({ verbose: true });
const last = verbose[verbose.length - 1];
const result = outcome(game);
const handle = me?.userHandle;
const record = { _id: gameId, type: "game", playerHandle: handle, moves, level: level.id, result };
const canSave = ready && handle && can.create(record).ok;
// Pick up this player's newest unfinished game once their games load, on a reload or another
// device. A game already under way here is left alone.
const resumed = useRef(false);
useEffect(() => {
if (resumed.current || !handle) return;
const open = games.filter((g) => g.playerHandle === handle && !g.result).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
if (moves.length || open) resumed.current = true;
if (moves.length || !open) return;
setGameId(open._id);
setMoves(open.moves ?? []);
setLevel(LEVELS.find((l) => l.id === open.level) ?? LEVELS[1]);
}, [games, handle]);
function play(next) {
setMoves(next);
setSelected(null);
// Each move saves the game, so a reload or another device picks it up mid-game.
if (canSave) database.put({ ...record, moves: next, result: outcome(replay(next)), updatedAt: Date.now() });
}
function tryMove(from, to, piece) {
const g = replay(moves);
try {
g.move({ from, to, promotion: piece });
} catch {
return false; // chess.js throws on an illegal move; the board just stays put
}
play(g.history());
return true;
}
function onSquare(square) {
if (result || thinking || game.turn() !== "w") return;
const options = selected ? game.moves({ square: selected, verbose: true }).filter((m) => m.to === square) : [];
if (options.some((m) => m.promotion)) return setPromotion({ from: selected, to: square });
if (options.length) return tryMove(selected, square);
const piece = game.get(square);
setSelected(piece?.color === "w" ? square : null);
}
// The engine answers whenever it is Black's turn.
useEffect(() => {
if (result || game.turn() !== "b" || !engine.current) return;
let live = true;
setThinking(true);
engine.current
.bestMove(game.fen(), level)
.then(({ uci }) => live && uci && tryMove(uci.slice(0, 2), uci.slice(2, 4), uci[4]))
.catch(() => {
// Offline or blocked: keep the game going with a random legal move, and say so.
setEngineDown(true);
const pick = game.moves({ verbose: true })[Math.floor(Math.random() * game.moves().length)];
if (live && pick) tryMove(pick.from, pick.to, pick.promotion);
})
.finally(() => live && setThinking(false));
return () => {
live = false;
};
}, [moves]);
if (!ready) {
return (
<main className="min-h-screen bg-background text-foreground font-sans p-4 flex flex-col items-center gap-4">
<Skeleton />
</main>
);
}
const status = result ?? (thinking ? "Stockfish is thinking…" : game.inCheck() ? "Check! Your move" : "Your move, you play White");
const mine = games.filter((g) => g.playerHandle === handle && g.result);
return (
<main className="min-h-screen bg-background text-foreground font-sans p-4 flex flex-col items-center gap-4">
<Heading level={1}>Chess</Heading>
<Row gap={2} align="center">
<Badge tone={result ? "up" : "info"}>{status}</Badge>
{engineDown && <Text size="sm" muted>Engine offline, playing random moves</Text>}
</Row>
<ChessBoard game={game} flip={false} selected={selected} lastMove={last ? [last.from, last.to] : null} onSquare={onSquare} />
{promotion && (
<Row gap={2} wrap>
{PROMOTE.map((p) => (
<Button key={p.piece} type="button" size="sm" onClick={() => (tryMove(promotion.from, promotion.to, p.piece), setPromotion(null))}>
{p.label}
</Button>
))}
</Row>
)}
<Row gap={2} wrap>
{LEVELS.map((l) => (
<Button key={l.id} type="button" size="sm" variant={l.id === level.id ? "solid" : "quiet"} onClick={() => setLevel(l)}>
{l.label}
</Button>
))}
<Button type="button" size="sm" variant="quiet" disabled={thinking || moves.length < 2} onClick={() => play(moves.slice(0, -2))}>
Take back
</Button>
<Button type="button" size="sm" variant="quiet" onClick={() => (setMoves([]), setGameId(`game:${Date.now()}`), setSelected(null), setThinking(false), setPromotion(null))}>
New game
</Button>
</Row>
{!handle && <Button type="button" variant="quiet" onClick={requestLogin}>Sign in to keep your games</Button>}
<div className="w-full max-w-md">
<Card>
<Stack gap={2}>
<Heading level={2}>Moves</Heading>
<Text size="sm">{moveText(moves) || "No moves yet."}</Text>
{mine.length > 0 && <Text size="sm" muted>{mine.length} finished games saved.</Text>}
</Stack>
</Card>
</div>
<Text size="sm" muted>Engine: Stockfish (GPL-3), loaded from unpkg.</Text>
</main>
);
}
access.js
jsexport function chess(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in to keep your games" };
if (doc.type === "game") {
// A player saves only their own games. A game in progress is theirs alone; a finished game
// moves to the public channel, like a club's scoresheet.
const player = oldDoc ? oldDoc.playerHandle : doc.playerHandle;
if (player !== user.userHandle || doc.playerHandle !== user.userHandle) throw { forbidden: "your own games" };
if (doc.result) return { channels: ["games"], grant: { public: ["games"] } };
const mine = `player:${user.userHandle}`;
return { channels: [mine], grant: { users: { [user.userHandle]: [mine] } } };
}
throw { forbidden: "unknown document type" };
}