MudEngine Part 6: Multiplayer
Game Component — WebSocket Client
The Game component is where the multiplayer action lives. It:
- Calls
use_websocketto create a handle connected to our server endpoint - Spawns a
use_futurethat listens for incoming messages and updates aplayers: Signal<HashMap<String, PlayerInfo>> - Renders the grid (using the
RoomCellcomponent from Part 5 with a newplayersprop), sidebar, and D-pad (usingButtonfrom dioxus-components)
The WebsocketHandle returned by use_websocket is Copy — we can freely pass it to the receive loop and to every button handler without explicit cloning.
Add the RoomCell component and the Game component after the App component:
// ── RoomCell component ── #[component] fn RoomCell(name: String, active: bool, players: Vec<String>) -> Element { rsx! { div { class: if active { "cell active" } else { "cell" }, style: if active { "background: #3b82f6; color: #fff; font-weight: bold;" } else { "background: #12122a; color: #5a5a7a;" }, div { class: "room-name", "{name}" } for p in &players { div { class: "player-indicator", "{p}" } } } } } // ── Game UI ── #[component] fn Game(name: String) -> Element { let mut players = use_signal::<HashMap<String, PlayerInfo>>(|| HashMap::new()); let mut my_id = use_signal(|| String::new()); // Connect to the WebSocket endpoint. // The handle is Copy — we can use it in multiple closures. let mut socket = use_websocket(move || { mud_ws(name.clone(), WebSocketOptions::new()) }); // ── Message receive loop ── // This future runs for the component's entire lifetime. // It reconnects automatically if the connection drops. { let mut players = players.clone(); let mut my_id = my_id.clone(); use_future(move || async move { loop { // Wait for the WebSocket to connect _ = socket.connect().await; // Read messages until the connection drops while let Ok(msg) = socket.recv().await { match msg { ServerMessage::State { players: p, your_id } => { let mut map = players.write(); map.clear(); for pl in p { map.insert(pl.id.clone(), pl); } my_id.set(your_id); } // PlayerJoined and PlayerMoved both carry a PlayerInfo // and are handled the same way: upsert into the map ServerMessage::PlayerJoined(pl) | ServerMessage::PlayerMoved(pl) => { players.write().insert(pl.id.clone(), pl); } ServerMessage::PlayerLeft { id } => { players.write().remove(&id); } } } } }); } // Derive this player's info for the description panel let my_info = { let p = players.read(); let id = my_id.read(); id.as_ref() .and_then(|id| p.get(id).cloned()) }; rsx! { div { class: "game-layout", // ── Left: 3×3 grid ── div { class: "grid-area", div { class: "world-grid", for y in 0..3 { for x in 0..3 { let idx = y * 3 + x; let room_name = ROOM_NAMES[idx]; // Collect the names of players in this room let occupants: Vec<String> = players.read().values() .filter(|p| p.room == idx) .map(|p| p.name.clone()) .collect(); // Is this the cell where "we" are standing? let is_my_cell = my_id.read().as_ref() .and_then(|id| players.read().get(id)) .map_or(false, |p| p.room == idx); RoomCell { key: "{idx}", name: room_name.to_string(), active: is_my_cell, players: occupants, } } } } } // ── Right: player list sidebar ── div { class: "sidebar", h2 { "⚔️ Adventurers" } for p in players.read().values() { let is_me = my_id.read().as_ref() .map_or(false, |id| id == &p.id); div { key: "{p.id}", class: if is_me { "player-entry me" } else { "player-entry" }, span { class: "player-name", "{p.name}" if is_me { " (you)" } } span { class: "player-room", "{ROOM_NAMES[p.room]}" } } } } } // ── Description panel ── if let Some(ref info) = my_info { Card { CardHeader { CardTitle { "📍 Current Room" } } Separator { horizontal: true } CardContent { "{ROOM_NAMES[info.room]}" "\n" "{ROOM_DESCS[info.room]}" } } } // ── D-pad movement controls ── div { class: "direction-pad", style: "display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 16px 0;", Button { variant: ButtonVariant::Outline, onclick: move |_| { let _ = socket.send(ClientMessage::Move { direction: "north".into(), }); }, "⬆ North" } div { style: "display: flex; gap: 4px;", Button { variant: ButtonVariant::Outline, onclick: move |_| { let _ = socket.send(ClientMessage::Move { direction: "west".into(), }); }, "⬅ West" } Button { variant: ButtonVariant::Outline, onclick: move |_| { let _ = socket.send(ClientMessage::Move { direction: "east".into(), }); }, "➡ East" } } Button { variant: ButtonVariant::Outline, onclick: move |_| { let _ = socket.send(ClientMessage::Move { direction: "south".into(), }); }, "⬇ South" } } } }
use_websocket takes a closure that calls a server function (our mud_ws). The hook:
- Creates a reactive handle (
WebsocketHandle) that manages the connection lifecycle - The handle's
.connect()method initiates the WebSocket upgrade on the server .send(msg)serializes theClientMessageto JSON and sends it as a WebSocket frame.recv()awaits the next incoming frame and deserializes it toServerMessage.status()returns the current connection state
When the use_future loop calls socket.connect().await, it establishes the connection. If the connection drops (server restart, network blip), the while let Ok(msg) loop exits and we retry by calling connect() again.
The closure inside use_websocket(move || mud_ws(name.clone(), ...)) re-runs only when its captured signals change. Since name is a derived String from player_name() (not a signal), it stays fixed for the component lifetime.
Message flow for a move:
Player clicks ▲ → socket.send(Move { "north" })
│
▼
Server validates against EXITS[room]
│
▼
Broadcasts PlayerMoved { room: 1 }
│
▼
┌──────────────┼──────────────┐
▼ ▼ ▼
Alice Bob (other Charlie (other
(sender) tab) tab)
│ │ │
▼ ▼ ▼
updates updates updates
players map players map players map
The sender also receives the broadcast — we don't optimistically update the local grid. The server is the single source of truth for all players. This prevents desyncs and makes the game logic simple to reason about.