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.
Architecture Overview
Section titled “Architecture Overview”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.
Technology Stack
Section titled “Technology Stack”- 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.
Game Flow
Section titled “Game Flow”1. Creating a Game
Section titled “1. Creating a Game”The host clicks Multiplayer and enters their display name. The client emits a create_game event with the name. The server:
- Generates a unique 6-character room code
- Creates a room and adds the host as the first player
- Responds with
game_created(code)so the host can share the code
2. Joining a Game
Section titled “2. Joining a Game”The joiner enters the room code and their display name. The client emits join_game(code, name). The server:
- Looks up the room by code
- Adds the joiner as the second player
- Sends
game_joined(name)to both players — each receives the other’s display name
3. Playing
Section titled “3. Playing”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_moveevent to the opponent. Both players also receive their own moves back as confirmation.
4. Game-End Events
Section titled “4. Game-End Events”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
5. Rematch
Section titled “5. Rematch”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
Section titled “Room Codes”Room codes are 6 characters, formatted as XX-XX-XX for readability when sharing verbally. The character set excludes visually ambiguous characters:
- No
0orO(zero vs. letter O) - No
1,I, orL(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.
Heartbeat System
Section titled “Heartbeat System”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:
- Each client sends a
heartbeatevent to the server every 5 seconds - The server acknowledges the heartbeat and forwards it to the opponent as
peer_heartbeat - The client tracks when it last received a
peer_heartbeatfrom the opponent - 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.
Room Cleanup
Section titled “Room Cleanup”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.
Deployment
Section titled “Deployment”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.
Testing Locally
Section titled “Testing Locally”To test multiplayer during development:
-
Clone and run the relay:
Terminal window git clone https://github.com/DarrellThomas/en-parlant-relay.gitcd en-parlant-relaycargo runThe server starts on port 3210.
-
In En Parlant~, change the relay server URL to
ws://localhost:3210. -
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.