← All Workshops

MudEngine Part 8: Inventory & Chat

Step 7 / 10

Extract Reusable Components

The client needs four changes:

  1. Message receive loop — handle the three new ServerMessage variants: Chat, ItemTaken, ItemDropped
  2. Command input — a text box where players type say, take, and drop commands
  3. Inventory panel — a component that shows what the current player is carrying
  4. Floating player list — a component that becomes a fixed-position panel on the right edge

We extract the inventory panel and player list into separate #[component] functions. Even though everything lives in main.rs, components make the code easier to reason about and each component only re-renders when its specific signal dependencies change.

The components receive data through ReadOnlySignal props. This is the standard Dioxus pattern for reactive props — the component re-renders when the underlying signal changes:

#[component]
fn FloatingPlayerList(
    players: ReadOnlySignal<HashMap<String, PlayerInfo>>,
    rooms: ReadOnlySignal<HashMap<usize, RoomInfo>>,
    my_id: ReadOnlySignal<String>,
) -> Element {
    // reads from signals inside rsx!
}

#[component]
fn InventoryPanel(
    players: ReadOnlySignal<HashMap<String, PlayerInfo>>,
    my_id: ReadOnlySignal<String>,
) -> Element {
    // reads from signals inside rsx!
}

Start by adding these component definitions after the ChatMessage struct and before the Game component. Then update the receive loop with the new signals:

mud-engine/src/main.rs
#[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 rooms = use_signal::<HashMap<usize, RoomInfo>>(|| HashMap::new());
    let mut chat_messages = use_signal::<Vec<ChatMessage>>(|| Vec::new());
    let mut command_input = use_signal(|| String::new());

    let mut socket = use_websocket(move || {
        mud_ws(name.clone(), WebSocketOptions::new())
    });

    // ── Message receive loop ──
    {
        let mut players = players.clone();
        let mut my_id = my_id.clone();
        let mut rooms = rooms.clone();
        let mut chat_messages = chat_messages.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,
                            rooms: r,
                        } => {
                            let mut map = players.write();
                            map.clear();
                            for pl in p {
                                map.insert(pl.id.clone(), pl);
                            }
                            my_id.set(your_id);

                            let mut room_map = rooms.write();
                            room_map.clear();
                            for room in r {
                                room_map.insert(room.id, room);
                            }
                        }
                        ServerMessage::PlayerJoined(pl)
                        | ServerMessage::PlayerMoved(pl) => {
                            // Preserve items from the existing entry when
                            // a PlayerMoved arrives (it doesn't carry items)
                            let mut map = players.write();
                            if let Some(existing) = map.get(&pl.id) {
                                let mut updated = pl.clone();
                                updated.items = existing.items.clone();
                                map.insert(pl.id.clone(), updated);
                            } else {
                                map.insert(pl.id.clone(), pl);
                            }
                        }
                        ServerMessage::PlayerLeft { id } => {
                            players.write().remove(&id);
                        }
                        ServerMessage::Chat { from, message, room } => {
                            let my_room = my_id.read().as_ref()
                                .and_then(|id| players.read().get(id))
                                .map(|p| p.room);

                            let mut msgs = chat_messages.write();
                            msgs.push(ChatMessage {
                                from,
                                message,
                                room,
                                is_mine: false,
                            });
                            if msgs.len() > 50 {
                                msgs.remove(0);
                            }
                        }
                        ServerMessage::ItemTaken {
                            player_id,
                            player_name,
                            item,
                        } => {
                            // Update the player's inventory
                            let mut map = players.write();
                            if let Some(p) = map.get_mut(&player_id) {
                                p.items.push(item.clone());
                            }

                            // Remove the item from the room it was in
                            let player_room = map.get(&player_id)
                                .map(|p| p.room);
                            if let Some(room_id) = player_room {
                                let mut room_map = rooms.write();
                                if let Some(r) = room_map.get_mut(&room_id) {
                                    r.items.retain(|i| i != &item);
                                }
                            }

                            // Local notification
                            let mut msgs = chat_messages.write();
                            msgs.push(ChatMessage {
                                from: "⚡ System".into(),
                                message: format!(
                                    "{} picks up {}.", player_name, item
                                ),
                                room: 0,
                                is_mine: false,
                            });
                        }
                        ServerMessage::ItemDropped {
                            player_id,
                            player_name,
                            item,
                            ..
                        } => {
                            // Remove from player inventory
                            let mut map = players.write();
                            if let Some(p) = map.get_mut(&player_id) {
                                p.items.retain(|i| i != &item);
                            }

                            // Add to the player's current room
                            let player_room = map.get(&player_id)
                                .map(|p| p.room);
                            if let Some(room_id) = player_room {
                                let mut room_map = rooms.write();
                                if let Some(r) = room_map.get_mut(&room_id) {
                                    r.items.push(item.clone());
                                }
                            }

                            let mut msgs = chat_messages.write();
                            msgs.push(ChatMessage {
                                from: "⚡ System".into(),
                                message: format!(
                                    "{} drops {}.", player_name, item
                                ),
                                room: 0,
                                is_mine: false,
                            });
                        }
                    }
                }
            }
        });
    }

    let my_info = {
        let p = players.read();
        let id = my_id.read();
        p.get(&*id).cloned()
    };

    // the rsx! block follows below
💡 ChatMessage + component helpers

Add a helper struct and two component definitions before the Game component:

#[derive(Clone, Debug)]
struct ChatMessage {
    from: String,
    message: String,
    room: usize,
    is_mine: bool,
}

#[component]
fn FloatingPlayerList(
    players: ReadOnlySignal<HashMap<String, PlayerInfo>>,
    rooms: ReadOnlySignal<HashMap<usize, RoomInfo>>,
    my_id: ReadOnlySignal<String>,
) -> Element {
    rsx! {
        div { class: "floating-player-list",
            h2 { class: "fpl-title", "⚔️ Adventurers" }
            div { class: "fpl-scroll",
                for p in players.read().values() {
                    {
                        let is_me = *my_id.read() == p.id;
                        let room_name = rooms.read()
                            .get(&p.room)
                            .map(|r| r.name.clone())
                            .unwrap_or_default();
                        rsx! {
                            div {
                                key: "{p.id}",
                                class: if is_me { "fpl-entry me" } else { "fpl-entry" },
                                div { class: "fpl-name",
                                    "{p.name}"
                                    if is_me { " (you)" }
                                }
                                div { class: "fpl-room", "{room_name}" }
                                if !p.items.is_empty() {
                                    div { class: "fpl-items",
                                        for item in &p.items {
                                            span { class: "fpl-item", "{item}" }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

#[component]
fn InventoryPanel(
    players: ReadOnlySignal<HashMap<String, PlayerInfo>>,
    my_id: ReadOnlySignal<String>,
) -> Element {
    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: "inventory-panel",
            h3 { class: "inv-title", "🎒 Inventory" }
            if let Some(ref info) = my_info {
                if info.items.is_empty() {
                    div { class: "inv-empty", "Empty" }
                } else {
                    for item in &info.items {
                        div { key: "{item}", class: "inv-item", "{item}" }
                    }
                }
            }
        }
    }
}

ChatMessage lives only on the client (never serialized). The room field filters messages to same-room chat only.

FloatingPlayerList and InventoryPanel are standalone components that receive signals as ReadOnlySignal props. They reactively re-render when their dependencies change.

mud-engine/src/main.rs
    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 = rooms.read()
                                    .get(&idx)
                                    .map(|r| r.name.clone())
                                    .unwrap_or_default();

                                let occupants: Vec<String> = players.read().values()
                                    .filter(|p| p.room == idx)
                                    .map(|p| p.name.clone())
                                    .collect();

                                let is_my_cell = players.read().get(&*my_id.read())
                                    .map_or(false, |p| p.room == idx);
                                rsx! {
                                    div {
                                        key: "{idx}",
                                        class: if is_my_cell { "cell active" } else { "cell" },
                                        div { class: "room-name", "{room_name}" }
                                        for occupant_name in &occupants {
                                            div { class: "player-indicator", "{occupant_name}" }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }

                // ── Description panel ──
                if let Some(ref info) = my_info {
                    let room_data = rooms.read().get(&info.room).cloned();
                    if let Some(rd) = room_data {
                        div { class: "description",
                            div { class: "desc-title", "{rd.name}" }
                            div { class: "desc-text", "{rd.description}" }
                        }
                        // Items visible in this room
                        if !rd.items.is_empty() {
                            div { class: "room-items",
                                h3 { "📦 Items here" }
                                for item in &rd.items {
                                    div { class: "room-item", "{item}" }
                                }
                            }
                        }
                    }
                }
            }

            // ── Right: floating player list ──
            FloatingPlayerList {
                players: players.into(),
                rooms: rooms.into(),
                my_id: my_id.into(),
            }

            // ── Bottom row: chat + input + controls ──
            div { class: "bottom-area",
                // Chat panel
                div { class: "chat-panel",
                    div { class: "chat-header", "💬 Room Chat" }
                    div { class: "chat-scroll",
                        if chat_messages.read().is_empty() {
                            div { class: "chat-empty",
                                "Say something with: say hello"
                            }
                        }
                        for msg in chat_messages.read().iter() {
                            let is_same_room = my_info.as_ref()
                                .map_or(false, |info| msg.room == info.room);
                            if is_same_room || msg.from.starts_with("") {
                                div { class: "chat-msg",
                                    if msg.from.starts_with("") {
                                        span { class: "chat-system", "{msg.message}" }
                                    } else {
                                        span { class: "chat-from", "{msg.from}: " }
                                        span { class: "chat-text", "{msg.message}" }
                                    }
                                }
                            }
                        }
                    }
                }

                // Command input + D-pad
                div { class: "controls-area",
                    div { class: "command-row",
                        input {
                            class: "command-input",
                            value: "{command_input}",
                            oninput: move |e| command_input.set(e.value()),
                            onkeydown: move |e| {
                                if e.key() == Key::Enter {
                                    let cmd = command_input.read().trim().to_string();
                                    if !cmd.is_empty() {
                                        if let Some((verb, rest)) = cmd.split_once(char::is_whitespace)
                                            .map(|(v, r)| (v.to_lowercase(), r.trim().to_string()))
                                        {
                                            match verb.as_str() {
                                                "say" | "'" => {
                                                    let _ = socket.send(
                                                        ClientMessage::Say { message: rest }
                                                    );
                                                }
                                                "take" | "get" => {
                                                    let _ = socket.send(
                                                        ClientMessage::Take { item: rest }
                                                    );
                                                }
                                                "drop" => {
                                                    let _ = socket.send(
                                                        ClientMessage::Drop { item: rest }
                                                    );
                                                }
                                                _ => {}
                                            }
                                        }
                                        command_input.set(String::new());
                                    }
                                }
                            },
                            placeholder: "Type 'say hello', 'take sword', 'drop map'...",
                        }
                    }

                    // D-pad
                    div { class: "dpad",
                        button {
                            class: "dpad-btn up",
                            onclick: move |_| {
                                let _ = socket.send(ClientMessage::Move {
                                    direction: "north".into(),
                                });
                            },
                            ""
                        }
                        div { class: "dpad-row",
                            button {
                                class: "dpad-btn left",
                                onclick: move |_| {
                                    let _ = socket.send(ClientMessage::Move {
                                        direction: "west".into(),
                                    });
                                },
                                ""
                            }
                            button {
                                class: "dpad-btn center",
                                disabled: "true",
                                ""
                            }
                            button {
                                class: "dpad-btn right",
                                onclick: move |_| {
                                    let _ = socket.send(ClientMessage::Move {
                                        direction: "east".into(),
                                    });
                                },
                                ""
                            }
                        }
                        button {
                            class: "dpad-btn down",
                            onclick: move |_| {
                                let _ = socket.send(ClientMessage::Move {
                                    direction: "south".into(),
                                });
                            },
                            ""
                        }
                    }
                }

                // Inventory panel
                InventoryPanel {
                    players: players.into(),
                    my_id: my_id.into(),
                }
            }
        }
    }
}
💡 Reading from signals inside rsx!

Notice that inside the rsx! macro we call players.read(), rooms.read(), and chat_messages.read() each time the render runs. Dioxus tracks these reads as dependencies — whenever the signal value changes, the component re-renders only the parts of the virtual DOM that depend on that signal.

This is why everything updates in real time: when the server broadcasts Chat, the receive loop pushes a message to chat_messages, which triggers a re-render of the chat panel section. Similarly for inventory changes and player movements.

Step 7 / 10