Chess BORED!: Stockfish in WebAssembly and a 3D Board Made of Cylinders
October 5, 2026
Chess BORED! is a chess site with a 3D board. You can play Stockfish, or play rated games online with clocks, spectators and replays. This post is about the two parts I find most interesting: the AI opponent, which runs entirely in your browser as WebAssembly, and the 3D board, which contains no 3D models and no chess logic at all.
Almost everything below is interactive, and the demos run the same code as the app. One of them downloads the real engine, so you can watch it think.
Play Chess BORED!The stack
- App: Next.js 16, React 19, Tailwind, zustand
- Rules: chess.js
- 3D: three.js through React Three Fiber
- AI: Stockfish 19, compiled to WebAssembly, in a Web Worker
- Online play: Supabase (auth, Postgres, Realtime)
Three of those do the heavy lifting in this post. chess.js knows the rules. Stockfish knows what a good move looks like. three.js draws things. None of them knows the other two exist, and most of the work was in keeping it that way.
Part 1: An AI opponent that costs nothing to run
The obvious way to add a computer opponent is an API route: send the position to a server, run an engine, send a move back. It works, but every move a visitor plays costs server time, and a chess engine is about the most CPU-hungry thing you could put behind an endpoint.
Stockfish is written in C++, and C++ compiles to WebAssembly. So the engine can be shippaed to the browser as a file and run on the visitor's own machine. A game against the computer makes no requests while it is being played. The server hears about it once, at the end, when the result is saved for your stats.
The catch is that a chess search is a tight loop that wants a whole CPU core. Run it on the main thread and the page freezes, 3D board included, until it finishes. So the engine lives in a Web Worker, and the page talks to it by passing messages.
1/7You play e4. chess.js checks it is legal on the main thread and the store publishes a new snapshot of the game.
Choosing a build
The stockfish npm package ships several builds of the same engine. The difference in size is not subtle.
I use the lite, single-threaded build. The package's own README describes it as much smaller and "quite a bit weaker" than the full engine, and also as "still far stronger than any human will ever be", which is strong enough for me. Single-threaded matters too: the multi-threaded builds need cross-origin isolation headers on every page, which is a lot of ceremony for an opponent that already wins.
Getting it into the page
A worker is loaded by URL, so the engine cannot be bundled like ordinary code. It has to exist as a real file the browser can fetch. A small script copies it out of node_modules after every install:
// scripts/copy-stockfish.mjs
const name = 'stockfish-19-lite-single';
rmSync(dest, { recursive: true, force: true });
mkdirSync(dest, { recursive: true });
copyFileSync(join(src, `${name}.js`), join(dest, `${name}.js`));
copyFileSync(join(src, `${name}.wasm`), join(dest, `${name}.wasm`));
There are two files. The .js is 21 KB of glue that the worker runs; it finds the .wasm sitting next to it and loads that. Because the version number is in the file name, the file at that URL will never change, so the browser can be told to keep it for a year:
// next.config.ts
{
source: '/stockfish/:path*',
headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }],
}
The 1.8 MB is paid once. And it is only paid by people who play the computer: the worker is created lazily, the first time a move is requested, so someone who only plays online never downloads it.
Talking to it
Stockfish speaks UCI, the protocol chess engines have used for decades. It is line-based text. You send a command, and some lines come back. There is no request id and no JSON.
Load the engine below and you will see the handshake, then try a search. The info lines are the engine reporting as it looks deeper.
This demo runs the real Stockfish 19 engine in your browser. Loading it downloads a 1.8 MB WebAssembly file, once, for every demo on this page.
Nothing sent yet. Load the engine to watch the handshake.
- Engine ready in
- –
- Depth
- –
- Score
- –
- Positions searched
- –
- Positions / second
- –
- Best move
- –
- Line it expects
- –
A whole AI move needs three lines from the app:
setoption name Skill Level value 6
position fen rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1
go movetime 400
and the only line the app cares about in return is the last one, bestmove e7e5.
A promise around a text stream
The rest of the app does not want to think about lines of text. It wants to await a move. The wrapper in lib/engine.ts is under a hundred lines, and this is the core of it:
async bestMove(fen: string, { skill, movetime }: SearchOptions): Promise<string> {
await this.init();
this.stop();
return new Promise<string>((resolve, reject) => {
this.pending = { resolve, reject };
this.send(`setoption name Skill Level value ${skill}`);
this.send(`position fen ${fen}`);
this.send(`go movetime ${movetime}`);
});
}
It stores the promise's resolve, sends the commands, and returns. When a line beginning bestmove arrives later, the message handler resolves whatever promise is waiting.
Difficulty is two numbers. Skill Level tells Stockfish to pick a weaker move some of the time, and movetime caps how long it may think:
export const DIFFICULTY = {
easy: { label: 'Easy', skill: 0, movetime: 150 },
medium: { label: 'Medium', skill: 6, movetime: 400 },
hard: { label: 'Hard', skill: 13, movetime: 1000 },
max: { label: 'Max', skill: 20, movetime: 2000 },
};
Play a few moves here and watch the exchange. It uses the same wrapper and the same table.
This demo runs the real Stockfish 19 engine in your browser. Loading it downloads a 1.8 MB WebAssembly file, once, for every demo on this page.
One small touch from the store: Easy answers in 150 ms, which feels less like an opponent and more like a reflex. So the engine's promise is raced against a minimum delay of 300 ms, and a move never lands sooner than that.
The reply nobody is waiting for
Here is the bug that a text protocol with no request ids invites.
When you tell Stockfish to stop, it does not go quiet. It stops searching and then reports the best move it had found so far, as a perfectly ordinary bestmove line. If the app has already started a new search by then, that line looks exactly like the answer to it.
This can happen for real. Start a new game as Black while the engine is still thinking about the old one: the store stops the old search and immediately asks for White's opening move.
- to workergo movetime 2000
Move 14 of a game on Max. Stockfish is thinking; the store is awaiting search #1.
- Promise waiting
- search #1
- staleReplies
- 0
The fix is a counter. Every cancelled search adds one to it, and every bestmove that arrives while it is above zero is swallowed:
// Abandon the current search; its result is discarded
stop() {
if (!this.pending) return;
this.pending.reject(new Error('Search cancelled'));
this.pending = null;
this.staleReplies++;
this.send('stop');
}
It works because the worker answers in order. The store adds a second line of defence: before playing an engine move, it checks the game and position are still the ones it asked about. And if the engine ever does suggest something illegal, or takes more than ten seconds over its limit, the store plays a random legal move so the game can carry on.
Part 2: chess.js is the game, and everything else is a photograph of it
Before the 3D, the most important design decision in the app, because the board only makes sense once you know it.
There is exactly one Chess object, at the top of the store module:
// chess.js holds the real game; the store only exposes plain snapshots of it
const chess = new Chess();
It is not in React state and it is not in zustand. It is the game. After anything changes it, a function called snapshot() asks it a set of questions (what is on the board, whose turn, is anyone in check, is it over) and the answers go into the store as plain data.
The board components get that data as props and send clicks back as square names. That is their entire relationship with chess.
function useSquareClick(square, onSquareClick) {
return (e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
// Ignore clicks that were really camera drags
if (e.delta > 5 || !onSquareClick) return;
onSquareClick(square);
};
}The board knows nothing about chess. It reports a square name, like "e2", and that is all.
The contract is spelt out in the board's prop types:
// Everything a board needs to draw a position. Boards hold no game logic:
// clicks are reported back through onSquareClick.
export interface BoardViewProps {
board: BoardState;
orientation: Color;
lastMove?: { from: Square; to: Square } | null;
checkSquare?: Square | null;
selected?: Square | null;
legalTargets?: Square[];
onSquareClick?: (square: Square) => void;
// …
}
This pays off in a few places. The 2D board and the 3D board are interchangeable, and you can switch between them mid-game. The landing page, the replay viewer and the spectator view all reuse the same 3D board by handing it a position and leaving out onSquareClick. And three.js, which is not small, is only downloaded when a 3D board is first shown.
Stockfish plugs into the same loop. Its reply goes through chess.move() exactly as a click does, so an AI move, your move and an online opponent's move are all drawn by the same code.
Part 3: Building the 3D board
Pieces without models
There is no .glb file in the repository. Every piece is a few cylinders, spheres and boxes stacked on top of each other in JSX. This is the whole pawn:
<mesh position={[0, 0.05, 0]} castShadow>
<cylinderGeometry args={[0.24, 0.27, 0.1, 24]} />
{mat}
</mesh>
<mesh position={[0, 0.25, 0]} castShadow>
<cylinderGeometry args={[0.09, 0.18, 0.3, 24]} />
{mat}
</mesh>
<mesh position={[0, 0.48, 0]} castShadow>
<sphereGeometry args={[0.13, 24, 16]} />
{mat}
</mesh>
A cylinder with different top and bottom radii is a cone with its tip cut off, and that one shape does most of the work: bases, bodies, a rook's flared top, a queen's crown. The bishop's mitre is a sphere stretched to 1.4 times its height. The knight is a box tilted forward by half a radian, which reads as a horse's head more convincingly than it has any right to. Knights are also the only piece with a front, so they are turned to face the opponent.
Nothing to download, nothing to parse, and a board theme is just a different set of colours passed to the materials.
From a square name to a point in space
chess.js names squares a1 to h8. three.js wants x, y and z. Two small functions sit between them:
export function squareCoords(square: Square): [number, number] {
return [FILES.indexOf(square[0]), Number(square[1]) - 1];
}
// file 0-7 = a-h, rank 0-7 = 1-8. White sits at -z looking toward +z,
// so the a-file is on +x (white's left) and rank 1 is at -z.
function squareToPosition(file: number, rank: number): [number, number, number] {
return [3.15 - file * SQUARE_SIZE, BOARD_TOP, rank * SQUARE_SIZE - 3.15];
}
A square is 0.9 units wide. The board is centred on the origin, so the middle of the outermost squares is three and a half squares out: 3.5 × 0.9 = 3.15.
Playing as Black does not rotate the board or change any of this maths. The camera is simply placed at the other end of the table.
Clicking on things
The board is 64 separate box meshes, not one textured plane, so each square is its own click target that already knows its name. React Three Fiber does the raycasting and delivers an ordinary onClick.
A square can have other things stacked on it, though, and they need to agree about what a click means.
Two details are worth pointing out.
Highlights must not eat clicks. The yellow last-move tint and the red check tint are flat planes floating two thousandths of a unit above the board. Left alone they would intercept the ray. They are given raycast={() => null}, which makes them invisible to it.
A drag is not a click. You can swing the camera by dragging, and a drag that starts and ends over the board would otherwise select whatever was under the pointer when you let go. R3F reports how far the pointer travelled between press and release, so the handler ignores anything that moved more than five pixels:
if (e.delta > 5 || !onSquareClick) return;
The legal-move dots do one more trick. The dot you see is small, so each target also has an invisible plane covering the whole square to give you something reasonable to hit.
The hard part: board() has no memory
chess.board() returns an 8 × 8 array describing what is on each square. After e4 it tells you e2 is empty and e4 has a white pawn. It does not tell you that it is the same pawn. As far as the data goes, one pawn disappeared and another appeared.
For a 2D board that barely matters. For a 3D board that wants to slide the piece across, it is everything. React needs a stable key for that pawn, so that the same mesh survives the move and can be animated instead of being destroyed and recreated in the new place.
So a hook compares each new position with the previous one and hands out ids, in three passes:
- Same square, same piece. Nearly everything. These keep their id.
- Nearest unclaimed piece of the same kind and colour. This is the piece that moved.
- Nearest unclaimed piece of the same colour, at most a square and a half away. This only ever fires for promotion, when a pawn has turned into something else.
Anything left unclaimed from the old position was captured. Anything unmatched in the new one gets a fresh id.
O-O One move, two pieces. Neither is on its old square, so both fall through to the second pass.
- Pass 1, same square: 30 pieces kept their ids without any searching.
- Pass 2: nearest piece of the same kind: id 32, white rook, h1 → f1
- Pass 2: nearest piece of the same kind: id 31, white king, e1 → g1
The useful property is that it never looks at the move. It works from two positions and nothing else, so it copes equally well with a move you just played, a replay jumping around, or an online game being rebuilt from the server's move list.
Making it glide
Each piece is told its square by a prop and moves itself there, a little each frame:
useFrame((_, delta) => {
const g = ref.current;
if (!g) return;
g.position.x = MathUtils.damp(g.position.x, x, 12, delta);
g.position.z = MathUtils.damp(g.position.z, z, 12, delta);
const remaining = Math.hypot(g.position.x - x, g.position.z - z);
g.position.y = y + Math.min(remaining, 1.2) * 0.3;
});
damp closes a fixed fraction of the remaining gap per unit of time, so the piece sets off quickly and eases in, and it behaves the same at 60 or 144 frames a second. There is no animation library, no timeline and no "is animating" flag. If the target changes mid-flight, the piece just heads for the new one.
The last line is the lift. Height is tied to the distance left to travel, capped at 1.2 units. It is not a true arc: the piece pops up at once and sinks as it arrives. On a long move the cap makes it cruise at a steady height first.
The snap-back bug
That code has one odd feature: the piece's position is never passed as a prop. It is set once when the piece mounts, and from then on only useFrame touches it.
That is deliberate. When React moves an element to a different place in a list, React Three Fiber re-inserts the object and re-applies all of its props. With position as a prop, a piece could suddenly jump back to the square it was first created on.
There are two halves to the fix. Position stays out of the props, and the tracked pieces are sorted by id before rendering, so an existing piece never changes its place in the list and never gets re-inserted in the first place.
The whole loop
Here is everything at once: the real board, the real engine, and a log of each stage as it happens. Click a piece and follow one move all the way round.
A click becomes a square name. chess.js turns that into legal moves, then into a new position. The position becomes a FEN string for the worker and a set of props for the board. Four characters come back from the engine, go through chess.js again, and the pieces slide.
What I would do differently
- The pieces are crude. Stacked primitives were quick, but a lathe geometry (a profile curve spun around an axis) would give proper turned pieces for about the same amount of code.
- It is a lot of meshes. 64 squares and 32 pieces of three to five meshes each comes to around 170 meshes for the starting position. It runs fine, but instancing the squares and the pawns would be the first thing to try if it ever did not.
- Piece tracking is a heuristic. It is guessing from two snapshots. chess.js knows precisely which piece moved, and using that when it is available would be more honest.
Credits
The engine is Stockfish, built for the web as stockfish.js by Nathan Rugg. It is GPL-3.0 licensed and used here unmodified. The rules come from chess.js, and the 3D from three.js and React Three Fiber.
Play Chess BORED!Related posts
Building a 3D Dice Roller with React Three Fiber in One Night
June 22, 2026
How I built a realistic physics-based 3D dice roller with React Three Fiber in 8 hours, adapting a Codrops tutorial with mobile accelerometer support.
Building itty-bitty-link: A Deep Dive into Vercel's Developer Experience
June 26, 2026
Building a Next.js link shortener on Vercel's serverless platform, covering authentication, database integration, and production-ready caching.
Neovim my love and hate relationship
February 17, 2026
An exploration of my personal experience with Neovim, highlighting both the aspects I love and the challenges I face while using it as my primary code editor.