← All Workshops

MudEngine Part 6: Multiplayer

Step 6 / 12

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:

  1. Generates a unique ID for the new player
  2. Registers the player at room 4 (Town Square) in the PLAYERS map
  3. Subscribes to the broadcast channel
  4. Announces the join to all connected players
  5. Sends the initial full state to the new player
  6. Upgrades the connection and enters a message loop
  7. On movement: validates the direction against EXITS, updates the player's room, broadcasts the update
  8. On disconnect: removes the player and broadcasts PlayerLeft

Add this function after the server state module:

mud-engine/src/main.rs
// ── 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 });
    })
}
🎯 How tokio::select! works

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.

Step 6 / 12