MudEngine Part 5: Polished UI with dioxus-components
Add a direction Button pad
The current app requires typing north, south, etc. into an input box. Let us add a D-pad of direction buttons so the player can click to move.
Instead of inlining the buttons directly inside App, we will create a standalone DirectionPad component — just like RoomCell in the previous step. It keeps App focused on layout and makes the D-PAD reusable.
Create a new file src/components/direction_pad.rs with this component, then append pub mod direction_pad; to src/components/mod.rs.
use crate::components::button::{Button, ButtonVariant}; use crate::game::world::{Direction, World}; use dioxus::prelude::*; #[component] pub fn DirectionPad(world: Signal<World>) -> Element { // Check which directions are reachable from the current room let room = world.read(); let north_ok = room.rooms[room.player_room].exits.iter().any(|(d, _)| *d == Direction::North); let south_ok = room.rooms[room.player_room].exits.iter().any(|(d, _)| *d == Direction::South); let east_ok = room.rooms[room.player_room].exits.iter().any(|(d, _)| *d == Direction::East); let west_ok = room.rooms[room.player_room].exits.iter().any(|(d, _)| *d == Direction::West); drop(room); rsx! { div { class: "direction-pad", style: "display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 16px 0;", Button { variant: ButtonVariant::Outline, disabled: !north_ok, onclick: move |_| { world.write().go(Direction::North); }, "⬆ North" } div { style: "display: flex; gap: 4px;", Button { variant: ButtonVariant::Outline, disabled: !west_ok, onclick: move |_| { world.write().go(Direction::West); }, "⬅ West" } Button { variant: ButtonVariant::Outline, disabled: !east_ok, onclick: move |_| { world.write().go(Direction::East); }, "➡ East" } } Button { variant: ButtonVariant::Outline, disabled: !south_ok, onclick: move |_| { world.write().go(Direction::South); }, "⬇ South" } } } }
The DirectionPad component follows the same pattern as RoomCell from the previous step — it is a #[component] function that receives props and returns an Element.
Prop
The only prop is world: Signal<World> — the game state. The component reads exits via .read() and moves the player via .write().go(dir).
Signal is Copy (a reference-counted handle), so passing it from App is cheap.
Inside the component
- Before the
rsx!block, read the current room'sexitslist — this avoids borrow conflicts. - For each cardinal direction, check if an exit with that name exists. This sets
north_ok,south_ok, etc. — booleans that determine whether each button is enabled. - Each
Buttongetsdisabled: !dir_ok— the browser grays out unreachable directions and blocks their click event. This is why we do not need afeedbacksignal for error messages: the disabled button can never be clicked. - The
onclickhandler is now a single call:world.write().go(Direction::North). No if/else or feedback — if the button is enabled the direction is guaranteed valid.
Using it in App
Replace the old inline direction pad code with a single component call inside App's rsx!:
DirectionPad { world }
The world signal is already defined at the top of App — just pass it as a prop. The component handles the rest: exit checking, button styling, disabled state, and movement.
In App's rsx! block, locate the old direction-pad div (the block with all five buttons between the Card and the feedback/input). Replace it with:
DirectionPad { world }
Because DirectionPad lives in its own file, add its import at the top of src/components/app.rs:
use crate::components::direction_pad::DirectionPad;
That is it — one prop, no feedback wiring, no inline button code. Your rsx! should now read:
Card {
CardHeader { CardTitle { "📍 Current Room" } }
Separator { horizontal: true }
CardContent { "{world.read().look()}" }
}
DirectionPad { world }
if !feedback.read().is_empty() {
p { style: "color: #ff6b6b; margin: 8px 0;", "{feedback}" }
}
input {
// ...
}