MudEngine Part 8: Inventory & Chat
Extract Reusable Components
The client needs four changes:
- Message receive loop — handle the three new
ServerMessagevariants:Chat,ItemTaken,ItemDropped - Command input — a text box where players type
say,take, anddropcommands - Inventory panel — a component that shows what the current player is carrying
- 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:
#[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
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.
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(), } } } } }
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.