A full-stack chess platform using SvelteKit, WebSockets, and the Stockfish engine, featuring adjustable AI difficulty, and real-time multiplayer.
Those summer days in Khartoum were filled with the rhythm of lessons, sports, and quiet moments at the chessboard. Chess wasn't just an activity; it was a mental escape, a strategic battle sandwiched between English classes and the heat of the afternoon sun. I didn't know it then, but those early moves were laying the foundation for something much bigger.
Decades later, the game called me back, but this time with a new kind of challenge: could I build my own chess platform?
This project was more than just an exercise in nostalgia. It was a technical gauntlet I set for myself. I wanted to dive deep into the complexities of chess engines, figure out the nuances of real-time multiplayer with WebSockets, and build the intricate game logic that makes it all work. It was a chance to test my skills against a classic and well-defined problem.
As a developer who spends most of my time with React and Next.js, my first instinct was to stick with what I knew. But to really challenge myself, I decided to step outside my comfort zone and revisit SvelteKit. I was looking for a framework that felt lean and fast, and Svelte's compiler-based approach felt like the right fit for a project that would involve a lot of state management on the client.
Of course, a chess platform is more than just a framework. I identified several key components I would need to either build or find:
After researching my options, I landed on this tech stack:
A full-stack chess platform using SvelteKit, WebSockets, and the Stockfish engine, featuring adjustable AI difficulty, and real-time multiplayer.
Those summer days in Khartoum were filled with the rhythm of lessons, sports, and quiet moments at the chessboard. Chess wasn't just an activity; it was a mental escape, a strategic battle sandwiched between English classes and the heat of the afternoon sun. I didn't know it then, but those early moves were laying the foundation for something much bigger.
Decades later, the game called me back, but this time with a new kind of challenge: could I build my own chess platform?
This project was more than just an exercise in nostalgia. It was a technical gauntlet I set for myself. I wanted to dive deep into the complexities of chess engines, figure out the nuances of real-time multiplayer with WebSockets, and build the intricate game logic that makes it all work. It was a chance to test my skills against a classic and well-defined problem.
As a developer who spends most of my time with React and Next.js, my first instinct was to stick with what I knew. But to really challenge myself, I decided to step outside my comfort zone and revisit SvelteKit. I was looking for a framework that felt lean and fast, and Svelte's compiler-based approach felt like the right fit for a project that would involve a lot of state management on the client.
Of course, a chess platform is more than just a framework. I identified several key components I would need to either build or find:
After researching my options, I landed on this tech stack:
With the pieces selected, I was ready to start building.
Integrating the svelte-chessground component with chess.js for move validation was fairly straightforward. The real challenge began when I introduced Stockfish.
The official Stockfish engine is written in C++, which doesn't naturally run in a browser's JavaScript environment. Thankfully, the open-source community maintains JavaScript ports of the engine, which are compiled to run efficiently in a Web Worker. This prevents the engine's intense calculations from freezing the user's browser tab.
To manage the communication with the engine, I created a Stockfish class. This class acts as a wrapper, sending commands like uci, isready, and go to the engine in the worker and handling the asynchronous messages that come back.
Once the engine was running, I faced the next problem: it was brutally, inhumanly strong. Even at its lowest setting, it played with a precision that would crush most casual players, which wasn't the experience I wanted to create. The solution was to build a system that could dynamically adjust Stockfish’s parameters to create a more natural difficulty curve. I exposed several of the engine's internal settings, like skill level, calculation depth, and "contempt", and mapped them to a simple 1-20 difficulty scale.
The most important tweak for user experience was adding a slight, artificial delay to the AI's moves at lower difficulties. Nothing makes an AI feel more human than pretending it needs a moment to think.
As the application grew, the code that managed the game's state became increasingly complex. I had a single, monolithic GameState class that was trying to handle everything: single-player logic, AI interactions, and real-time multiplayer events. It was quickly becoming difficult to manage and debug.
It was time for a strategic refactor. I decided to apply a classic object-oriented programming pattern. I broke down the monolithic class into a more logical structure:
This new structure made the codebase far more modular and maintainable. If I needed to tweak the AI's behavior, I could do it in AIGameState without any risk of breaking the multiplayer logic, and vice versa.
With the client-side logic organized, I turned my attention to the multiplayer server. Initially, I was drawn to modern, serverless solutions like Cloudflare Workers. They support WebSockets and have a generous free tier, which seemed perfect for a personal project. I even started building with Hono, a framework designed for these edge environments.
However, I soon ran into a significant roadblock. While Workers are excellent for client-to-server communication, orchestrating communication between two clients in a stateful game room proved complicated. The recommended solution, Durable Objects, was powerful but fell outside the scope and budget I had in mind for this project.
After weighing my options, I pivoted to a more traditional but highly effective solution: a simple Express server using the battle-tested ws library for WebSocket management. It was a pragmatic decision that gave me direct control over the game rooms and player connections, which was exactly what the project needed.
On the server, the centerpiece of the multiplayer logic is the GameRoom class. This class is responsible for everything that happens within a single match, from the moment the first player joins until a winner is decided.
Its key responsibilities include:
One of the trickier parts was handling player reconnections without a full authentication system. The solution was to use a session cookie stored on the client. When a player joins a room, the server sends them a unique playerId. If their connection drops, they can rejoin the same game room with that ID, allowing the server to restore their session.
Time management was another critical feature. The GameRoom class precisely tracks each player's remaining time, subtracting the time taken for each move and adding the configured increment. This server-side authority ensures fair and accurate timing, even with potential network lag.
To make the single-player mode more user-friendly, I added "hint" and "undo" features. The hint button simply asks the Stockfish engine for its best move and displays it on the board. The undo feature reverses the last two moves of the player and the AI, and updates the engine with the previous board state. These small additions make playing against the formidable AI a much more forgiving and enjoyable learning experience.
Building Statemates was a long and rewarding game. Each feature was a new puzzle, and every bug was an unexpected counter-move. The biggest takeaways for me were:
While the core features are complete, a project like this is never truly finished. I have a long roadmap of features I'd like to add, including user authentication, saved game history, and post-game analysis with Stockfish.
This project taught me that coding and chess have a lot in common. They both require patience, strategic thinking, and a willingness to learn from your mistakes. For now, I'm happy with the result—a testament to what can be built with a bit of nostalgia and a lot of code.
I invite you to check out Statemates. Challenge the AI or invite a friend for a game. And if you find any bugs, please let me know. After all, the game is never truly over; there's always another move to make.
With the pieces selected, I was ready to start building.
Integrating the svelte-chessground component with chess.js for move validation was fairly straightforward. The real challenge began when I introduced Stockfish.
The official Stockfish engine is written in C++, which doesn't naturally run in a browser's JavaScript environment. Thankfully, the open-source community maintains JavaScript ports of the engine, which are compiled to run efficiently in a Web Worker. This prevents the engine's intense calculations from freezing the user's browser tab.
To manage the communication with the engine, I created a Stockfish class. This class acts as a wrapper, sending commands like uci, isready, and go to the engine in the worker and handling the asynchronous messages that come back.
Once the engine was running, I faced the next problem: it was brutally, inhumanly strong. Even at its lowest setting, it played with a precision that would crush most casual players, which wasn't the experience I wanted to create. The solution was to build a system that could dynamically adjust Stockfish’s parameters to create a more natural difficulty curve. I exposed several of the engine's internal settings, like skill level, calculation depth, and "contempt", and mapped them to a simple 1-20 difficulty scale.
The most important tweak for user experience was adding a slight, artificial delay to the AI's moves at lower difficulties. Nothing makes an AI feel more human than pretending it needs a moment to think.
As the application grew, the code that managed the game's state became increasingly complex. I had a single, monolithic GameState class that was trying to handle everything: single-player logic, AI interactions, and real-time multiplayer events. It was quickly becoming difficult to manage and debug.
It was time for a strategic refactor. I decided to apply a classic object-oriented programming pattern. I broke down the monolithic class into a more logical structure:
This new structure made the codebase far more modular and maintainable. If I needed to tweak the AI's behavior, I could do it in AIGameState without any risk of breaking the multiplayer logic, and vice versa.
With the client-side logic organized, I turned my attention to the multiplayer server. Initially, I was drawn to modern, serverless solutions like Cloudflare Workers. They support WebSockets and have a generous free tier, which seemed perfect for a personal project. I even started building with Hono, a framework designed for these edge environments.
However, I soon ran into a significant roadblock. While Workers are excellent for client-to-server communication, orchestrating communication between two clients in a stateful game room proved complicated. The recommended solution, Durable Objects, was powerful but fell outside the scope and budget I had in mind for this project.
After weighing my options, I pivoted to a more traditional but highly effective solution: a simple Express server using the battle-tested ws library for WebSocket management. It was a pragmatic decision that gave me direct control over the game rooms and player connections, which was exactly what the project needed.
On the server, the centerpiece of the multiplayer logic is the GameRoom class. This class is responsible for everything that happens within a single match, from the moment the first player joins until a winner is decided.
Its key responsibilities include:
One of the trickier parts was handling player reconnections without a full authentication system. The solution was to use a session cookie stored on the client. When a player joins a room, the server sends them a unique playerId. If their connection drops, they can rejoin the same game room with that ID, allowing the server to restore their session.
Time management was another critical feature. The GameRoom class precisely tracks each player's remaining time, subtracting the time taken for each move and adding the configured increment. This server-side authority ensures fair and accurate timing, even with potential network lag.
To make the single-player mode more user-friendly, I added "hint" and "undo" features. The hint button simply asks the Stockfish engine for its best move and displays it on the board. The undo feature reverses the last two moves of the player and the AI, and updates the engine with the previous board state. These small additions make playing against the formidable AI a much more forgiving and enjoyable learning experience.
Building Statemates was a long and rewarding game. Each feature was a new puzzle, and every bug was an unexpected counter-move. The biggest takeaways for me were:
While the core features are complete, a project like this is never truly finished. I have a long roadmap of features I'd like to add, including user authentication, saved game history, and post-game analysis with Stockfish.
This project taught me that coding and chess have a lot in common. They both require patience, strategic thinking, and a willingness to learn from your mistakes. For now, I'm happy with the result—a testament to what can be built with a bit of nostalgia and a lot of code.
I invite you to check out Statemates. Challenge the AI or invite a friend for a game. And if you find any bugs, please let me know. After all, the game is never truly over; there's always another move to make.
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
}
{
/**
* 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
}
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);
}
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...
}
{
// ... 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
}
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
}
{
/**
* 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
}
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);
}
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...
}
{
// ... 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
}
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
}
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
}