I recently started creating a real-time ship combat game where multiple players compete to be the last one standing. The game needs to be playable over LAN, and I would like to create SDKs for developers to write bots to play in addition to human players playing themselves. To achieve this, I first created a Rust client that compiles to WebAssembly (WASM) that humans can play through the browser. This is to make sure the game is fun to play before introducing the complexities of bots. To ensure any future SDKs and the current WASM client stay in sync, I need a specific protocol that all types of clients can use when interacting with the server. This is one way to structure a web socket protocol for game development.
When building many different clients to the same server, there is a risk of behavioral drift between them. A way to mitigate this is to define what the server handles and then what the client is responsible for. For this project, the web socket protocol that the clients and server use to communicate puts rules into the code.
Defining the protocol
The protocol needs to implement 2 rules:
- The client tells the server what actions it would like to take based on the game state.
- The server is in charge of telling the client the new state of the game.
Those rules form the basic game loop and ensure the server has the final say on what actually happens with the game state.
In the code, rule 1 is represented by this enum:
// protocol.rs
// ── Client → Server ──────────────────────────────────────────────────────────
pub enum ClientMsg {
/// The following give the client control of which lobby it is currently playing in.
Join { display_name: String },
Reconnect { session_id: Uuid },
CreateLobby,
JoinLobby { lobby_id: LobbyId },
LeaveLobby,
Ready,
/// Once a client is in-game it can take the following actions.
Input { keys: InputState },
LaunchMissile { target: Vec2 },
SonarPing { target: Vec2 },
}
Those are all the actions any client can take — bot script or human in the browser. For each, the result could be successful or unsuccessful. For example, if the client tries to call JoinLobby on an ID that doesn’t exist, the server wouldn’t connect them to that lobby.
Rule number 2 is represented by a different enum in the same file:
// protocol.rs
// ── Server → Client ──────────────────────────────────────────────────────────
pub enum ServerMsg {
/// Lobby and connection management messages.
Connected { session_id: Uuid },
LobbyList { lobbies: Vec },
LobbyUpdate { lobby: LobbyInfo },
MatchStart { match_id: MatchId, arena: ArenaInfo },
/// Mid match messages
Snapshot {
tick: u64,
ships: Vec,
drones: Vec,
missiles: Vec,
sonar_pings: Vec,
/// Seconds until the observer can launch another missile (0.0 = ready).
missile_cooldown_remaining_secs: f32,
/// Seconds until the observer can fire another sonar ping (0.0 = ready).
sonar_cooldown_remaining_secs: f32,
},
MatchEnd { result: MatchResult },
/// Server-side error message for the client to surface.
Error { message: String },
}
The first section tells the client the connection has succeeded and provides enough information to join a lobby. Once a match has started, the server will broadcast a Snapshot to each client explaining what it is allowed to know about the game and make decisions based on each game tick.
Benefits of this contract
Deserializing on the server and client becomes simpler. The server has already defined the information it needs to receive and what it will send. So the message handling is just a switch in which each branch is one of the messages from the contract.
// server's session.rs
async fn handle_msg(
msg: ClientMsg,
session_id: Uuid,
state: &Arc<Mutex<option>>,
out_tx: &mpsc::Sender,
app: &AppState,
) {
match msg {
ClientMsg::Join { display_name } => {
// Handle client launching and joining the network
}
ClientMsg::Reconnect {
session_id: claimed_id,
} => {
// Handle client reconnect
}
ClientMsg::CreateLobby => {
// Handle client requesting lobby creation
}
ClientMsg::JoinLobby { lobby_id } => {
// Handle client requesting joining lobby
}
ClientMsg::LeaveLobby => {
// Handle client leave
}
ClientMsg::Ready => {
// Handle client ready
}
ClientMsg::Input { keys } => {
// Handle client message
}
ClientMsg::LaunchMissile { target } => {
// Handle missile launch
}
ClientMsg::SonarPing { target } => {
// Handle sonar ping
}
}
}
The client has a mirrored message handler. Notice that the client only needs to handle messages from the server-to-client section of the protocol. If we give clients the ability to send new messages, we only need to update the server side.
// Clients lib.rs
fn handle_server_message(
app: Rc<RefCell>,
document: &Document,
message: ServerMsg,
) -> Result<(), JsValue> {
match message {
ServerMsg::Connected { session_id } => {
// Save the session ID to the browser and request available lobbies
}
ServerMsg::LobbyList { lobbies } => {
// render the list of lobbies
}
ServerMsg::LobbyUpdate { lobby } => {
// update the local rendering of the current lobby
}
ServerMsg::MatchStart { match_id, arena } => {
// render the game board and start playing
}
ServerMsg::Snapshot {
tick,
ships,
drones,
missiles,
sonar_pings,
missile_cooldown_remaining_secs,
sonar_cooldown_remaining_secs,
} => {
// render the game board and/or call logic that interprets it
}
ServerMsg::MatchEnd { result } => {
// render join lobby screen and show result
}
ServerMsg::Error { message } => {
debug_log("server_error", || message.clone());
toast(document, &message, "error")?;
}
}
Ok(())
}
Risks to this pattern
Confusion can arise if the server or the client starts doing the other’s job. One bug I’ve already found was when the player’s browser inputs were not correctly being reflected in subsequent game states. Initially, I thought it was a server bug, but eventually found that the client was trying to validate if the action was legal. Since the client could only see the last snapshot it received, it couldn’t compensate for what the other clients might do. So it was incorrectly blocking players from calling the LaunchMissile action. This was not enforced by the contract but was a result of the client not following rule number 2. The fix was to send the LaunchMissile action regardless of whether it was legal and let the server say no.
Another potential risk is bloat entering the protocol. Depending on how complex the game gets, it might become a real hassle to keep track of the types of messages that can be sent and their arguments.
When I would use this pattern
I would use this when I want to moderate the communication between a client and server over a shared channel. It draws significant inspiration from API design outside of game development and helps maintain the extensible architecture I wanted by enabling different types of clients to follow the same communication rules with the server.