← All Workshops

MudEngine Part 5: Polished UI with dioxus-components

Step 8 / 13

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.

mud-engine/src/components/direction_pad.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"
            }
        }
    }
}
🎯 How DirectionPad works

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

  1. Before the rsx! block, read the current room's exits list — this avoids borrow conflicts.
  2. 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.
  3. Each Button gets disabled: !dir_ok — the browser grays out unreachable directions and blocks their click event. This is why we do not need a feedback signal for error messages: the disabled button can never be clicked.
  4. The onclick handler 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.

🧩 Integrating into App

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 {
    // ...
}
Step 8 / 13