← All Workshops

MudEngine Part 5: Polished UI with dioxus-components

Step 9 / 13

Clickable room cards

The D-PAD buttons let you click to move, but there is an even more intuitive way: click the room card itself. Instead of pressing "North", click the Hilltop card directly.

In this step we make the room cards clickable — but only the ones you can actually reach. Non-adjacent rooms stay inert. You click a card, and if there is a valid exit to that room, the player moves there.

What we change

  • RoomCell — gains clickable and onclick props; when clickable, clicking the cell calls go(direction) on the world
  • WorldGrid — now takes Signal<World> (write access), computes which rooms are reachable from the current room by scanning the exits list, and passes click handlers to each cell
  • CSS — clickable cells get a pointer cursor, border, and hover glow
💡 WorldGrid needs write access

Currently WorldGrid takes ReadSignal<World> — a read-only view. To call world.write().go(dir) when the player clicks a cell, we need write access. Changing the prop to Signal<World> gives us both .read() (for rendering) and .write() (for mutations).

In App, world is already a Signal<World> — it is passed directly, so no change needed at the call site. Only WorldGrid's function signature changes.

mud-engine/src/components/room_cell.rs
use dioxus::prelude::*;

#[component]
pub fn RoomCell(
    name: String,
    active: bool,
    clickable: bool,
    onclick: EventHandler<MouseEvent>,
) -> Element {
    rsx! {
        div {
            class: if active { "cell active" }
                   else if clickable { "cell clickable" }
                   else { "cell" },
            style: if active { "background: #3b82f6; color: #fff; font-weight: bold;" }
                   else if clickable { "background: #1a1a3e; color: #7a7aaa;" }
                   else { "background: #12122a; color: #5a5a7a;" },
            onclick: move |e| if clickable { onclick(e) },
            "{name}"
        }
    }
}
🎯 New props on RoomCell
  • clickable: bool — when true, the cell gets a lighter background and the .cell.clickable CSS class. The onclick handler only fires when clickable is true.
  • onclick: EventHandler<MouseEvent> — the callback that runs when the cell is clicked. For reachable rooms this will call world.write().go(direction). For unreachable rooms it is a no-op.

The EventHandler<MouseEvent> type is Dioxus's standard event callback. It wraps a closure and implements Clone + PartialEq so it works as a component prop. You pass a closure directly when calling RoomCell — Dioxus converts it automatically.

🔁 Update WorldGrid to compute reachable rooms

Replace the entire WorldGrid component with this version. It changes the prop type from ReadSignal<World> to Signal<World>, and uses .map().collect() to build the grid cells outside the rsx! macro — each cell checks the current room's exits to determine if it is clickable:

mud-engine/src/components/world_grid.rs
use crate::components::room_cell::RoomCell;
use crate::game::world::World;
use dioxus::prelude::*;

#[component]
pub fn WorldGrid(world: Signal<World>) -> Element {
    let w = world.read();
    let player_room = w.player_room;
    let names: Vec<String> = w.rooms.iter().map(|r| r.name.clone()).collect();
    // Clone the current room's exits to iterate outside the read guard
    let exits = w.rooms[player_room].exits.clone();
    drop(w);

    // Build each cell outside the rsx! macro — rsx! does not support let bindings in for loops
    let cells: Vec<Element> = names.iter().enumerate().map(|(idx, name)| {
        let is_active = idx == player_room;

        // Is there an exit from the current room pointing to this cell?
        let exit_dir = exits.iter()
            .find(|(_, target)| *target == idx)
            .map(|(dir, _)| dir.clone());
        let is_clickable = exit_dir.is_some();

        // Clone for the onclick closure
        let mut world2 = world.clone();
        let dir2 = exit_dir.clone();

        rsx! {
            RoomCell {
                key: "{idx}",
                name: name.clone(),
                active: is_active,
                clickable: is_clickable,
                onclick: move |_: MouseEvent| {
                    if let Some(ref d) = dir2 {
                        world2.write().go(*d);
                    }
                },
            }
        }
    }).collect();

    rsx! {
        div {
            class: "world-grid",
            {cells.into_iter()}
        }
    }
}
🎯 How reachable rooms are determined

Each room stores its exits as Vec<(Direction, usize)> — a list of (direction, target_room_index) pairs. For example, Town Square (room 4) has:

exits: vec![
    (Direction::North, 1),   // Hilltop
    (Direction::South, 7),   // Old Bridge
    (Direction::West, 3),    // Dark Forest
    (Direction::East, 5),    // Temple Courtyard
]

In the .map() call over room indices, we check: does the current room have an exit whose target is this index? If yes, we know the direction and the room is reachable.

For Hilltop (index 1) when the player is in Town Square — exit (Direction::North, 1) matches → exit_dir = Some(Direction::North) → cell is clickable. For Riverbank (index 6) when the player is in Town Square — no exit targets index 6 → exit_dir = None → cell is inert.

When the player clicks a reachable cell, the onclick closure fires world2.write().go(*d), which moves the player. Dioxus detects the signal change and re-renders the grid with the new current room and updated reachable rooms.

🎨 Update the CSS

Open assets/main.css and add these rules at the end. They give clickable cells a pointer cursor, a visible border, and a hover glow effect:

mud-engine/assets/main.css
/* ── Clickable cells ── */
.world-grid .cell.clickable {
    cursor: pointer;
    border: 1px solid #2a2a5a;
    transition: filter 0.2s, border-color 0.2s, box-shadow 0.2s;
}

.world-grid .cell.clickable:hover {
    filter: brightness(1.35);
    border-color: #3b82f6;
    box-shadow: 0 0 12px rgba(59, 130, 246, 0.15);
}

.world-grid .cell.clickable:active {
    filter: brightness(0.9);
    transform: scale(0.97);
}
🎯 How the CSS works
RuleEffect
cursor: pointerChanges the cursor to a hand icon on hover
border: 1px solid #2a2a5aGives clickable cells a subtle border (non-clickable cells have no border)
:hoverBrightens the cell, adds a blue border, and a soft glow
:activeDarkens and slightly shrinks the cell on click for tactile feedback

The filter: brightness(...) approach avoids fighting the inline background colour — it brightens whatever background is already set.

🧪 Try it out

Run dx serve and open the app. Start in Town Square (room 4, the center):

  1. Click "Hilltop" (the card north of Town Square) → the player moves to Hilltop. The grid updates: Hilltop is now blue (active), and its adjacent rooms — Dark Forest (south) and Abandoned Tower (east) — become clickable.
  2. Click "Abandoned Tower" → the player moves again. The exits update to show Abandoned Tower's reachable rooms.
  3. Hover over a clickable cell → you see the pointer cursor and the brightening glow effect. Hover over a dimmed non-adjacent cell → nothing happens.
  4. Click a non-adjacent cell (like "Riverbank" when at Town Square) → nothing happens. The exit_dir is None, so the onclick handler is a no-op.

The D-PAD buttons and the keyboard input still work — all three navigation methods coexist.

Step 9 / 13