MudEngine Part 6: Multiplayer
WebSocket Endpoint
The heart of the multiplayer server is a Dioxus server function marked with #[get]. It declares a route /api/mud_ws that accepts WebSocket upgrades.
The function receives the player's name as a query parameter and a WebSocketOptions object (the upgrade handler). It returns a Websocket<ClientMessage, ServerMessage>, which is a strongly-typed WebSocket connection.
The server function:
- Generates a unique ID for the new player
- Registers the player at room 4 (Town Square) in the
PLAYERSmap - Subscribes to the broadcast channel
- Announces the join to all connected players
- Sends the initial full state to the new player
- Upgrades the connection and enters a message loop
- On movement: validates the direction against
EXITS, updates the player's room, broadcasts the update - On disconnect: removes the player and broadcasts
PlayerLeft
Add this function after the server state module:
// ── WebSocket endpoint ── #[get("/api/mud_ws?name")] async fn mud_ws( name: String, options: WebSocketOptions, ) -> Result<Websocket<ClientMessage, ServerMessage>> { let id = uuid::Uuid::new_v4().to_string(); let player_name = name.clone(); // 1. Register the new player at the center room { let mut players = srv::PLAYERS.lock().unwrap(); players.insert(id.clone(), PlayerInfo { id: id.clone(), name: name.clone(), room: 4, // Town Square }); } // 2. Subscribe to the broadcast channel before announcing, // so we don't miss our own join event let mut rx = srv::BROADCAST.subscribe(); // 3. Announce the new player to everyone let _ = srv::BROADCAST.send(ServerMessage::PlayerJoined( PlayerInfo { id: id.clone(), name: name.clone(), room: 4 }, )); // 4. Build the initial state snapshot for this new player let initial_state = { let players = srv::PLAYERS.lock().unwrap(); ServerMessage::State { players: players.values().cloned().collect(), your_id: id.clone(), } }; // 5. Upgrade the HTTP connection to a WebSocket options.on_upgrade(move |mut socket| async move { // Send initial state so the client can render immediately let _ = socket.send(initial_state).await; // ── Message loop ── // Multiplex between incoming messages from this client // and broadcast messages from other clients loop { tokio::select! { // A message arrived from THIS client msg = socket.recv() => { match msg { Ok(ClientMessage::Move { direction }) => { // Normalise short direction names let dir = match direction.as_str() { "n" => "north", "s" => "south", "e" => "east", "w" => "west", d => d, }; // Validate the move and update position let new_room = { let mut players = srv::PLAYERS.lock().unwrap(); let player = players.get_mut(&id).unwrap(); let current = player.room; if let Some(exits) = srv::EXITS.get(current) { if let Some(&(_, next)) = exits.iter().find(|(d, _)| *d == dir) { player.room = next; Some(next) } else { None // invalid direction } } else { None } }; // Broadcast the move to EVERY client if let Some(room) = new_room { let _ = srv::BROADCAST.send( ServerMessage::PlayerMoved(PlayerInfo { id: id.clone(), name: player_name.clone(), room, }), ); } } Err(_) => break, // connection closed } } // A broadcast arrived from another player's action msg = rx.recv() => { match msg { Ok(server_msg) => { // Forward the broadcast to this client if socket.send(server_msg).await.is_err() { break; } } Err(_) => break, } } } } // ── Cleanup on disconnect ── srv::PLAYERS.lock().unwrap().remove(&id); let _ = srv::BROADCAST.send(ServerMessage::PlayerLeft { id }); }) }
tokio::select! races two (or more) async operations and runs the arm that completes first. In our case:
- Arm 1 (
socket.recv()) — waits for a message from this particular client - Arm 2 (
rx.recv()) — waits for a broadcast from any other client
Both arms can proceed concurrently. When the player presses a direction button, arm 1 fires — the server validates the move and broadcasts the result. When another player moves, arm 2 fires — the server forwards the broadcast to this client.
This multiplexing is what gives us real-time updates: every client hears about every move almost instantly.
When the client disconnects, socket.recv() returns Err and we break out of the loop, triggering the cleanup code.