← All Workshops

MudEngine Part 6: Multiplayer

Step 10 / 12

Complete main.rs

Here is the full src/main.rs with everything in one place — imports, shared types, server state, WebSocket endpoint, and both UI components. The code uses the dioxus-components (Button, Card, Input, Separator) and the custom RoomCell component introduced in Part 5. The RoomCell now includes a players: Vec<String> prop that shows occupant names in each grid cell.

mud-engine/src/main.rs
use std::collections::HashMap;
use dioxus::prelude::*;
use dioxus_fullstack::{use_websocket, Websocket, WebSocketOptions};
use serde::{Deserialize, Serialize};

mod components;

use crate::components::button::{Button, ButtonVariant};
use crate::components::card::{Card, CardHeader, CardTitle, CardContent};
use crate::components::separator::Separator;

fn main() {
    dioxus::launch(App);
}

// ── Shared message types ──

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
struct PlayerInfo {
    id: String,
    name: String,
    room: usize,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
enum ClientMessage {
    Move { direction: String },
}

#[derive(Serialize, Deserialize, Debug, Clone)]
enum ServerMessage {
    State {
        players: Vec<PlayerInfo>,
        your_id: String,
    },
    PlayerJoined(PlayerInfo),
    PlayerMoved(PlayerInfo),
    PlayerLeft { id: String },
}

// ── Static room data ──

static ROOM_NAMES: &[&str] = &[
    "Forest Path", "Hilltop", "Abandoned Tower",
    "Dark Forest", "Town Square", "Temple Courtyard",
    "Riverbank", "Old Bridge", "Graveyard",
];

static ROOM_DESCS: &[&str] = &[
    "A winding path leads through ancient oaks. Sunlight filters through the canopy.",
    "From this vantage point you can see the entire valley. A cool breeze carries the scent of pine.",
    "A crumbling stone tower stands alone. Vines crawl up its walls and crows nest in the windows.",
    "Twisted trees block out most of the light. Strange sounds echo through the undergrowth.",
    "A bustling town square with a fountain at its center. Cobblestones gleam from the morning rain.",
    "Ancient stone pillars surround a quiet courtyard. Moss clings to weathered statues.",
    "A slow-moving river borders a muddy bank. Frogs croak from the reeds.",
    "A weathered stone bridge crosses the river. Moss covers the ancient masonry.",
    "Rows of moss-covered headstones stretch into the fog. An iron gate creaks in the wind.",
];

// ── Server-only state ──

#[cfg(feature = "server")]
mod srv {
    use std::collections::HashMap;
    use std::sync::{LazyLock, Mutex};
    use tokio::sync::broadcast;

    use super::*;

    pub static PLAYERS: LazyLock<Mutex<HashMap<String, PlayerInfo>>> =
        LazyLock::new(|| Mutex::new(HashMap::new()));

    pub static BROADCAST: LazyLock<broadcast::Sender<ServerMessage>> =
        LazyLock::new(|| {
            let (tx, _) = broadcast::channel(64);
            tx
        });

    pub const EXITS: &[&[(&str, usize)]] = &[
        &[("south", 3), ("east", 1)],                               // 0
        &[("south", 4), ("west", 0), ("east", 2)],                   // 1
        &[("south", 5), ("west", 1)],                                // 2
        &[("north", 0), ("south", 6), ("east", 4)],                  // 3
        &[("north", 1), ("south", 7), ("west", 3), ("east", 5)],     // 4
        &[("north", 2), ("south", 8), ("west", 4)],                  // 5
        &[("north", 3), ("east", 7)],                                // 6
        &[("north", 4), ("west", 6), ("east", 8)],                   // 7
        &[("north", 5), ("west", 7)],                                // 8
    ];
}

// ── 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();

    {
        let mut players = srv::PLAYERS.lock().unwrap();
        players.insert(id.clone(), PlayerInfo {
            id: id.clone(),
            name: name.clone(),
            room: 4,
        });
    }

    let mut rx = srv::BROADCAST.subscribe();

    let _ = srv::BROADCAST.send(ServerMessage::PlayerJoined(
        PlayerInfo { id: id.clone(), name: name.clone(), room: 4 },
    ));

    let initial_state = {
        let players = srv::PLAYERS.lock().unwrap();
        ServerMessage::State {
            players: players.values().cloned().collect(),
            your_id: id.clone(),
        }
    };

    options.on_upgrade(move |mut socket| async move {
        let _ = socket.send(initial_state).await;

        loop {
            tokio::select! {
                msg = socket.recv() => {
                    match msg {
                        Ok(ClientMessage::Move { direction }) => {
                            let dir = match direction.as_str() {
                                "n" => "north", "s" => "south",
                                "e" => "east", "w" => "west",
                                d => d,
                            };

                            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
                                    }
                                } else {
                                    None
                                }
                            };

                            if let Some(room) = new_room {
                                let _ = srv::BROADCAST.send(
                                    ServerMessage::PlayerMoved(PlayerInfo {
                                        id: id.clone(),
                                        name: player_name.clone(),
                                        room,
                                    }),
                                );
                            }
                        }
                        Err(_) => break,
                    }
                }
                msg = rx.recv() => {
                    match msg {
                        Ok(server_msg) => {
                            if socket.send(server_msg).await.is_err() {
                                break;
                            }
                        }
                        Err(_) => break,
                    }
                }
            }
        }

        srv::PLAYERS.lock().unwrap().remove(&id);
        let _ = srv::BROADCAST.send(ServerMessage::PlayerLeft { id });
    })
}

// ── Name Registration ──

#[component]
fn App() -> Element {
    let mut player_name = use_signal(|| String::new());
    let mut registered = use_signal(|| false);

    if !*registered.read() {
        rsx! {
            div { class: "name-screen",
                h1 { "🧙 MudEngine" }
                p { class: "subtitle", "A multiplayer adventure awaits. What is your name?" }
                Input {
                    style: "padding: 10px 14px; font-size: 16px; border: 2px solid #3b82f6; border-radius: 6px; background: #12122a; color: #d0d0e0; width: 280px; outline: none; font-family: 'Courier New', Courier, monospace; box-sizing: border-box;",
                    value: "{player_name}",
                    oninput: move |e: FormEvent| player_name.set(e.value()),
                    onkeydown: move |e: KeyboardEvent| {
                        if e.key() == Key::Enter
                            && !player_name.read().trim().is_empty()
                        {
                            registered.set(true);
                        }
                    },
                    placeholder: "Enter your name...",
                }
                Button {
                    variant: ButtonVariant::Primary,
                    style: "margin: 16px auto 0; display: block;",
                    onclick: move |_| {
                        if !player_name.read().trim().is_empty() {
                            registered.set(true);
                        }
                    },
                    "Enter the World"
                }
            }
        }
    } else {
        rsx! {
            Game { name: player_name() }
        }
    }
}

// ── 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());

    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);
                        }
                    }
                }
            }
        });
    }

    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",
            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];
                            let occupants: Vec<String> = players.read().values()
                                .filter(|p| p.room == idx)
                                .map(|p| p.name.clone())
                                .collect();
                            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,
                            }
                        }
                    }
                }
            }

            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]}" }
                    }
                }
            }
        }

        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]}"
                }
            }
        }

        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"
            }
        }
    }
}
Step 10 / 12