Maged Faiz

Case study · Stalemates

Stalemates: building a chess platform from the other side of the board

A full-stack chess platform with adjustable Stockfish difficulty, real-time multiplayer, reconnection, and a server-authoritative clock.

Filed
Sep, 2024 · completed
Stack
Svelte / Express.js / Vite / TypeScript / Tailwind CSS
Live
magedfaiz.xyz
Source
github.com

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 as the kind of project that forces you to learn the things you have been 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 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 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 both ride a sigmoid, so the middle of the range is where strength changes fastest. Contempt is how hard the engine works to dodge a draw, basically its appetite for a fight. Depth and move time follow power curves. MultiPV tells Stockfish how many principal variations to report. The app still consumes only bestmove, so Skill Level, depth, and move time do the real work of weakening the opponent. The wider MultiPV setting does not add variation by itself. The low levels also get a deliberate delay, because nothing sells a human opponent quite like pretending to think.

src/lib/engine/Stockfish.tsview source
setDifficulty(level: number): void {
  this.difficulty = level;
  const skillLevel = this.mapLevelToSkill(level);
  const contempt = this.mapLevelToContempt(level);
  const moveTime = this.mapLevelToMoveTime(level);
  const depth = this.mapLevelToDepth(level);
  const multiPV = this.mapLevelToMultiPV(level);
  const moveDelay = this.mapLevelToMoveDelay(level);

  // ... log the six chosen values, then hand three of 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 };
}

// ...

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);
}

// ...

private mapLevelToDepth(level: number): number {
  return Math.round(1 + Math.pow((level - 1) / 19, 1.4) * 14);
}

// ...

private mapLevelToMoveTime(level: number): number {
  return Math.round(100 + Math.pow((level - 1) / 19, 1.5) * 1700);
}

// ...

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. The browser and multiplayer server both use chess.js at the same declared version, which removes one source of rule drift. The client uses it to preview legal moves and validate local input. The server still owns the result. It replays every submitted move on its own board and resynchronizes the client when the states differ. The chess.js beta had one sharp edge, and for a while it could take the whole server down with it. The handler now catches malformed or illegal moves, ignores them, and sends the authoritative state back to the player.

api/src/lib/GameRoom.tsview source
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. 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. Timeouts are the server's call alone. It arms a watchdog for the side to move, recomputes the remaining time when the timer fires, and schedules another timer if that check finds time left. Otherwise the server 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

view source
/**
 * Pure, authoritative clock math for the multiplayer server.
 *
 * No `Date.now()` or `setTimeout` lives in here — the current time is always
 * injected as `now` (ms). This keeps flag-fall deterministic and unit-testable
 * (see clock.test.ts). Clocks are stored in MILLISECONDS to avoid the
 * seconds/ms rounding drift the old per-move logic suffered from.
 */

// ...

/**
 * The mover's new remaining ms after completing a move: deduct the time spent
 * on the move, then add the increment (awarded only if they did not flag).
 */
export function clockAfterMove(
  currentMs: number,
  turnStartedAt: number | null,
  incrementMs: number,
  now: number
): number {
  const elapsed = turnStartedAt !== null ? now - turnStartedAt : 0;
  const remaining = currentMs - elapsed;
  if (remaining <= 0) return 0;
  return remaining + incrementMs;
}

08Surviving a dropped connection

Real games happen on flaky phone networks, so losing the socket does not remove a player. It marks them disconnected and keeps the seat in the room. Their identity stays in a cookie. When they return, the server matches the ID, attaches the new socket, and sends the current game state. The room is removed only after both players have left.

api/src/lib/GameRoom.tsview source
reconnectPlayer(playerId: string, ws: WebSocket): boolean {
  const player = this.findPlayerById(playerId);
  if (!player) {
    return false;
  }

  player.ws = ws;
  player.connected = true;

  this.resyncPlayer(player);
  this.notifyOpponentOfReconnection(playerId);

  return true;
}

// ...

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 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 the same ordinary VPS as a few other services, so it adds little to infrastructure I already keep online.

10The small things that make it feel finished

A lot of the work went into details you notice only when they fail. Against the computer, undo treats the player's move and the engine's reply as a pair, then resets Stockfish to the restored position. An earlier version could freeze if undo landed during an active search. The snippet shows the ordering rule that fixed it. The game also has a hint arrow and audio cues for moves, captures, castling, and check. None is difficult alone. Together they move the project past a board that merely works.

src/lib/chess/AIGameState.tsview source
undoMove() {
  this.engine.stop();
  this.core.undo();
  this.core.undo();
  this.patch({ moveHistory: this.snapshot().moveHistory.slice(0, -2) });
  this.updateGameState();
  this.engine.setPosition(this.core.fen());
}

11The code I trusted

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.

Object-oriented structure turned out not to be gospel. Here it was a frame, one small base model that lets two different modes share a board, with the rules pushed into a plain class that has its own tests. The Cloudflare detour taught me to distrust the fancier option by default, because the boring server I understood shipped the feature faster than the clever one I kept fighting. But the audit is the one I think about. 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.