MudEngine Part 6: Multiplayer
Game Component — WebSocket Client
The Game component connects to the server via use_websocket, listens for incoming messages, and composes the UI from the enhanced components:
- Signals —
players(HashMap<String, PlayerInfo>) andmy_id(String) store the authoritative server state - WebSocket —
use_websocketreturns aWebsocketHandle(which is Copy) that we pass freely to closures - Message loop — a
use_futurecallsconnect()then loops onrecv(), matching eachServerMessagevariant:State— received on connect, contains the full player map and our assigned IDPlayerJoined/PlayerMoved— insert or update a player entryPlayerLeft— remove the player by ID
- Current room info — computed from
playersandmy_idto show the room description - One
send_moveclosure — a single closure that spawnssocket.send(ClientMessage::Move { direction }). All three navigation methods reuse it: the D-pad buttons, the clickable room cards (viaWorldGrid'sonmove), and the keyboard shortcuts (viaonkeydownon the game layout). - Keyboard shortcuts — the
.game-layoutdiv is focusable (tabindex: "0") and is auto-focused on mount, so arrow keys / WASD / vim keys (hjkl) send moves. There is no text input on this screen anymore, so noinput_focusedguard is needed (unlike Part 5). - Layout —
WorldGridon the left, player sidebar on the right, description below,DirectionPadat the bottom
Create src/components/game.rs with this component — the last frontend component. Then append pub mod game; to src/components/mod.rs so App can use it.
Notice the imports: Game pulls in the other frontend components (WorldGrid, DirectionPad) and the game logic (mud_ws, the message types, the room data). It is the only component that knows about the WebSocket connection.
use std::collections::HashMap; use crate::components::direction_pad::DirectionPad; use crate::components::world_grid::WorldGrid; use crate::game::messages::{ClientMessage, PlayerInfo, ServerMessage}; use crate::game::room_data::{ROOM_DESCS, ROOM_NAMES}; use crate::game::server::mud_ws; use dioxus::prelude::*; use dioxus_fullstack::{use_websocket, WebSocketOptions}; #[component] pub fn Game(name: String) -> Element { let mut players = use_signal::<HashMap<String, PlayerInfo>>(|| HashMap::new()); let mut my_id = use_signal(|| String::new()); let mut socket = use_websocket(move || { mud_ws(name.clone(), WebSocketOptions::new()) }); { let mut players = players.clone(); let mut my_id = my_id.clone(); use_future(move || async move { loop { _ = socket.connect().await; 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); } ServerMessage::PlayerJoined(pl) | ServerMessage::PlayerMoved(pl) => { players.write().insert(pl.id.clone(), pl); } ServerMessage::PlayerLeft { id } => { players.write().remove(&id); } } } } }); } // Focus the game area on mount so keyboard shortcuts work immediately use_effect(move || { let _ = document::eval("document.querySelector('.game-layout')?.focus()"); }); let my_info = { let p = players.read(); let id = my_id.read(); p.get(&*id).cloned() }; // The single way to send a move: spawn the async send so it gets polled let send_move = move |direction: String| { spawn(async move { let _ = socket.send(ClientMessage::Move { direction }).await; }); }; rsx! { div { class: "game-layout", tabindex: "0", onkeydown: move |e| { let dir = match e.key() { Key::ArrowUp => Some("north"), Key::ArrowDown => Some("south"), Key::ArrowLeft => Some("west"), Key::ArrowRight => Some("east"), Key::Character(c) => match c.as_str() { "w" | "k" => Some("north"), "s" | "j" => Some("south"), "a" | "h" => Some("west"), "d" | "l" => Some("east"), _ => None, }, _ => None, }; if let Some(d) = dir { e.prevent_default(); send_move(d.to_string()); } }, div { class: "grid-area", WorldGrid { players: players, my_id: my_id, onmove: send_move, } } div { class: "sidebar", h2 { "⚔️ Adventurers" } for p in players.read().values() { { let is_me = *my_id.read() == p.id; rsx! { 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]}" } } } } } } } if let Some(ref info) = my_info { div { class: "description", "{ROOM_NAMES[info.room]}" "\n" "{ROOM_DESCS[info.room]}" } } DirectionPad { onmove: send_move, } } }
socket.send(...) is an async function — it returns a Future that does nothing until it is polled. Calling it without await creates the future and immediately drops it. A dropped future never runs, so the move message would never leave the browser (clicking the D-pad would do nothing).
Registration worked because the receive side was correctly awaited (connect().await, recv().await). Only the fire-and-forget send was swallowed.
spawn(async move { ... }) hands the future to the Dioxus runtime, which polls it to completion. socket is Copy, so moving it into the async block is safe. The send_move closure is passed to the D-pad, the clickable room cards, and the keyboard shortcuts — so every navigation method actually transmits ClientMessage::Move, the server broadcasts PlayerMoved, and the grid and description update.
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.