It Started With a Simple Frustration

I lose a chess game. I want to know why.

Lichess and Chess.com both have analysis boards, but they’re cluttered, slow to load, and I don’t control my data. I just want to paste a PGN — the standard text format chess games are saved in — and step through my moves on a clean board.

That’s it. That’s the whole requirement.

What I shipped six weeks later: a retro terminal-style chess app with 3D WebGPU rendering, a Stockfish bot at three difficulty levels, real-time online multiplayer, JWT authentication, a PostgreSQL database tracking win/loss stats, and an AI lesson generator powered by LiteLLM.

Scope creep is a hell of a drug.


What WebGPU Actually Is (And Why It Matters)

Before the story: a quick detour.

Most web graphics run on WebGL — a browser API from 2011 that wraps OpenGL ES. It works everywhere, but it was designed for a different era of hardware. GPU programming has moved on.

WebGPU is the modern replacement. It maps directly to how current GPUs actually work — closer to Metal (Apple), Vulkan (Linux/Android), and DirectX 12 (Windows). The result: better performance, more predictable behavior, and access to compute shaders for general GPU programming.

The catch: browser support in 2024-2025 is uneven. Chrome on desktop supports it. Safari added it behind a flag. Firefox is still catching up. Mobile is spotty.

So when I decided to use WebGPU for the chess board, I also had to write the entire fallback path to WebGL. Every piece that works in WebGPU has to work in WebGL. You ship two renderers.

This is the tax you pay for using bleeding-edge technology.


Phase 1: The “Simple” PGN Viewer

The first version was genuinely simple. Three files:

  1. A PGN parser (parser.ts) — takes the text format chess games are exported in and produces a list of moves
  2. A Babylon.js board renderer (babylonChess.ts) — places pieces, animates moves, handles the camera
  3. A React UI (App.tsx) — paste PGN, click step forward/backward

Babylon.js is a 3D engine that handles the WebGPU/WebGL abstraction. You write scene code once; it figures out which GPU API to use.

Getting pieces to move in 3D with smooth animations took most of the time. Chess pieces aren’t cubes — they have concave surfaces, thin bases, varying heights. The camera needs to rotate around the board without clipping through it. Highlights on squares need to glow without washing out the piece colors.

None of this is hard. All of it takes longer than you expect.

Time to first working demo: 3 days.


Phase 2: The Bot

Once you can replay games, you want to play games. Against something.

Stockfish is the strongest open-source chess engine in the world. It’s written in C++. Browsers can’t run C++.

Except they can now — compiled to WebAssembly (WASM). Stockfish 18 lite ships as a .js + .wasm bundle that runs directly in the browser. No server required. The engine runs in a Web Worker so it doesn’t block the UI thread.

The integration is surprisingly clean:

const worker = new Worker('/stockfish.js')
worker.postMessage('uci')
worker.postMessage(`position fen ${currentFen}`)
worker.postMessage(`go depth ${difficulty}`)
worker.onmessage = (e) => {
  if (e.data.startsWith('bestmove')) {
    const move = e.data.split(' ')[1]
    makeMove(move)
  }
}

Three difficulty levels map to different search depths. Easy (depth 5) plays like a beginner. Medium (depth 10) will punish you for obvious blunders. Hard (depth 15) will make you question your life choices.

The unexpected problem: WASM files can’t be served without the correct MIME type (application/wasm). Nginx’s default config doesn’t include it. The engine silently failed to load with a CORS-adjacent error that had nothing to do with CORS.

Fix: one line in nginx.conf:

types {
    application/wasm wasm;
}

Phase 3: The Decision I Almost Didn’t Make

At this point I had a working single-player app. The natural stopping point.

Then I thought: what if two people could play each other?

Real-time multiplayer requires:

  • A persistent server (WebSockets, not HTTP)
  • Server-side move validation (never trust the client)
  • Matchmaking (pairing players who want to play)
  • Connection handling (what happens when someone disconnects?)

This is not a weekend feature. I did it anyway.

The server is Node.js + Express with the ws package for WebSockets. Every game lives in a server-side state object. When a player makes a move, the client sends it to the server; the server validates it with chess.js, updates state, and broadcasts to both clients.

Why server-side validation matters: without it, any player can open the browser console and send { move: 'e1g1', result: 'checkmate' }. Game over in one message. Trust the server, not the client.

The matchmaking is a queue. First player in waits. Second player in gets paired with the first. A room ID is generated. Both clients receive it and subscribe.

// Server: pairing logic
if (waitingPlayer) {
  const roomId = generateRoomId()
  rooms[roomId] = { white: waitingPlayer, black: ws, game: new Chess() }
  waitingPlayer.send(JSON.stringify({ type: 'matched', color: 'white', roomId }))
  ws.send(JSON.stringify({ type: 'matched', color: 'black', roomId }))
  waitingPlayer = null
} else {
  waitingPlayer = ws
}

The hardest part wasn’t the code — it was handling disconnections gracefully. If one player closes the tab, the other player needs to know. WebSocket close events fire reliably, but figuring out whether it was intentional or a network hiccup requires a heartbeat mechanism.


Phase 4: Accounts and the Stats Nobody Asked For

Once you have multiplayer, wins and losses are meaningful. Once wins and losses are meaningful, people want to track them.

PostgreSQL. JWT auth. A users table with wins, losses, draws columns updated on game end.

The auth flow is standard: register hashes the password with bcryptjs, stores the hash, returns a JWT. Login verifies the hash, returns a JWT. Every authenticated request includes the token in the Authorization header; the server verifies it with jsonwebtoken.

Nothing novel here. Worth doing once to remember how it works.


Phase 5: The AI Lesson Generator (The Feature Nobody Planned)

The app is connected to LiteLLM for a different project. One afternoon I wired it up to the chess backend.

Paste a PGN → the app identifies the critical moments (large swings in Stockfish evaluation) → sends those positions to the LLM → gets back a structured lesson explaining what went wrong and what the correct idea was.

Position after move 23: Nc4
Stockfish evaluation: +2.1 → -0.8 (swing of 2.9 pawns)

Lesson: The knight on c4 looks active but has no support and can be 
immediately attacked by ...b5. The correct plan was Nd2, rerouting 
to the kingside where an attack was brewing. Once the knight is 
displaced, Black's queenside pawns become a serious threat.

This took a day to build. The LLM does the heavy lifting — you just need to feed it the right context (FEN position, previous moves, evaluation change) and ask the right question.


The Themes

Four visual themes — Amber, GBC, DMG, Synth — each a different retro terminal aesthetic. Amber is old CRT monitors. GBC is Game Boy Color. DMG is the original gray Game Boy. Synth is 80s neon.

This was the most fun part. CSS variables for everything. One data-theme attribute on the root element controls the entire visual identity.

[data-theme="amber"] {
  --bg: #1a0e00;
  --fg: #ff8c00;
  --board-light: #3d2200;
  --board-dark: #1a0e00;
  --highlight: #ff8c0066;
}

What I’d Do Differently

Ship the MVP and stop. The PGN viewer was the requirement. Everything after that was scope creep masquerading as feature development. Each addition was individually justified. The sum was six weeks instead of three days.

That said: the multiplayer feature is the one I’m most proud of and wouldn’t have built if I’d stopped at the MVP. So maybe the lesson is: build the MVP, deploy it, use it for a week, then decide what to add. Don’t design the full system upfront.

The WebGPU bet. Paying the WebGL fallback tax was real work. A year from now, WebGPU support will be universal and the fallback can be deleted. Today, if you’re on Firefox or most mobile browsers, you’re on WebGL. Whether the 3D quality delta was worth the engineering cost is debatable.

The mobile app. There’s a React Native/Expo version of this app. It’s not in this post because it’s not done yet. Building the same thing twice — once for web, once for mobile — is a lesson in why cross-platform frameworks exist and why they’re never quite perfect.


The Stack, Summarized

WhatHow
3D renderingBabylon.js (WebGPU → WebGL fallback)
Chess logicchess.js (moves, FEN, validation)
BotStockfish 18 lite (WASM, runs in-browser)
MultiplayerWebSockets (Node.js ws package)
AuthJWT + bcryptjs
DatabasePostgreSQL
AI lessonsLiteLLM → GPT-4o
DeployDocker + Nginx + Cloudflare + VPS

Try It

Live at chess.anmious.cloud — no account required to replay games or play the bot. Create an account if you want stats tracked.

Source on GitHub.

The lesson from building this: requirements are a starting point, not a ceiling. Sometimes the unplanned features are the ones worth building. You just have to be honest about when you’re exploring versus when you’re avoiding shipping.


Next: the mobile app. When it’s done.