Skip to content

Multiplayer Architecture

En Parlant~ multiplayer uses a WebSocket relay server to connect two players in real time. There’s no peer-to-peer — all communication goes through the relay. This keeps the networking simple and works reliably across firewalls and NATs.

Player A (host) Relay Server Player B (joiner)
| | |
|--- create_game(name) ----------->| |
|<-- game_created(code) -----------| |
| |<--- join_game(code, name) -----|
|<-- game_joined(name) ------------|--- game_joined(name) -------->|
| | |
|--- game_move(uci, times) ------->|--- game_move(uci, times) --->|
|<-- game_move(uci, times) --------|<-- game_move(uci, times) ----|
| | |
|--- heartbeat ------------------>|--- peer_heartbeat ----------->|

The relay is a thin forwarding layer. It holds rooms in memory, routes events between the two players in each room, and handles cleanup. There’s no game logic or move validation on the server — the clients are authoritative.

  • Frontend (client): socket.io-client — the standard Socket.IO JavaScript client
  • Backend (relay): socketioxide — a Rust Socket.IO server built on Axum
  • Protocol: Socket.IO over WebSocket (with automatic fallback to HTTP long-polling if needed)

Socket.IO was chosen over raw WebSocket because it provides automatic reconnection, room/namespace management, and structured event handling out of the box.

The host clicks Multiplayer and enters their display name. The client emits a create_game event with the name. The server:

  1. Generates a unique 6-character room code
  2. Creates a room and adds the host as the first player
  3. Responds with game_created(code) so the host can share the code

The joiner enters the room code and their display name. The client emits join_game(code, name). The server:

  1. Looks up the room by code
  2. Adds the joiner as the second player
  3. Sends game_joined(name) to both players — each receives the other’s display name

Once both players are in the room:

  • Host always plays White, joiner always plays Black. This is determined by join order, not negotiation.
  • Moves are sent as UCI format (e.g., e2e4) along with clock times for both players.
  • The relay forwards each game_move event to the opponent. Both players also receive their own moves back as confirmation.

Several events handle end-of-game scenarios:

  • Resign — forwarded to the opponent to trigger the resignation dialog
  • Draw offer — forwarded to the opponent, who can accept or ignore
  • Draw accept — forwarded to both players to end the game as a draw

After a game ends, either player can send a ready event to signal they want a rematch. When both players have sent ready, the clients reset the board and swap colors (or keep them — determined client-side).

Room codes are 6 characters, formatted as XX-XX-XX for readability when sharing verbally. The character set excludes visually ambiguous characters:

  • No 0 or O (zero vs. letter O)
  • No 1, I, or L (one vs. capital I vs. capital L)

This avoids the “is that a zero or an O?” problem when someone reads a code over voice chat or types it from a friend’s message.

Multiplayer connections need to detect when a player disconnects — whether from a network drop, closing the app, or their laptop going to sleep. En Parlant~ uses a heartbeat system for this:

  1. Each client sends a heartbeat event to the server every 5 seconds
  2. The server acknowledges the heartbeat and forwards it to the opponent as peer_heartbeat
  3. The client tracks when it last received a peer_heartbeat from the opponent
  4. The isPeerAlive(timeoutMs) function checks whether the opponent’s last heartbeat is within the acceptable threshold

This drives the connection status indicator in the UI. If heartbeats stop arriving, the player sees that their opponent may have disconnected, and can choose to wait or leave the game.

The relay server automatically removes idle rooms to prevent memory leaks:

  • A room is considered idle after 30 minutes with no activity
  • A cleanup task runs every 60 seconds, sweeping any rooms that have exceeded the idle threshold
  • When a room is cleaned up, any remaining connections are dropped

There is no persistent storage. If the server restarts, all rooms are gone. This is intentional — the relay is stateless and ephemeral. Games in progress would need to be restarted, but this is rare in practice.

The default relay runs on Fly.io, providing low-latency WebSocket connections with automatic TLS. See the Multiplayer Server setup guide for instructions on running your own relay.

To test multiplayer during development:

  1. Clone and run the relay:

    Terminal window
    git clone https://github.com/DarrellThomas/en-parlant-relay.git
    cd en-parlant-relay
    cargo run

    The server starts on port 3210.

  2. In En Parlant~, change the relay server URL to ws://localhost:3210.

  3. Open two instances of the app (or one app and one in dev mode) to simulate both players.

Moves, heartbeats, and all events work identically to the production relay — the only difference is the connection URL.