vibes.diy/docs Home Discord
creator docs · reference · generated

Chess — the rules from chess.js, the opponent from Stockfish, the board drawn your way

This is the same document the Vibes code generator reads when it builds apps — rendered from 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:

  1. 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.
  2. The computer opponent is Stockfish, running in a Web Worker, never callAI and 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.
  3. The board is drawn whichever way the app's look asks for, from the same chess.js game: the board-games SVG board (the default), react-chessboard for 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 with new Chess() and one move per 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 its startFen too and replays from new Chess(startFen).
  • A move is legal when chess.js accepts it. Wrap move in try/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 same move call.
  • Promotion asks. When the chosen square's legal moves carry promotion, show a four-button picker (Queen, Rook, Bishop, Knight) and send the pick as promotion. 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); keep pgn() for a Download PGN button, after setHeader("White", …) and its siblings.
  • The game is over when isGameOver() says so, and the reason is the first of isCheckmate, isStalemate, isThreefoldRepetition, isInsufficientMaterial, then the fifty-move rule, the remaining isDraw.

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 importScripts the 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 .wasm address rides in the worker URL's #hash. stockfish.js finds its .wasm next to its own script URL, which inside a blob is blob:, so the fetch fails. Stockfish reads the hash first: new Worker(${blobUrl}#${encodeURIComponent(wasmUrl)}).
  • Use the -lite-single build from unpkg. The multi-threaded builds need SharedArrayBuffer, which a vibe's page does not have, and the full builds carry a 99 MB network file. jsDelivr refuses the stockfish package 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. evaluate returns the engine's best reply too, which is the hint and the better move to show.
  • callAI explains, Stockfish chooses. A coach that talks sends callAI the 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 in fill-card and fill-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 is fill-card stroke-foreground, Black fill-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 is fill-accent opacity-40, the king in check fill-destructive. The worked example below draws this board.
  • react-chessboard for drag-and-drop and classic piece art. import { Chessboard } from "react-chessboard" (v5: one options prop). Pass position: game.fen(), onPieceDrop: ({ sourceSquare, targetSquare }) => tryMove(sourceSquare, targetSquare) (return false to snap the piece back), onSquareClick: ({ square }) => … for tap-to-move, boardOrientation: "black" to flip, and squareStyles to mark targets. It styles with inline styles, so give its squares the room's colours through CSS variables: darkSquareStyle: { backgroundColor: "var(--muted, var(--border))" } and lightSquareStyle: { backgroundColor: "var(--card, var(--surface))" }.
  • A 3D board is the three-js skill's stage over the same chess.js game: the squares are a plane of tiles, the pieces lathe geometry, and a raycast tap calls the same onSquare.

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, white and black handles, and only the seat whose colour is turn writes. Each phone checks the move with chess.js before writing; access.js cannot import the library, so it checks the seat, the turn and that moves grew by exactly one.
  • A puzzle is a startFen plus the solution as UCI moves. The player's move is right when it matches the next solution move or when the engine's evaluate says 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" };
}