David Reis

What happens when you make a move in lichess.org?

September 23, 2024 (1m ago)

Have you ever wondered what goes on behind the scenes every time you make a move in your favorite online chess platform?

Lichess.org is a popular, free, and open-source chess platform that attracts millions of players worldwide. Its seamless, real-time gameplay experience must be powered by a robust backend infrastructure. In this post, we'll peek behind the curtain and explore the technical processes involved when you play a move.

Inspecting Network Activity with Chrome DevTools

To understand the flow of data when making a move, we'll start by utilizing Chrome DevTools, particularly the Network tab, which allows us to monitor communication between the client (your browser) and the server.

WebSocket Connections

The first notable network activity is a WebSocket connection to a URL similar to:

wss://socket2.lichess.org/play/H5uHz0egyvIA/v6?sri=bt6QzcyOiZg5&v=0

The protocol, wss, indicates an encrypted websockets connection using TLS.

No surprises here, WebSockets are the obvious choice for real-time browser apps like online chess because they allow for full-duplex communication, enabling instant updates between the client and server without the overhead of repeated HTTP requests.

A few notable parameters in the URL:

Let's observe the network activity when we make a move.

Local Player's Turn

When we make a move, several packets of data are exchanged. Here's the first packet, sent by us:

// Sent at 22:51:35.280
{ 
  "t": "move",
  "d": {
    "u": "d2d4",
    "l": 32,
    "a": 1
  }
}

Clear enough, right? Let's break it down:

We then receive the following message:

// Received at 22:51:35.312 
{ 
  "t": "ack",
  "d": 1
}

This message from the server acknowledges that it has received our move. Notice that the d field contains the same acknowledgment counter we sent in our move message, in this case, 1.

Right after, we receive the following message:

// Received at 22:51:35.312 
{
  "t": "move",
  "v": 1,
  "d": {
    "uci": "d2d4",
    "san": "d4",
    "fen": "rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR",
    "ply": 1,
    "clock": {
      "white": 300,
      "black": 300
    }
  }
}

This message provides details about the move we just made and the updated game state.

In summary, when a move is made, the client sends a move message, receives an acknowledgment, and then receives a detailed update about the move and the game's new state.

Opponent's Turn

When the opponent makes a move, we receive a similar packet from the server.

// Received at 22:51:43.489 
{
  "t": "move",
  "v": 2,
  "d": {
    "uci": "d7d5",
    "san": "d5",
    "fen": "rnbqkbnr/ppp1pppp/8/3p4/3P4/8/PPP1PPPP/RNBQKBNR",
    "ply": 2,
    "dests": {
      "c2": "c3c4",
      "g2": "g3g4"
      // .. additional possible moves
    },
    "clock": {
      "white": 300,
      "black": 300
    }
  }
}

One key difference is the dests parameter, which lists all possible moves available from the current position. This parameter is used to highlight the possible moves on the chessboard that we can make after our opponent's move.

While these moves could be calculated client-side, providing them server-side ensures consistency - especially for complex or esoteric chess variants - and optimizes performance on clients with limited processing capabilities or energy restrictions.

Now that we've analysed the network activity, let's look into the backend architecture that powers these real-time interactions.

Lichess's Architecture

Lichess's real-time playing system is primarily composed of two main services (both written in Scala):

  1. lila: Core service that manages game logic, state, user interactions, and other fundamental functionalities. Without it, the lichess.org website would be unable to function.
  2. lila-ws: Specialized service responsible for handling WebSocket connections, acting as a bridge between the client and lila. This service knows as little as possible about chess.

Architecture Overview

From the lila-ws readme:

lila <-> redis <-> lila-ws <-> websocket <-> client

As this implies, lila communicates with lila-ws through Redis, which in turn manages WebSocket connections with clients.

If lila is momentarily down, lila-ws can still handle WebSocket connections and maintain real-time communication with clients (perhaps features like chat would still work!). Conversely, if lila-ws is down, the lichess.org website will still be online, but you won't be able to play games.

This separation also allows for independent scaling of the two components.

Communication using Redis Pub/Sub

The move event is published to a Redis Pub/Sub channel, to which lila is subscribed and processes the move.

Redis Pub/Sub offers at-most-once delivery. This means that each message is delivered no more than once to each subscriber, if at all. If a subscriber fails while processing a message, that message is lost and not re-delivered. This has the benefit that once a message is delivered, it can be removed from Redis, reducing memory usage. However, it also means that message loss is possible.

Redis Streams could be used to provide at-least-once delivery, ensuring that messages are not lost even if a subscriber fails. However, this would increase memory usage and complexity.

Eventual Data Persistence with MongoDB

While lila primarily stores game states in MongoDB, it optimizes database load by not saving every single move immediately. Instead, it buffers progress by accumulating moves and periodically saving them unless a significant event occurs, such as a game conclusion. This strategy lightens the load on the database while maintaining data integrity.

def save(progress: Progress): Funit =
  // Saves the latest game state to a variable. Used for basing the following moves on.
  set(progress.game)
  
  // Prepares the dirty progress to be saved to the database.
  dirtyProgress = dirtyProgress.fold(progress.dropEvents)(_.withGame(progress.game)).some
  
  // If there are special events, such as a resignation, the game state is flushed.
  if shouldFlushProgress(progress) then flushProgress() 
  
  // Otherwise, the game state is scheduled to be stored to the DB after a delay.
  else fuccess(scheduleFlushProgress())

Joining a Game In Progress

As mentioned earlier, when a player connects, they provide the v parameter, which tells the system the latest version of the game they know about. Since the game state in MongoDB syncs up eventually (and your opponent might move just as you join), a player might initially get a state that's a bit behind the latest action.

To handle this, lila-ws uses a trusty java.util.concurrent.ConcurrentHashMap:

This setup keeps track of all events for any ongoing game and clears them once the game wraps up. It helps players reconnect to an active game by giving them all the events they need from point v onwards, without missing any or doubling up. Understandably, it has to be a concurrency-safe data structure, since multiple threads can be serving multiple players at the same time.

Wrapping Up

We are now ready to sum up the process of making a move in Lichess:

  1. Client Connection

    The client establishes a WebSocket connection to lila-ws at a URL like wss://socket2.lichess.org/play/....

  2. Sending a Move

    When a player makes a move, the client sends a move event to lila-ws with details like the move's UCI string and acknowledgment counter.

  3. Acknowledgment

    lila-ws responds with an acknowledgment (ack) to confirm that the move has been received.

  4. Publishing to Redis

    The move event is published to a Redis Pub/Sub channel, to which lila is listening and processes the move.

  5. Updating Game State

    lila receives the move, updates the game state accordingly, and (eventually) stores it in a MongoDB database. An updated game state is then sent back through lila-ws to the client.

  6. Client Receives Update

    The client receives the updated game state, reflecting the new move and any changes in the game's status.

Thank you for reading! If you have any questions or further insights, feel free to reach out.

1. ^ UCI (Universal Chess Interface): A standard format for encoding chess moves, useful for computer processing. Learn more.

2. ^ SAN (Standard Algebraic Notation): A human-readable format for chess moves. Compared to UCI, this move requires chess knowledge, since it doesn't specify the piece moved. Learn more.

3. ^ FEN (Forsyth-Edwards Notation): Encodes the entire game state, including piece positions, turn order, castling rights, and more. Learn more.