01Back to the board
Some of my clearest memories from those summers in Khartoum are of a chessboard. Lessons in the morning, sport in the afternoon, and somewhere in between, games that felt like the only quiet thing all day. I was not good. I just loved the shape of it, a problem with clear rules and no luck to hide behind.
Years later I wanted to come back to it from the other side, as the person building the thing. A chess platform looks small and turns out not to be. You need the board and the rules, an opponent smart enough to be worth playing, and the wiring for two strangers to play each other in real time. I picked it on purpose, as the kind of project that forces you to learn the things you have been quietly avoiding.
02Choosing the pieces
Most of my work lives in React and Next.js, so for this one I went somewhere else on purpose. SvelteKit is lean, the compiler does a lot of the work, and its stores map cleanly onto something as stateful as a chess game. On top of that I pulled in chess.js for move generation and validation, svelte-chessground for the board itself, and a JavaScript/asm.js build of the Stockfish C++ engine for the computer opponent. The multiplayer side runs on a small Node server using Express and the ws library.
03One board, two modes
The same board has to work whether you are playing the computer or another person, so both modes extend one shared GameModel that owns the game view the UI reads. The rules themselves sit one level lower, in a small ChessCore class that wraps chess.js and nothing else. The AI subclass bolts on the engine. The multiplayer subclass bolts on the clocks and the reconnection handling. The board component never has to know which one it is driving, which kept the UI simple even after the two modes had drifted apart in what they each needed. And because ChessCore is a plain class with no Svelte in it, the rules layer gets ordinary unit tests instead of hoping the UI exercises every edge case.
04The engine, kept off the main thread
Stockfish is a serious chess engine. The JS build is a few megabytes, and it will happily chew on a single move for a full second or more. Run that on the main thread and the whole UI freezes while the computer makes up its mind. So the engine lives in a Web Worker, and the app talks to it over the standard UCI protocol with postMessage. The worker thinks, the board stays responsive, and answers come back as messages. You cannot just start asking it for moves either. First it has to come up through the UCI handshake: send uci, wait for uciok, send isready, wait for readyok, and only then is it ready to play.
05Making difficulty feel human
Even on its weakest setting, raw Stockfish plays with a precision that quietly flattens a casual player. Making the computer strong is the easy part. The hard part is making it fun to lose to when you crank it up and fun to beat when you dial it down, and one strength slider just cannot carry that. So a difficulty level from 1 to 20 fans out into six separate engine settings, each on its own curve. Skill and contempt ride a sigmoid, so the middle of the range moves fastest, contempt being how hard the engine works to dodge a draw, basically its appetite for a fight. Depth and move time follow a power curve. MultiPV, the number of candidate moves the engine keeps in play, widens at the low levels so an easy game feels varied instead of just bad. The low levels also get a deliberate delay, because nothing sells a human opponent quite like pretending to think.
setDifficulty(level: number): void {
this.difficulty = level;
const skillLevel = this.mapLevelToSkill(level); // sigmoid, 0-20
const contempt = this.mapLevelToContempt(level); // sigmoid, -100..100
const moveTime = this.mapLevelToMoveTime(level); // power curve
const depth = this.mapLevelToDepth(level); // power curve
const multiPV = this.mapLevelToMultiPV(level); // stepped, 5..1
const moveDelay = this.mapLevelToMoveDelay(level); // linear, 400..0ms
// ...log the six chosen values, then hand them to the engine.
this.worker.postMessage(`setoption name Skill Level value ${skillLevel}`);
this.worker.postMessage(`setoption name Contempt value ${contempt}`);
this.worker.postMessage(`setoption name MultiPV value ${multiPV}`);
this.searchParams = { moveTime, depth, moveDelay };
}
// Skill (0-20): sigmoid centered at level 10 so the middle changes fastest.
private mapLevelToSkill(level: number): number {
const x = (level - 10) / 5; // Center the sigmoid at level 10
const sigmoid = 1 / (1 + Math.exp(-x));
return Math.round(sigmoid * 20);
}
// Depth (1-15): power curve, exponent 1.4.
private mapLevelToDepth(level: number): number {
return Math.round(1 + Math.pow((level - 1) / 19, 1.4) * 14);
}
// Move time (100-1800 ms): power curve, exponent 1.5.
private mapLevelToMoveTime(level: number): number {
return Math.round(100 + Math.pow((level - 1) / 19, 1.5) * 1700);
}
// Move delay (400-0 ms): linear decrease so easy levels do not answer instantly.
private mapLevelToMoveDelay(level: number): number {
return Math.round(400 - ((level - 1) / 19) * 400);
}06One rulebook, two sides
Once single-player was playable, the harder half of the brief was still sitting there: two strangers on two machines, agreeing on one game. Both the browser and the multiplayer server run the same chess library, at the same version in both package.json files, so the two can never disagree on some rules edge case. The client uses it to show legal moves and check what the player does locally. The server uses it as the source of truth: every move a client sends gets replayed on the server's own board. There was a sharp edge hiding in that, though. The chess.js beta does not politely refuse an illegal move, it throws, and for a while an illegal move was an uncaught exception in the one process every live game shares. A malformed message could take down everyone's evening. Now the server treats anything suspect the same way: catch it, ignore it, and resync the sender with the authoritative state.
private handleMove(player: Player, move: { from: string; to: string; promotion?: string }) {
if (!this.gameStarted || player.color !== this.currentTurn) {
this.resyncPlayer(player);
return;
}
if (!move || typeof move.from !== 'string' || typeof move.to !== 'string') {
this.resyncPlayer(player);
return;
}
let success;
try {
// chess.js (beta) throws on illegal moves
success = this.chess.move(move);
} catch (error) {
console.error('Ignoring illegal move:', error);
this.resyncPlayer(player);
return;
}
if (!success) {
this.resyncPlayer(player);
return;
}
this.updateGameStateAfterMove(player, move);
}07Keeping time
Each multiplayer game is a GameRoom on the server, holding the board, both players, and their clocks. I will be honest about how this part grew up, because the repo says it out loud anyway. The project carries an AUDIT.md, a full pass I did over my own shipped code, and its headline finding was embarrassing: the first version of the clock trusted the client. When you ran out of time, your opponent's browser was the one that said so, which means anyone who could type a WebSocket message could have said so whenever they liked. Nobody ever cheated me with it, as far as I know. It still went to the top of the audit as the thing to kill first.
The fix inverted the design. All the clock arithmetic moved into a pure module that never calls Date.now() or setTimeout itself; the current time comes in as an argument, so flag-fall is deterministic and every case sits in an ordinary unit test. Clocks run in milliseconds now too, because the old per-move seconds math drifted. When a move lands, the server charges the mover for exactly the time they spent and awards the increment only if they had not already flagged. And timeouts are the server's call alone: it arms a watchdog timer for the side to move, re-checks the real remaining time when the timer fires, re-arms if a reconnect adjusted the clock mid-wait, and otherwise ends the game itself. Where the old client-declared timeout case used to be in the message handler, there is now just a comment explaining that clients cannot declare outcomes anymore.
The server owns the clock
08Surviving a dropped connection
Real games happen on flaky phone networks. If a dropped connection ended the game, half the matches would die to a tunnel or a lift. So losing the socket does not remove a player. It just marks them disconnected and drops their WebSocket. Their identity lives in a cookie, and when they come back the server matches that id, re-attaches the socket, and replays the current state to bring them up to speed. The room only gets torn down once every player has actually left.
reconnectPlayer(playerId: string, ws: WebSocket): boolean {
const player = this.findPlayerById(playerId);
if (!player) {
return false; // unknown id, nothing to restore
}
player.ws = ws;
player.connected = true;
this.resyncPlayer(player);
this.notifyOpponentOfReconnection(playerId);
return true;
}
// resync = replay the full authoritative state to one player
private resyncPlayer(player: Player) {
this.sendToPlayer(player, {
type: 'gameState',
...this.getCurrentGameState(),
timeControl: this.timeControl
});
}09Where the server lives
I did not start with Express. My first sketch ran on Cloudflare Workers with Hono, which is a lovely setup right up until you need two clients to share one live game room. Coordinating that much stateful, in-memory back-and-forth across a distributed edge runtime fought me harder than the feature was worth, so I backed off to something I could actually hold in my head: a plain Express server with the ws library, owning the rooms directly.
The multiplayer backend is small on purpose. One Express process handles the WebSocket connections too, and the live games sit in memory as a map of rooms. There is no database and no message broker, which does mean a game will not survive a server restart. For something like this I think that is a fair trade, and I would rather say so than pretend the limit is not there. It is a single process, it does not scale sideways, and the day I genuinely needed it to, I would reach for a shared store and pub/sub. For now it runs in a container on an ordinary VPS alongside a few other services, so the whole real-time layer costs me almost nothing to keep online.
10The small things that make it feel finished
A lot of the work went into details you only notice when they are missing. Against the computer, undo rolls back two half-moves at once, yours and the engine's reply, then re-syncs the engine to the new position so its next move is honest. The subtle part is the first line: undo has to tell the engine to stop thinking before anything else, because an answer computed for a position that no longer exists is worse than no answer. An earlier version skipped that, and undoing while the engine was mid-search could freeze the game on a stale move. Beyond that there is a hint that draws an arrow on the board, and little audio cues for moves, captures, castling, and check. None of it is hard on its own. Put together, it is the gap between a tech demo and something you actually want to play.
undoMove() {
this.engine.stop(); // cancel any in-flight search first
this.core.undo(); // take back the engine's reply
this.core.undo(); // and your move
this.patch({ moveHistory: this.snapshot().moveHistory.slice(0, -2) });
this.updateGameState();
this.engine.setPosition(this.core.fen()); // keep the engine honest
}11What I took from it
Stalemates dragged me through a stack I do not touch day to day and a problem I had mostly only read about, real-time game state. The authoritative server, the reconnection handling, and the difficulty curves are the parts I am proudest of, because they are the parts that only matter once you treat the game as something real people will sit down and play.
A few things stuck with me. Object-oriented structure is not gospel. Here it was just scaffolding, one small base model that lets two pretty different modes share a board, with the rules pushed down into a plain class that has its own tests. The Cloudflare detour taught me to distrust the fancier option by default; the boring server I understood shipped the feature faster than the clever one I kept fighting. And auditing my own live project taught me the most of all, because the worst bugs were not in the code I found hard. They were in the code I had trusted without looking.
The board is still on. Accounts, saved game history, and post-game analysis are the obvious next moves, and they are on the list. For now it is live, so go play a game, and if something breaks, I want to hear about it.