Checkmate Your Coding Skills: The Statemates Chess Platform Saga

A full-stack chess platform using SvelteKit, WebSockets, and the Stockfish engine, featuring adjustable AI difficulty, and real-time multiplayer.

Sep, 2024Completedmagedfaiz.xyzSource
Carousel image (1)
Carousel image (2)

From Pawns to Promises: A Developer's Journey Back to the Board

Those summer days in Khartoum were filled with the rhythm of lessons, sports, and quiet moments at the chessboard. Chess wasn't just another activity—it was a mental escape, a strategic battle sandwiched between English classes and the heat of the afternoon sun. Little did I know, those early moves would lay the foundation for something much bigger. Decades later, the game of kings and queens called me back, this time with a new goal in mind: could I build my own chess platform?

Now, I know what you're thinking. "Another chess app? Hasn't that board been flipped enough times?" But hear me out. This wasn't just about creating yet another place for people to push pixels shaped like horses. No, this was about pushing myself as a developer. I wanted to dive into the world of chess engines, real-time multiplayer, and the intricate dance of game logic. It was time to put my coding skills in check.

The Opening Moves: Choosing the Right Pieces

As a React/Next.js aficionado, my first instinct was to reach for my trusted tools. But where's the fun in that? I decided to shake things up and revisit an old flame: Svelte/SvelteKit. It was like reuniting with an ex, hoping they've matured as much as you have (spoiler alert: Svelte did not disappoint).

To ensure I wasn't reinventing the wheel (or in this case, the rook), I opted for shadcn-svelte as my UI library. Because let's face it, spending hours crafting the perfect button is about as exciting as watching paint dry on a chessboard.

But a chessboard alone does not a platform make. I needed:

After scouring the internet and engaging in deep philosophical debates with various LLMs (who, by the way, are surprisingly opinionated about JavaScript frameworks), I settled on my tech stack:

With my pieces assembled, it was time to make the first move.

The Middlegame: Battling Bishops and Bugs

Getting the chessboard to play nice with chess.js was smoother than Hikaru Nakamura calculating a 20-move combination. But then came Stockfish, the final boss of chess engines.

Integrating Stockfish was like trying to get Magnus Carlsen to play a game without pre-moving – theoretically possible, but practically challenging. At times, I felt like Beth Harmon staring down a complex position, analyzing every move and counter-move. Stockfish was relentless, playing with the precision of a grandmaster, but I was determined to outmaneuver it. It's a C++ engine, you see, and JavaScript and C++ get along about as well as a knight and a bishop fighting for the same square. Thankfully, there are JavaScript ports of Stockfish. It's like finding out your favorite grandmaster has started streaming on Twitch—unexpected, but oh so welcome.

File:Stockfish.ts

import { Engine, EngineState } from './engine';
import type { ChessMove } from '$lib/chess/types';
import { STARTING_FEN } from '$lib/constants';

interface SearchParams {
	moveTime: number;
	depth: number;
}

/**
 * Stockfish class that interacts with the Stockfish chess engine via a Web Worker.
 * It provides methods to control the engine, set difficulty, and retrieve best moves.
 */
export class Stockfish extends Engine {
	private state: EngineState;
	private difficulty: number;
	private bestMove: ChessMove;
	private ponder: ChessMove;
	private searchParams: SearchParams;
	private messageCallback: ((message: string) => void) | null = null;
	private currentFen: string = STARTING_FEN;
	private debug: boolean;

	/**
	 * Creates a new Stockfish instance.
	 * @param debug - If true, enables detailed logging.
	 */
	constructor({ debug = false, difficulty = 10 }) {
		super('/stockfish.js');
		this.state = EngineState.Uninitialized;
		this.difficulty = difficulty; // Default difficulty level (range: 1-20)
		this.bestMove = { from: '', to: '' };
		this.ponder = { from: '', to: '' };
		this.searchParams = { moveTime: 1000, depth: 5, moveDelay: 400 };
		this.debug = debug;
		this.initialize();
	}

	private initialize(): void {
		this.setState(EngineState.Initializing);
		this.worker.postMessage('uci');
		this.worker.onmessage = this.handleInitialization.bind(this);
	}

	private handleInitialization(event: MessageEvent): void {
		const message = event.data;
		if (message.includes('uciok')) {
			this.setState(EngineState.Waiting);
			this.setDifficulty(this.difficulty);
			this.worker.postMessage('isready');
		} else if (message.includes('readyok')) {
			this.log('Engine is fully initialized and ready', 'info');
			this.state = EngineState.Waiting;
			this.worker.onmessage = this.handleMessage.bind(this);
		}
	}

	private handleMessage(event: MessageEvent): void {
		const message = event.data;
		this.log('Stockfish message: ', message);
		this.handleBestMoveMessage(message);
		if (this.messageCallback) {
			this.messageCallback(message);
		}
	}

	onMessage(callback: (message: string) => void): void {
		this.messageCallback = callback;
	}

	private handleBestMoveMessage(message: string): void {
		if (!message.includes('bestmove')) return;

		this.log(message, 'info');
		const moves = message.split(' ');
		this.bestMove = this.parseMove(moves[1]);
		this.ponder = moves[3] ? this.parseMove(moves[3]) : { from: '', to: '' };
		this.setState(EngineState.Waiting);
	}

	private parseMove(move: string): ChessMove {
		return {
			from: move.slice(0, 2),
			to: move.slice(2, 4)
		};
	}

// other methods

}

But even with our JavaScript-speaking Stockfish, I wasn't out of the woods yet. Stockfish was playing like it had the combined brains of every grandmaster in history. Great if you want to be humbled, not so great for casual players who don't enjoy digital dismemberment.

The solution? Tweaking Stockfish's parameters. It turns out, you can adjust things like skill level, contempt (how much the AI respects its opponent), and depth of calculation. It's like giving your AI opponent different personalities, ranging from "first-time player" to "Magnus Carlsen on espresso".

File:Stockfish.ts

{

  /**
	 * Sets the difficulty level of the chess engine.
	 * @param level - Difficulty level (1-20, where 1 is easiest and 20 is hardest)
	 *
	 * This method adjusts several Stockfish parameters based on the difficulty level:
	 * 1. Skill Level (0-20): Mapped using a sigmoid function for a more gradual increase.
	 *    Lower values make the engine play weaker, allowing for more mistakes.
	 *    At 0, the engine plays randomly from a selection of good moves.
	 *
	 * 2. Contempt (-100 to 100): Mapped using a sigmoid function centered at 0.
	 *    Positive values make the engine play more aggressively and take more risks to avoid draws.
	 *    Negative values make the engine more accepting of draws.
	 *    At 0, the engine plays objectively.
	 *
	 * 3. MultiPV (5-1): Decreases linearly as difficulty increases.
	 *    Determines the number of alternative moves the engine considers.
	 *    At lower difficulties, more alternatives are considered, making play more varied.
	 *    At higher difficulties, fewer alternatives are considered, focusing on the best moves.
	 *
	 * 4. Move Time (100-1800 ms): Increases non-linearly with difficulty.
	 *    Determines how long the engine thinks about each move.
	 *    Longer times at higher difficulties allow for deeper, more accurate analysis.
	 *
	 * 5. Depth (1-15): Increases non-linearly with difficulty.
	 *    Determines how many moves ahead the engine calculates.
	 *    Greater depth at higher difficulties results in stronger, more strategic play.
	 *
	 * 6. Move Delay (400-0 ms): Decreases linearly with difficulty.
	 *    Adds a delay before the engine makes its move, ensuring a more engaging user experience.
	 *    Shorter delays at higher difficulties balance out the longer move times.
	 *
	 * The new mappings ensure a smoother progression of difficulty:
	 * - Beginner and Casual levels have longer delays and shorter move times for quick, varied play.
	 * - Intermediate to Expert levels balance move time and delay for a natural progression.
	 * - Master and Grandmaster levels have longer move times but shorter delays for deep analysis and quicker responses.
	 * This progression aims to provide a more natural increase in difficulty while maintaining engagement.
	 */
	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);

		this.log(
			`Setting difficulty: Skill Level ${skillLevel}, Contempt ${contempt}, MultiPV ${multiPV}, Move Time ${moveTime}, Depth ${depth}, Move Delay ${moveDelay}`,
			'info'
		);
		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 };
	}


    /**
	 * Maps the difficulty level (1-20) to a Stockfish Skill Level (0-20).
	 * Uses a sigmoid function for a more gradual increase in skill level.
	 *
	 * @param level - The input difficulty level (1-20)
	 * @returns The corresponding Stockfish Skill Level (0-20)
	 */
	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);
	}

	/**
	 * Maps the difficulty level (1-20) to a Stockfish Contempt value (-100 to 100).
	 * Uses a sigmoid function for a more balanced progression, centered at 0.
	 *
	 * @param level - The input difficulty level (1-20)
	 * @returns The corresponding Stockfish Contempt value (-100 to 100)
	 */
	private mapLevelToContempt(level: number): number {
		const x = (level - 10) / 3; // Center the sigmoid at level 10
		const sigmoid = 1 / (1 + Math.exp(-x));
		return Math.round((sigmoid * 2 - 1) * 100); // Map to range -100 to 100
	}

	/**
	 * Maps the difficulty level (1-20) to a search depth (1-15).
	 * Uses a power function with exponent 1.4 for a balanced depth increase.
	 *
	 * @param level - The input difficulty level (1-20)
	 * @returns The corresponding search depth (1-15)
	 */
	private mapLevelToDepth(level: number): number {
		return Math.round(1 + Math.pow((level - 1) / 19, 1.4) * 14);
	}

	/**
	 * Maps the difficulty level (1-20) to a move time (100-1800 ms).
	 * Uses a power function with exponent 1.5 for a more balanced time progression.
	 *
	 * @param level - The input difficulty level (1-20)
	 * @returns The corresponding move time in milliseconds (100-1800)
	 */
	private mapLevelToMoveTime(level: number): number {
		return Math.round(100 + Math.pow((level - 1) / 19, 1.5) * 1700);
	}

	/**
	 * Maps the difficulty level (1-20) to a move delay (400-0 ms).
	 * Uses a linear function to provide a smooth decrease in delay.
	 *
	 * @param level - The input difficulty level (1-20)
	 * @returns The corresponding move delay in milliseconds (400-0)
	 */
	private mapLevelToMoveDelay(level: number): number {
		return Math.round(400 - ((level - 1) / 19) * 400);
	}

	/**
	 * Maps the difficulty level (1-20) to a MultiPV value (5-1).
	 * MultiPV decreases as difficulty increases, making the engine consider fewer alternative moves at higher difficulties.
	 *
	 * @param level - The input difficulty level (1-20)
	 * @returns The corresponding MultiPV value (5-1)
	 */
	private mapLevelToMultiPV(level: number): number {
		return Math.max(1, Math.floor((21 - level) / 4));
	}

    go(): void {
		if (this.state !== EngineState.Waiting) {
			this.log('Engine is not ready to start searching', 'warn');
			return;
		}
		this.setState(EngineState.Searching);
		const { moveTime, depth, moveDelay } = this.searchParams;
		this.log(`Delaying move by ${moveDelay}ms`);
		setTimeout(() => {
			this.log(`Sending go command to Stockfish with depth: ${depth}, movetime: ${moveTime}`);
			this.worker.postMessage(`go depth ${depth} movetime ${moveTime}`);
		}, moveDelay);
	}

  // other methods
}

But the real game-changer? Adding a move delay for lower difficulties. Because nothing says "I'm definitely not a super-computer" like taking a coffee break before each move.

The Great Refactor: Dividing and Conquering

As Statemates grew, so did the complexity of our game logic. Our single, monolithic GameState class was becoming as unwieldy as a chess game with only queens. It was time for a strategic reorganization - a refactoring gambit, if you will.

Much like Bobby Fischer's relentless pursuit of the perfect game, refactoring the code felt like a never-ending quest for precision. Each adjustment brought me closer to a cleaner, more efficient solution. We now have a base GameState class with AIGameState and MultiplayerGameState subclasses. Each class focuses on its specific responsibilities, making the code cleaner than a freshly polished chess set.

File:GameState.ts

import { writable, type Writable } from 'svelte/store';
import { Chess, type Move, type Square } from 'chess.js';
import { getCheckState, toDestinations } from './utils';
import type { GameSettings } from '$lib/stores/gameSettings';
import { STARTING_FEN, MOVE_AUDIOS_PATHS } from '../constants';
import type { CheckState, ChessMove, GameMode, GameOver, PromotionMove, MoveType } from './types';
import type { Color } from 'chessground/types';

export abstract class GameState {
	protected chess: Chess;
	private audioFiles: Record<MoveType, HTMLAudioElement> = {} as Record<MoveType, HTMLAudioElement>;
	mode: GameMode;
	player: Color;
	moveHistory: Writable<ChessMove[]> = writable([]);
	audioCue: Writable<MoveType> = writable('normal');
	started: Writable<boolean> = writable(false);
	promotionMove: Writable<PromotionMove> = writable(null);
	checkState: Writable<CheckState> = writable({ inCheck: false });
	gameOver: Writable<GameOver> = writable({ isOver: false, winner: null });
	fen: Writable<string>;
	turn: Writable<Color>;
	destinations: Writable<Map<Square, Square[]>> = writable(new Map());
	hint: Writable<ChessMove | null> = writable(null);

	constructor(mode: GameMode, player: Color, fen: string = STARTING_FEN) {
		this.chess = new Chess(fen);
		this.mode = mode;
		this.player = player;
		this.fen = writable(fen);
		this.turn = writable(this.chess.turn() === 'w' ? 'white' : 'black');
		this.updateDestinations();

		// Initialize audio files
		Object.entries(MOVE_AUDIOS_PATHS).forEach(([key, path]) => {
			this.audioFiles[key as MoveType] = new Audio(path);
			this.audioFiles[key as MoveType].load();
			this.audioFiles[key as MoveType].volume = 0.9;
		});
	}

	abstract setDifficulty(difficulty: number): void;
	abstract updateSettings(settings: GameSettings): void;
	abstract undoMove(): void;
	abstract getHint(): Promise<ChessMove | null>;

	newGame() {
		this.chess.reset();
		this.updateGameState();
		this.started.set(true);
		this.audioCue.set('game-start');
	}


	endGame() {
		this.chess.reset();
		this.updateGameState();
		this.started.set(false);
		this.gameOver.set({ isOver: false, winner: null });
		this.audioCue.set('game-end');
	}

	handlePlayerMove({ from, to }: ChessMove) {
		if (this.isPromotionMove(from, to)) {
			this.promotionMove.set({ from, to });
		} else {
			this.makeMove({ from, to });
		}
	}

	makeMove({ from, to, promotion }: ChessMove): boolean {
		try {
			const move = this.chess.move({ from, to, promotion });
			if (move) {
				this.moveHistory.update((history) => [...history, { from, to, promotion }]);
				this.updateGameState();
				this.determineMoveType(move);
				return true;
			}
		} catch (error) {
			console.error('Invalid move:', { from, to, promotion }, error);
		}
		return false;
	}

    protected updateGameState() {
		this.fen.set(this.chess.fen());
		this.turn.set(this.chess.turn() === 'w' ? 'white' : 'black');
		this.updateDestinations();
		this.checkState.set(getCheckState(this.chess));
		this.checkGameOver();
	}


  //other methods

}

The base GameState is our chessboard, knowing the rules and managing basic game flow. AIGameState and MultiplayerGameState are the specialized players, each adding their unique flavor:

File:AIGameState.ts

import { get } from 'svelte/store';
import { GameState } from './GameState';
import type { Stockfish } from '../engine/Stockfish';
import type { GameSettings } from '$lib/stores/gameSettings';
import type { Color } from 'chessground/types';
import type { ChessMove } from './types';
import { initializeEngine } from './utils';

export interface AIGameStateOptions {
	player: Color;
	difficulty: number;
	debug: boolean;
}

export class AIGameState extends GameState {
	private engine: Stockfish;
	private difficulty: number;

	constructor({ player, difficulty, debug = false }: AIGameStateOptions) {
		super('pve', player);
		this.difficulty = difficulty;
		this.engine = initializeEngine(this.handleEngineMessage.bind(this), difficulty, debug);
	}

	newGame() {
		super.newGame();
		this.engine.newGame();
		this.engine.setPosition(this.chess.fen());
		if (this.player === 'black') {
			this.triggerAiMove();
		}
	}

	makeMove(move: ChessMove): boolean {
		const result = super.makeMove(move);
		if (result) {
			this.engine.setPosition(this.chess.fen());
			this.triggerAiMove();
		}
		return result;
	}
  
    setDifficulty(difficulty: number) {
		this.difficulty = difficulty;
		this.engine.setDifficulty(difficulty);
	}

	updateSettings(settings: GameSettings) {
		this.player = settings.color!;
		this.setDifficulty(settings.difficulty);
	}

	private triggerAiMove() {
		if (this.player !== get(this.turn)) {
			this.engine.go();
		}
	}

	private handleEngineMessage(message: string) {
		if (message.includes('bestmove')) {
			const { from, to } = this.engine.getBestMove();
			if (get(this.turn) !== this.player) {
				const move = this.isPromotionMove(from, to) ? { from, to, promotion: 'q' } : { from, to };
				this.makeMove(move);
			}
		}
	}


  //other methods

}

File:MultiplayerGameState.ts

import { get, writable, type Writable } from 'svelte/store';
import { GameState } from './GameState';
import type { Color } from 'chessground/types';
import type { ChessMove, GameOver, TimeControl } from './types';
import { WebSocketManager } from '../websocket/WebSocketManager';
import { AddItemToCookies, GetItemFromCookies } from '$lib/utils';
import { PLAYER_ID_EXPIRATION } from '$lib/constants';

export interface MultiplayerGameStateOptions {
	player: Color;
	roomId: string;
}

export class MultiplayerGameState extends GameState {
	private wsManager: WebSocketManager;
	private timer: number | null = null;
	private lastMoveTime: number | null = null;
	private firstMovesMade: { white: boolean; black: boolean } = { white: false, black: false };

	opponentConnected: Writable<boolean> = writable(false);
	isUnlimited: Writable<boolean> = writable(true);
	whiteTime: Writable<number> = writable(0);
	blackTime: Writable<number> = writable(0);
	rematchOffer: Writable<boolean> = writable(false);
	roomId: string;

	constructor({ player, roomId }: MultiplayerGameStateOptions) {
		super('pvp', player);
		this.roomId = roomId;
		const playerId = GetItemFromCookies(`${this.roomId}-playerId`);
		const wsUrl = this.constructWebSocketUrl(player, roomId, playerId);
		this.wsManager = new WebSocketManager(wsUrl);
		this.setupMessageHandlers();
	}

	private constructWebSocketUrl(player: Color, roomId: string, playerId: string | null): string {
		const baseUrl = `${import.meta.env.VITE_API_WS_URL}/game/join?id=${roomId}&color=${player}`;
		return playerId ? `${baseUrl}&playerId=${playerId}` : baseUrl;
	}

	private setupMessageHandlers() {
		this.wsManager.addMessageHandler('connected', (data) => this.handleConnected(data.playerId));
		this.wsManager.addMessageHandler('opponentMove', (data) => this.handleOpponentMove(data.move));
		this.wsManager.addMessageHandler('opponentJoined', () => this.handleOpponentJoined());
		this.wsManager.addMessageHandler('opponentReconnected', () => this.handleOpponentReconnected());
		this.wsManager.addMessageHandler('gameStart', (data) => this.handleGameStart(data));
		this.wsManager.addMessageHandler('gameOver', (data) => this.handleGameOver(data));
		this.wsManager.addMessageHandler('gameState', (data) => this.handleGameState(data));
		this.wsManager.addMessageHandler('rematchOffer', () => this.rematchOffer.set(true));
		this.wsManager.addMessageHandler('rematchAccepted', () => this.handleRematchAccepted());
	}

	newGame() {
		super.newGame();
		this.resetTimer();
		this.startTimer();
	}

  endGame() {
		super.endGame();
		this.stopTimer();
	}

	makeMove(move: ChessMove): boolean {
		const result = super.makeMove(move);
		if (result) {
			this.wsManager.sendMessage({
				type: 'move',
				move: { from: move.from, to: move.to, promotion: move.promotion }
			});
			this.updateTimer();
			this.firstMovesMade[this.player] = true;
			if (!this.timer) {
				this.startTimer();
			}
		}
		return result;
	}

  //other methods

}

This approach made the code more modular and easier to maintain. Updating basic move logic or tweaking AI behavior became as straightforward as upgrading different parts of a chess set independently.

The Multiplayer Gambit: Connecting Kings Across Kingdoms

With our game logic neatly organized, it was time to tackle the multiplayer aspect. Initially, I had my sights set on Cloudflare Workers. They were the new hotness, supporting WebSockets and offering a generous free tier. I even flirted with Hono, a framework to extend Workers' capabilities. But alas, it was not to be. Turns out, making two clients talk to each other was harder than getting a rook and a bishop to mate.

After a brief dalliance with Durable Objects (which, despite their name, proved quite fragile for my free-tier dreams), I pivoted to a good old Express server with the ws library. Sometimes, you've got to retreat to advance.

File:websocket.ts

import WebSocket from 'ws';
import { IncomingMessage } from 'http';
import { URL } from 'url';
import {
	removePlayerFromGame,
	handlePlayerMessage,
	checkGameStart,
	addPlayerToGame,
	reconnectPlayerToGame
} from './game';

interface ConnectionParams {
	id: string;
	color: 'white' | 'black';
	playerId: string | null;
}

export function handleWebSocketConnection(ws: WebSocket, req: IncomingMessage) {
	const params = parseConnectionParams(req);
	if (!params) {
		closeConnection(ws, 1008, 'Invalid game room');
		return;
	}

	console.log(`New connection attempt for game ${params.id}`);
	console.log(`Color: ${params.color}, PlayerId: ${params.playerId}`);

	try {
		const activePlayerId = handlePlayerConnection(ws, params);
		if (!activePlayerId) {
			closeConnection(ws, 1008, 'Unable to join game');
			return;
		}

		setupEventListeners(ws, params.id, activePlayerId);
		checkGameStartStatus(params.id);
	} catch (error) {
		console.error('Error handling WebSocket connection:', error);
		closeConnection(ws, 1011, 'Internal server error');
	}
}

function parseConnectionParams(req: IncomingMessage): ConnectionParams | null {
	const url = new URL(req.url!, `http://${req.headers.host}`);
	const id = url.searchParams.get('id');
	const color = url.searchParams.get('color') as 'white' | 'black';
	const playerId = url.searchParams.get('playerId');

	if (!id) {
		return null;
	}

	return { id, color, playerId };
}

function handlePlayerConnection(ws: WebSocket, params: ConnectionParams): string | null {
	if (!params.playerId) {
		console.log('Adding new player to game');
		const activePlayerId = addPlayerToGame(params.id, params.color, ws);
		if (activePlayerId) {
			ws.send(JSON.stringify({ type: 'connected', playerId: activePlayerId }));
		}
		return activePlayerId;
	} else {
		console.log('Reconnecting existing player');
		const reconnected = reconnectPlayerToGame(params.id, params.playerId, ws);
		return reconnected ? params.playerId : null;
	}
}

function setupEventListeners(ws: WebSocket, gameId: string, playerId: string) {
	ws.on('message', (message: string) => handlePlayerMessage(gameId, playerId, message));
	ws.on('close', () => removePlayerFromGame(gameId, playerId));
}

function checkGameStartStatus(gameId: string) {
	const gameStarted = checkGameStart(gameId);
	if (gameStarted) {
		console.log(`Game ${gameId} started with both players`);
	}
}

function closeConnection(ws: WebSocket, code: number, reason: string) {
	ws.close(code, reason);
}

These functions ensure players join the right game, assigning them colors, reconnecting, and starting the match when both players are ready. It's much like a chess arbiter setting up a tournament match.

The Game Room: Where the Magic Happens

To manage our multiplayer matches, I introduced the GameRoom class – the cornerstone of our multiplayer experience. It handles everything from player connections to rematch offers, ensuring smooth gameplay.

The GameRoom is responsible for:

File:GameRoom.ts

import WebSocket from 'ws';
import { Chess } from 'chess.js';
import { nanoid } from 'nanoid';
import { Player, TimeControl, TimeOption, Color, GameMessage } from './types';

export class GameRoom {
    id: string = nanoid();
    players: Player[] = [];
    gameStarted: boolean = false;
    private chess: Chess = new Chess();
    private currentFen: string = this.chess.fen();
    private currentTurn: Color = 'white';
    private timeControl: TimeControl;
    private lastMoveTime?: number;

    constructor({ time = 0 }: { time: TimeOption }) {
        this.timeControl = this.convertTimeOption(time);
    }

    addPlayer(color: Color, ws: WebSocket): string {
        if (this.players.length >= 2) {
            throw new Error('Game room is full');
        }
        const playerId = nanoid();
        const timeRemaining = this.timeControl.isUnlimited ? null : this.timeControl.initial;
        const player = { id: playerId, color, ws, timeRemaining, connected: true };
        this.players.push(player);

        this.notifyPlayersOfJoin(playerId);

        if (this.players.length === 2) {
            this.startGame();
        }

        return playerId;
    }

    removePlayer(playerId: string) {
		const playerIndex = this.players.findIndex((p) => p.id === playerId);
		if (playerIndex !== -1) {
			this.players[playerIndex].connected = false;
			this.players[playerIndex].ws = null;
		}
		this.broadcastGameState();
	}

	reconnectPlayer(playerId: string, ws: WebSocket): boolean {
		const player = this.findPlayerById(playerId);
		if (!player) {
			return false;
		}

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

		this.sendToPlayer(player, {
			type: 'gameState',
			...this.getCurrentGameState(),
			timeControl: this.timeControl
		});
		this.notifyOpponentOfReconnection(playerId);

		return true;
	}

	handleMessage(playerId: string, message: GameMessage) {
		const player = this.findPlayerById(playerId);
		if (!player) return;

		switch (message.type) {
			case 'move':
				this.handleMove(player, message.move);
				break;
			case 'offerRematch':
				this.handleRematchOffer(playerId);
				break;
			case 'acceptRematch':
				this.handleRematchAccept(playerId);
				break;
			case 'gameOver':
				if (message.reason === 'timeout') {
					this.handleTimeOut(message.winner);
				}
				break;
		}
	}

    private updateGameStateAfterMove(
        playerId: string,
        move: { from: string; to: string; promotion?: string }
    ) {
        this.currentTurn = this.currentTurn === 'white' ? 'black' : 'white';
        this.currentFen = this.chess.fen();
        this.broadcastMove(playerId, move);
        if (!this.timeControl.isUnlimited) {
            this.updatePlayerTime(this.findPlayerById(playerId)!);
        }
        this.checkGameEnd();
    }

    private updatePlayerTime(player: Player) {
		if (player.timeRemaining === null) return;

		const now = Date.now();
		if (this.lastMoveTime) {
			const timeTaken = (now - this.lastMoveTime) / 1000;
			player.timeRemaining -= timeTaken;

			if (player.timeRemaining <= this.timeControl.lowTimeThreshold) {
				player.timeRemaining += this.timeControl.increment;
			}

			if (player.timeRemaining <= 0) {
				this.handleTimeOut(player.color === 'white' ? 'black' : 'white');
			}
		}
		this.lastMoveTime = now;
		this.broadcastGameState();
	}

    private resetPlayerTimes() {
		this.players.forEach((player) => {
			if (player.timeRemaining !== null) {
				player.timeRemaining = this.timeControl.initial;
			}
		});
	}

    // More methods...
}

One of the trickiest parts was implementing reconnection without full-blown authentication. My solution uses session cookies, acting as a secret handshake between the player and the server. It's a bit like trying to rejoin a chess match after a power outage – not perfect, but it gets the job done most of the time.

Time management was another challenge. The GameRoom now keeps track of each player's remaining time, handles increments, and manages timeouts. It’s like having a well-coordinated referee in a tournament, but without the pressure or interruptions. With these pieces in place, Statemates now handles multiplayer games with the precision and smoothness of a perfectly executed chess combinatio

Bonus Moves: Hints and Take-Backs

Because sometimes we all need a little help (and a lot of do-overs), I added hint and undo features for the AI games. It’s like having Levy Rozman whisper suggestions in your ear—minus the excessive rook sacrifices.

File:AIGameState.ts

{

// ... other methods

  async getHint(): Promise<ChessMove | null> {
		if (!this.started || this.player !== get(this.turn)) return null;
		const hintMove = await this.engine.getHint(this.player === 'white' ? 'w' : 'b');
		this.hint.set(hintMove);
		return hintMove;
	}

  undoMove() {
		this.chess.undo();
		this.chess.undo();
		this.moveHistory.update((history) => history.slice(0, -2));
		this.updateGameState();
		this.engine.setPosition(this.chess.fen());
	}

// ... other methods

}

Lessons from the Chessboard

Building Statemates was like playing a long, complex game of chess. Each feature was a move, each bug a counterattack. But in the end, the lessons learned were invaluable:

The Road Ahead: More Pawns to Promote

While Statemates is up and running, there's always room for improvement. Authentication, game history, post-game analysis—these are all on the roadmap. It's like promoting a pawn; the possibilities are endless.

Just like in chess, coding has taught me that every move counts, whether it's a brilliant idea (or should I say brilliant move 😁) or a bug fix. Both games require patience, strategy, and creativity. In both coding and chess, the journey is ongoing, with always something new to learn—whether you're a developer or a chess player.

For now, though, I'm reveling in the satisfaction of a game well played. Statemates may not be the next chess.com, but it's a testament to what one can achieve with a bit of nostalgia, a lot of coding, and an unhealthy obsession with moving pieces on a board.

So, fellow developers and chess enthusiasts, I invite you to check out Statemates. Challenge the AI, invite a friend for a game, and most importantly, let me know if you find any bugs. After all, in chess as in coding, the game is never truly over—there's always another match to be played, another feature to be implemented.

Now, if you'll excuse me, I have a date with an AI that thinks it's Garry Kasparov. Time to show it that in the game of chess, the player is still king... at least until the next update.

Share Project