Skip to main content

Let's build our own online multiplayer chess game


Greetings!
Chess is a hobby I enjoy, and as a chess fan, I often play online at chess.com. But you know what's even more thrilling? Creating our very own online chess game! I'm excited to share my experience with anyone interested!

Source Code: GitHub (slmanju)

Final result (using two browser windows)

Chess game

Chess is a complex board game played by two players. When a player makes a valid move we need to make it appear on other players board. What else, pieces, rules, communication, and lots of complex things are happening. I don't want to reinvent such complex things when things are free.

Aha... the free stuff

It's challenging to create all the necessary elements from scratch. Why go through the trouble when there are plenty of excellent free resources readily available? Let's make use of what's already out there.

Chess notation

Before moving on, there's one more special thing to mention. If you're not a die-hard chess fan, you might not know that any chess position can be represented in a single line. This representation is called FEN, and it follows a standard format. (FEN)

Setup the project

This is a node.js project and I assume you are familiar with this setup. 


Folder structure:

Let's start with the server

Rather than direct client-to-client communication, we rely on a server to handle communication. In a game, there are various aspects where the server needs to intervene.
  • Move validation
  • Cheat detection
  • Save game history
  • And many more

While we may not implement all these features, we leverage Node.js and Socket.IO for efficient communication.
app.js
import express from "express";
import path from "path";
import cors from "cors";
import http from "http";
import wsChess from "./ws-chess.js";

const __dirname = path.resolve();
const PORT = process.env.PORT || 3000;

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cors());

app.use('/public', express.static(path.join(__dirname, 'public')));

app.use("/api", (_req, res) => res.send({ text: "Hello World" }));

const server = http.createServer(app);
server.listen(PORT, () => console.log(`Application started on port ${PORT}`));

wsChess(server);
ws-chess.js
import { Server } from "socket.io";
import { v4 as uuidv4 } from "uuid";

let socketIO = null;
const players = [];

const findPlayer = (name) => players.find((player) => player.username === name);

const onUserConnected = (socket) => (data) => {
  players.push({ username: data.userName, socketId: socket.id });

  socketIO.emit("players", players);
};

const onChallenge = (data) => {
  const challenger = findPlayer(data.from);
  const challengee = findPlayer(data.to);
  const message = {
    gameId: uuidv4(),
    white: challenger.username,
    player1: challenger.username,
    player2: challengee.username,
  };
  socketIO.to(challenger.socketId).emit("gameStart", message);
  socketIO.to(challengee.socketId).emit("gameStart", message);
};

const onMove = (data) => {
  const player = findPlayer(data.to);
  socketIO.to(player.socketId).emit("move", data.fen);
};

const onConnect = (socket) => {
  socket.on("userConnected", onUserConnected(socket));

  socket.on("challenge", onChallenge);

  socket.on("move", onMove);
};

const wsChess = (server) => {
  socketIO = new Server(server);

  socketIO.on("connection", onConnect);
};

export default wsChess;
Let me explain the socket piece a bit here.
  • on:connection - initiate the socket connection.
  • on:userConnected - when a new player is joined, the username and the connected socket_id are saved in an array. After that, emit an event to notify online events to everyone.
  • on:challenge - when a new challenge is created, this will listen to it. Without any special logic, challenge is accepted by the other player :)
  • on:move - when a player makes a move, it is notified to the other player.
  • emit:players - notify everyone with an array of online players.
  • emit:gameStart - emit the event to start the game for both the challenger and the challengee.
  • emit:move - notify one player's move to the other.

Connect the client

Why reinvent the wheel? I copied the example from the official documentation of chessboard.js and adjusted the server communication logic accordingly. I'm using the game as the mediator to keep the board and WebSocket logic isolated.


I believe the code is straightforward. However, one challenge I encountered was the need to update both the board and chess.js for incoming moves, ensuring rule validation doesn't fail.

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <title>Chess</title>

    <link rel="stylesheet" href="/public/css/chessboard-1.0.0.min.css" />
    <link rel="stylesheet" href="/public/css/node-chess.css" />
  </head>
  <body>
    <div id="players" class="players">
      <div>Players online</div>
    </div>
    <div id="chess-board" class="chess-board"></div>

    <script src="/public/lib/jquery-3.6.4.min.js"></script>
    <script src="/public/lib/chessboard-1.0.0.min.js"></script>
    <script src="/public/lib/chess.min.js"></script>
    <script src="/socket.io/socket.io.js"></script>

    <script src="/public/js/players.js"></script>
    <script src="/public/js/board.js"></script>
    <script src="/public/js/ws.js"></script>
    <script src="/public/js/game.js"></script>
    <script src="/public/ws-chess.js"></script>
  </body>
</html>
node-chess.css
body {
  overflow-y: auto;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #2a2438;
}

.chess-board {
  width: 400px;
  margin-top: 50px;
}

ul {
  padding-left: 0;
  list-style-type: none;
}

ul li {
  padding-left: 0;
}

.players {
  margin: 50px;
  padding: 20px 50px;
  background-color: white;
}
players.js
Just a simple DOM manipulation.
const displayPlayers = (me, players, fn) => {
  const playerList = document.createElement("ul");
  playerList.setAttribute('id', 'players-ul');

  players.forEach((player) => {
    const listItem = document.createElement("li");
  
    if (player.username === me) {
      listItem.textContent = player.username + " (you)";
    } else {
      const button = document.createElement("button");
      button.textContent = "Play";
      button.onclick = function () {
        fn({ from: me, to: player.username });
      };
      listItem.textContent = player.username + " ";
      listItem.appendChild(button);
    }
    playerList.appendChild(listItem);
  });

  const parent = document.getElementById("players");
  const child = document.getElementById("players-ul");
  if (child) {
    parent.removeChild(child);
  }
  parent.appendChild(playerList);
};
game.js
This is used as the mediator (pattern) to keep the board and the socket connection isolated.
const game = (ws, board) => {
  const onGameStart = (data) => board.startGame(data);

  const onMove = (data) => board.onMove(data);

  const onPlayerMove = (data) => ws.onMove(data);

  ws.initiate(onGameStart, onMove);
  board.initiate(onPlayerMove);
};
ws.js
I believe now this is clear. One of the good things about socket.io is, that it uses the same notation for both server and the client. 
const connectWs = (userName, onPlayersFn) => {
  const socket = io("http://localhost:3000");

  let onGameStartFn = null;
  let onMoveFn = null;

  const initiate = (startFn, moveFn) => {
    onGameStartFn = startFn;
    onMoveFn = moveFn;
  };

  const onChallenge = (players) => socket.emit("challenge", players);

  const onMove = (data) => socket.emit("move", data);

  socket.on("connect", () => socket.emit("userConnected", { userName }));

  socket.on("players", (data) => onPlayersFn(userName, data, onChallenge));

  socket.on("gameStart", (data) => onGameStartFn(data));

  socket.on("move", (data) => onMoveFn(data));

  return { initiate, onMove };
};
board.js
This is the largest code but is a copy of the official example. I modified it for server communication. I have added inline comments just in case.
let initBoard = (username) => {
  let board = null;
  let engine = new Chess();
  let fn = null;
  let gameData = null;
  let turn = null;

  let gameOver = () => engine.game_over();
  let illegalWhiteMove = (piece) => gameData.color === "white" && piece.search(/^b/) !== -1;
  let illegalBlackMove = (piece) => gameData.color === "black" && piece.search(/^w/) !== -1;

  function onDragStart(_source, piece, _position, _orientation) {
    if (gameOver() || turn === -1 || illegalWhiteMove(piece) || illegalBlackMove(piece)) {
      return false;
    }
  }

  function onDrop(source, target) {
    let move = engine.move({
      from: source,
      to: target,
      promotion: "q", // NOTE: always promote to a queen for example simplicity
    });

    // illegal move
    if (move === null) return "snapback";

    turn = -1;
    // notify the opponent with the move, uses fen to communicate the position.
    fn({
      fen: engine.fen(),
      from: username,
      to: gameData.player1 === username ? gameData.player2 : gameData.player1
    });
  }

  // update the board position after the piece snap
  // for castling, en passant, pawn promotion
  function onSnapEnd() {
    board.position(engine.fen());
    notifyGameOver();
  }

  const notifyGameOver = () => {
    if (gameOver()) {
      alert("Game is over");
    }
  }

  const initiate = (cb) => {
    fn = cb;
  };

  const startGame = (data) => {
    const color = (username === data.white) ? "white" : "black";
    turn = color === "white" ? 1 : -1;

    gameData = {
      ...data,
      color,
    };

    // configure the board with start position
    let config = {
      position: "start",
      orientation: color,
      draggable: true,
      onDragStart,
      onDrop,
      onSnapEnd,
      pieceTheme: "/public/images/pieces/{piece}.svg",
    };
  
    board = Chessboard("chess-board", config);
  }

  // listen to opponent's move, makesure to update both board and the engine.
  const onMove = (data) => {
    board.position(data);
    engine.load(data);
    turn = 1;
    notifyGameOver();
  };

  return {
    initiate,
    startGame,
    onMove
  };
};
ws-chess.js
This connects all client-side functions.
let username = null; // localStorage.getItem("username");

if (username === null) {
  username = prompt("Enter username");
  // localStorage.setItem("username", username);
}

if (username) {
  const ws = connectWs(username, displayPlayers);
  const board = initBoard(username);
  game(ws, board);
}
Start the game using the node command.
node src/app.js
No fancy UIs, just a prompt to accept the username ☺

Conclusion

In this article, we successfully built an online chess game using freely available resources. While this small program works perfectly, developing a production-ready application involves considering scalability, availability, and other architectural components. Perhaps, we can delve into those aspects in a future discussion. Happy playing, everyone!

Comments