← All Workshops

MudEngine Part 6: Multiplayer

Step 15 / 18

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:

  1. Signalsplayers (HashMap<String, PlayerInfo>) and my_id (String) store the authoritative server state
  2. WebSocketuse_websocket returns a WebsocketHandle (which is Copy) that we pass freely to closures
  3. Message loop — a use_future calls connect() then loops on recv(), matching each ServerMessage variant:
    • State — received on connect, contains the full player map and our assigned ID
    • PlayerJoined / PlayerMoved — insert or update a player entry
    • PlayerLeft — remove the player by ID
  4. Current room info — computed from players and my_id to show the room description
  5. One send_move closure — a single closure that spawns socket.send(ClientMessage::Move { direction }). All three navigation methods reuse it: the D-pad buttons, the clickable room cards (via WorldGrid's onmove), and the keyboard shortcuts (via onkeydown on the game layout).
  6. Keyboard shortcuts — the .game-layout div is focusable (tabindex: "0") and is auto-focused on mount, so arrow keys / WASD / vim keys (h j k l) send moves. There is no text input on this screen anymore, so no input_focused guard is needed (unlike Part 5).
  7. LayoutWorldGrid on the left, player sidebar on the right, description below, DirectionPad at 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.

mud-engine/src/components/game.rs
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,
        }
    }
}
⚠️ Why the send is wrapped in spawn

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.

💡 How use_websocket works

use_websocket takes a closure that calls a server function (our mud_ws). The hook:

  1. Creates a reactive handle (WebsocketHandle) that manages the connection lifecycle
  2. The handle's .connect() method initiates the WebSocket upgrade on the server
  3. .send(msg) serializes the ClientMessage to JSON and sends it as a WebSocket frame
  4. .recv() awaits the next incoming frame and deserializes it to ServerMessage
  5. .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.

Step 15 / 18