Web Client
Status: Implemented (design updated 2026-03-29) Date: 2026-03-26 Decision: SolidJS + Three.js (originally Canvas 2D, upgraded during implementation)
Stack
| Layer | Choice | Why |
|---|---|---|
| UI Framework | SolidJS | Fine-grained reactivity, no virtual DOM, tiny bundle, JSX syntax |
| Map Rendering | Three.js WebGL | 3D isometric room cubes with orbit controls |
| Server Communication | Eyre SSE + HTTP pokes | Native Urbit, ~100 lines of wrapper code |
| Styling | CSS (vanilla or CSS modules) | No heavy UI library, full control over game aesthetic |
| Build | Vite | Fast dev server, Solid plugin, standard tooling |
Why SolidJS
What We Need From a Framework
A MUD client is a real-time data display — not a form-heavy CRUD app and not a full graphical game. The workload is:
- Append lines to a scrolling text log (high frequency during combat)
- Update a handful of status values (HP, mana, moves, XP) on every game tick
- Redraw a small section of the map when the player moves rooms
- Show/hide players in the online list as they connect/disconnect
- Route chat messages to the correct channel tab
Every one of these is a surgical update to a small part of the UI. Virtual DOM diffing (React, Vue) does unnecessary work here — it diffs the entire component tree to find what changed. Solid’s signals update only the exact DOM node bound to the signal. For a game client receiving 1-4 updates per second during combat, this is the right model.
What We Don’t Need
- Server-side rendering (it’s a game client, not a content site)
- A massive component library (we’re building custom game UI)
@urbit/http-api(we’ll write a thin Eyre wrapper)- React’s ecosystem breadth (we have a focused, known UI)
The Tradeoffs We Accept
- Smaller community than React — fewer Stack Overflow answers, fewer contributors who know it already
- No existing Urbit integration library — we write our own (~100 lines)
- Some Solid-specific patterns to learn (signals, effects, stores vs React’s useState/useEffect)
Architecture
Component Tree
<App>
├── <GameWindow> // Main layout container
│ ├── <MainPane> // Left/center: scrolling game output
│ │ ├── <TextLog> // Scrolling text with ANSI color support
│ │ └── <CommandInput> // Text input + command history + autocomplete
│ │
│ ├── <SidePanel> // Right side: stacked info panels
│ │ ├── <StatusBar> // HP / Mana / Moves bars + level
│ │ ├── <MapPanel> // Three.js 3D auto-map
│ │ ├── <OnlineUsers> // Who's in this world
│ │ └── <RoomInfo> // Current room name, exits (clickable)
│ │
│ └── <ChannelTabs> // Bottom or tabbed: chat channels
│ ├── <Channel name="game"> // Main game output (room, combat, system)
│ ├── <Channel name="say"> // say + yell + overheard
│ ├── <Channel name="tell"> // tell + mail notifications
│ ├── <Channel name="gossip">// World-wide chat
│ └── <Channel name="newbie">// New player help
│ // Admin announce appears in ALL tabs simultaneously
│
├── <CharacterSheet> // Modal/overlay: full stats, skills, equipment
├── <Inventory> // Modal/overlay: bag contents, equipment slots
├── <Mailbox> // Modal/overlay: read/send/delete mail
├── <QuestLog> // Modal/overlay: active quests, completed
└── <LoginScreen> // Guest creation or citizen auth
State Management
SolidJS uses signals (reactive atoms) and stores (reactive objects). No Redux, no external state library.
// Core game state — reactive signals
const [hp, setHp] = createSignal(100);
const [maxHp, setMaxHp] = createSignal(100);
const [mana, setMana] = createSignal(50);
const [maxMana, setMaxMana] = createSignal(50);
const [moves, setMoves] = createSignal(80);
const [maxMoves, setMaxMoves] = createSignal(80);
const [level, setLevel] = createSignal(1);
const [roomName, setRoomName] = createSignal("The Void");
const [exits, setExits] = createSignal<string[]>([]);
// Complex state — reactive stores
const [room, setRoom] = createStore({
id: 0,
name: "",
description: "",
exits: {} as Record<string, number>,
players: [] as string[],
mobs: [] as string[],
items: [] as string[],
});
const [character, setCharacter] = createStore({
name: "",
race: "",
class: "",
stats: { str: 0, int: 0, wis: 0, dex: 0, con: 0, luck: 0 },
inventory: [] as Item[],
equipment: {} as Record<string, Item | null>,
});
// Map state — explored rooms, cached for citizen localStorage
const [mapRooms, setMapRooms] = createStore<Record<number, MapRoom>>({});
// Waypoints — discovered fast-travel points (golden markers on map)
const [waypoints, setWaypoints] = createSignal<Set<number>>(new Set());
// Corpse locations — where your bodies are across worlds
const [corpses, setCorpses] = createSignal<Array<{world: string, room: number}>>([]);
// Online users
const [onlineUsers, setOnlineUsers] = createSignal<PlayerSummary[]>([]);
// Text log — append-only signal array
const [textLog, setTextLog] = createSignal<LogEntry[]>([]);
function appendLog(entry: LogEntry) {
setTextLog(prev => {
const next = [...prev, entry];
// Cap at 1000 lines to prevent memory bloat
return next.length > 1000 ? next.slice(-1000) : next;
});
}
When an SSE message arrives with {"hp": 85, "maxHp": 100}, we call setHp(85) — and only the HP bar DOM node updates. The text log, map, online users, everything else stays untouched. This is Solid’s superpower.
Log Entry Format
type LogEntry = {
id: number; // auto-increment for keying
timestamp: number; // for display if enabled
type: LogType; // determines styling
text: string; // the actual content
channel?: string; // which channel tab this belongs to
};
type LogType =
| "room" // room descriptions (white/default)
| "combat" // combat messages (red/orange)
| "quest" // quest updates (yellow)
| "system" // system messages (cyan)
| "say" // local speech (green)
| "tell" // private messages (magenta)
| "gossip" // world chat (bright yellow)
| "newbie" // help channel (bright green)
| "error" // error messages (bright red)
| "loot" // item/gold acquisition (gold)
| "xp" // experience gain (bright cyan)
;
Eyre Communication Layer
The Wrapper (~100 lines)
No need for @urbit/http-api. We talk to Eyre directly:
// eyre.ts — thin wrapper for Urbit communication
class EyreClient {
private baseUrl: string;
private eventSource: EventSource | null = null;
private channelId: string;
private eventId: number = 0;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
this.channelId = `mud-${Date.now()}`;
}
// For citizens: authenticate with Eyre
async login(code: string): Promise<boolean> {
const res = await fetch(`${this.baseUrl}/~/login`, {
method: "POST",
body: `password=${code}`,
credentials: "include",
});
return res.ok;
}
// For guests: create session via custom endpoint
async guestLogin(name: string, race: string, cls: string): Promise<boolean> {
const res = await fetch(`${this.baseUrl}/mud/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, race, class: cls }),
credentials: "include",
});
return res.ok;
}
// Send a game command (poke the %mud-world agent)
async command(cmd: string): Promise<void> {
await this.poke("mud-world", "mud-command", { command: cmd });
}
// Generic poke
async poke(app: string, mark: string, json: any): Promise<void> {
const id = ++this.eventId;
await fetch(`${this.baseUrl}/~/channel/${this.channelId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify([{
id,
action: "poke",
ship: "", // same ship
app,
mark,
json,
}]),
});
}
// Subscribe to game updates
subscribe(app: string, path: string, handler: (data: any) => void): void {
const id = ++this.eventId;
// Send subscribe action
fetch(`${this.baseUrl}/~/channel/${this.channelId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify([{
id,
action: "subscribe",
ship: "",
app,
path,
}]),
});
// Open SSE stream
if (!this.eventSource) {
this.eventSource = new EventSource(
`${this.baseUrl}/~/channel/${this.channelId}`,
{ withCredentials: true }
);
this.eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.json) handler(data.json);
// ACK the event
this.ack(Number(event.lastEventId));
};
}
}
private async ack(eventId: number): Promise<void> {
await fetch(`${this.baseUrl}/~/channel/${this.channelId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify([{
id: ++this.eventId,
action: "ack",
"event-id": eventId,
}]),
});
}
disconnect(): void {
this.eventSource?.close();
// Delete channel
fetch(`${this.baseUrl}/~/channel/${this.channelId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify([{
id: ++this.eventId,
action: "delete",
}]),
});
}
}
Message Flow
Player types "kill goblin"
│
▼
<CommandInput> captures input, calls eyre.command("kill goblin")
│
▼
HTTP PUT to /~/channel/{id} with poke action
│
▼
Eyre delivers poke to %mud-world agent's on-poke
│
▼
Agent processes combat, emits facts to subscription path
│
▼
SSE stream delivers multiple updates (one per fact emitted by agent):
event: {"combat-round": {"lines": [
{"type": "combat", "text": "You swing your sword at the goblin!"},
{"type": "combat", "text": "Your slash hits the goblin for 15 damage!"},
{"type": "combat", "text": "The goblin claws at you for 8 damage!"}
]}}
event: {"vitals": {"hp": 85, "max-hp": 100, "mana": 50, "max-mana": 50,
"moves": 80, "max-moves": 100}}
Each SSE event is one %mud-update variant (tagged union), JSON-encoded.
The agent emits multiple facts per tick — the client receives them
individually and dispatches each by its tag. This keeps the agent's
subscription output clean (one fact = one update type) while the client
handles them as a rapid batch within the same tick.
│
▼
Handler dispatches by tag:
- "combat-round" → appendLog() for each line → TextLog updates
- "vitals" → setHp(85) → StatusBar HP bar shrinks
- setRoom("mobs", ["a wounded goblin"]) → RoomInfo updates mob list
Guest vs Citizen Connection
| Aspect | Guest | Citizen |
|---|---|---|
| Auth | Custom /mud/guest endpoint, session cookie | Eyre +code login |
| Pokes | HTTP PUT to Eyre channel (same as citizen) | HTTP PUT to Eyre channel |
| Subscriptions | SSE via Eyre channel (same) | SSE via Eyre channel |
| Difference | Session tied to browser cookie, character on host ship | Session tied to @p, character on own ship |
From the client code’s perspective, guests and citizens use the same Eyre channel mechanism. The difference is in authentication and where character data is stored — that’s the backend’s concern, not the frontend’s.
Three.js 3D Map Panel
What It Renders
An isometric 3D view with rooms as colored cubes and connections as lines. The current room is highlighted gold. Cardinal direction labels (N/S/E/W) float at the edges. Camera orbits with mouse/touch controls.
Rendering
Uses Three.js WebGLRenderer with PerspectiveCamera and OrbitControls.
Axis mapping (LOCKED — do not change):
DIR_OFFSET: north=[0,-1,0], south=[0,1,0], east=[1,0,0], west=[-1,0,0], up=[0,0,1], down=[0,0,-1]
Position: mesh.position.set(rx, rz, ry)
Camera: (-35, 35, 35)
Room cubes are colored by sector:
| Sector | Color |
|---|---|
| city | #888888 |
| forest | #228B22 |
| mountain | #8B4513 |
| water | #1E90FF |
| underground | #4B0082 |
| indoor | #CD853F |
| field | #90EE90 |
| desert | #F4A460 |
Features
- Current room: Gold wireframe highlight with emissive glow
- Raycasting: Hover shows room name tooltip. On mobile, tap-to-toggle.
- Indicators: Small colored spheres on room cubes — cyan for NPCs, red for mobs, yellow for player notes
- Search: Text input filters and highlights matching rooms
- Cardinal labels: N/S/E/W text sprites at edges of the map
- ResizeObserver: Automatically adjusts to container size changes
Room Positions
Rooms use server-provided coordinates from the coord field in the JSON area file. Each room has x, y, z coordinates that map directly to the 3D scene. The scripts/check-map.py BFS validator checks for coordinate collisions and unreachable rooms.
Level Editor
A separate 3D editor at /mud/editor shares the map renderer. Features:
- Click room cubes to select
- Property panel: edit name, description, sector, flags
- Exit management: add/remove connections between rooms
- NPC editing panel
- Search bar with highlight
- Saves via admin API (
/mud/api/admin/save-room,/mud/api/admin/save-npc)
Fog of war: Only rooms the player has visited are in the client’s room cache. Unexplored exits show as ? indicators. The server sends room data on arrival — the client adds it to its local map cache. This cache persists in localStorage for citizens, not for guests.
Map Interaction
- Click a room: Shows room name tooltip
- Click an adjacent room: Sends movement command
- Scroll/pinch: Zoom in/out
- Drag: Pan the map view
Layout & Responsive Design
Desktop (>1024px)
┌─────────────────────────────────────────────────────┐
│ [World Name] [Level 15 Warrior] [⚙] │ ← Header
├────────────────────────────────┬────────────────────┤
│ │ ┌────────────────┐ │
│ │ │ HP ████░░ 85% │ │ ← Status
│ │ │ MN ██████ 100%│ │
│ You are in the Dark Forest. │ │ MV ████░░ 80% │ │
│ Ancient oaks tower overhead, │ └────────────────┘ │
│ their gnarled branches │ ┌────────────────┐ │
│ blocking the pale moonlight. │ │ ┌──┐ │ │
│ │ │ ┌──┤ ├──┐ │ │ ← Map
│ A snarling wolf circles you. │ │ │ └──┘ │ │ │
│ │ │ └──┤@@├──┘ │ │
│ Exits: [north] [east] [south] │ │ └──┘ │ │
│ │ └────────────────┘ │
│ > kill wolf │ ┌────────────────┐ │
│ You swing your sword! │ │ Online (7) │ │ ← Users
│ Your slash hits wolf for 12! │ │ Aragorn (L45)│ │
│ Wolf bites you for 8! │ │ Luna (L12)│ │
│ │ │ [G] Wandr (L5)│ │
│ │ └────────────────┘ │
├────────────────────────────────┴────────────────────┤
│ [Game] [Say] [Tell] [Gossip] [Newbie] │ ← Channels
│ Aragorn gossips 'anyone want to group?' │
├─────────────────────────────────────────────────────┤
│ > _ │ ← Input
└─────────────────────────────────────────────────────┘
Mobile (<768px)
Side panel collapses. Map, users, and status accessible via tab icons above the main text area. Channel tabs remain at bottom.
┌──────────────────────────┐
│ Dark Forest L15 85HP │ ← Compact header w/ vitals
├──────────────────────────┤
│ [📜] [🗺️] [👥] [📊] │ ← Tab icons: log, map, users, stats
├──────────────────────────┤
│ │
│ You are in the Dark │
│ Forest. Ancient oaks │
│ tower overhead... │
│ │
│ A snarling wolf circles │
│ you. │
│ │
│ Exits: [N] [E] [S] │
│ │
│ > kill wolf │
│ You swing your sword! │
│ Your slash hits wolf! │
│ │
├──────────────────────────┤
│ [Game][Say][Tell][Gossip]│ ← Channel tabs
├──────────────────────────┤
│ > _ │ ← Input
└──────────────────────────┘
Accessibility
Drawing from doc 08 (MUD Accessibility):
Screen Reader Support
<!-- Main text log: aria-live so screen readers announce new lines -->
<div role="log" aria-live="polite" aria-label="Game output">
<!-- log entries appended here -->
</div>
<!-- Status: aria-live but lower priority -->
<div role="status" aria-live="polite" aria-label="Character status">
HP: 85 of 100. Mana: 50 of 50. Moves: 80 of 100.
</div>
<!-- Critical alerts (near death, quest complete) -->
<div role="alert" aria-live="assertive">
<!-- only for urgent messages -->
</div>
<!-- Map: text alternative for screen readers -->
<div class="map-container" aria-hidden="true"></div>
<div class="sr-only" aria-label="Map">
You are in the Dark Forest. Exits: north to Mountain Pass, east to River Crossing, south to Village Gate.
</div>
<!-- Online users -->
<div role="complementary" aria-label="Online players">
<ul>
<li>Aragorn, level 45 warrior, in Mountain Pass</li>
<li>Luna, level 12 mage, in Dark Forest</li>
</ul>
</div>
Keyboard Navigation
| Key | Action |
|---|---|
Enter | Submit command |
Up/Down | Command history |
Tab | Cycle focus: input → channels → side panels |
Escape | Close modal (inventory, character sheet) |
/ | Focus command input from anywhere |
Ctrl+1-5 | Switch channel tabs |
Screen Reader Mode
A toggle that:
- Hides the 3D map, shows text-based room exit description instead
- Reduces
aria-livechattiness (batches combat updates) - Adds
[Combat],[Room],[Chat]prefixes to log entries for easier parsing - Disables animations and visual-only indicators
Color Palette
:root {
/* Background */
--bg-primary: #1a1a2e; /* deep navy */
--bg-secondary: #16213e; /* panel backgrounds */
--bg-input: #0f0f1a; /* input field */
/* Text */
--text-primary: #e0e0e0; /* room descriptions, default text */
--text-dim: #888888; /* system messages, timestamps */
--text-bright: #ffffff; /* room names, important */
/* Game colors — loosely ANSI-inspired */
--color-combat: #ff6b6b; /* combat messages */
--color-say: #51cf66; /* local speech */
--color-tell: #cc5de8; /* private messages */
--color-gossip: #ffd43b; /* world chat */
--color-quest: #ff922b; /* quest updates */
--color-xp: #22b8cf; /* XP gains */
--color-loot: #fcc419; /* item/gold pickups */
--color-system: #74c0fc; /* system messages */
--color-error: #ff4444; /* errors */
--color-newbie: #69db7c; /* help channel */
/* UI */
--hp-bar: #ff6b6b;
--mana-bar: #5c7cfa;
--move-bar: #51cf66;
--border: #333355;
--highlight: #ffd700; /* gold for current room, selections */
}
Project Structure
mud-client/
├── index.html
├── vite.config.ts
├── tsconfig.json
├── package.json
│
├── src/
│ ├── index.tsx # entry point, mount <App>
│ ├── App.tsx # top-level layout, auth routing
│ │
│ ├── lib/
│ │ ├── eyre.ts # Eyre HTTP/SSE wrapper
│ │ ├── commands.ts # command parser (aliases, history)
│ │ └── ansi.ts # ANSI color code → CSS class converter
│ │
│ ├── state/
│ │ ├── game.ts # signals: hp, mana, room, combat state
│ │ ├── character.ts # store: stats, inventory, equipment
│ │ ├── chat.ts # signals: channel messages
│ │ ├── map.ts # store: explored rooms, positions
│ │ └── session.ts # signal: auth state, guest vs citizen
│ │
│ ├── components/
│ │ ├── layout/
│ │ │ ├── GameWindow.tsx
│ │ │ ├── MainPane.tsx
│ │ │ ├── SidePanel.tsx
│ │ │ └── MobileLayout.tsx
│ │ │
│ │ ├── game/
│ │ │ ├── TextLog.tsx # scrolling game output
│ │ │ ├── CommandInput.tsx # input + history + autocomplete
│ │ │ ├── StatusBar.tsx # HP/mana/moves bars
│ │ │ ├── RoomInfo.tsx # room name, exits, contents
│ │ │ └── CombatTracker.tsx # enemy HP, combat state
│ │ │
│ │ ├── map/
│ │ │ ├── MapPanel.tsx # Three.js 3D map component
│ │ │ ├── Editor.tsx # 3D level editor component
│ │ │ └── map-layout.ts # BFS room position algorithm
│ │ │
│ │ ├── social/
│ │ │ ├── OnlineUsers.tsx
│ │ │ ├── ChannelTabs.tsx
│ │ │ └── Channel.tsx
│ │ │
│ │ ├── modals/
│ │ │ ├── CharacterSheet.tsx
│ │ │ ├── Inventory.tsx
│ │ │ ├── QuestLog.tsx
│ │ │ └── Settings.tsx
│ │ │
│ │ └── auth/
│ │ ├── LoginScreen.tsx
│ │ ├── GuestCreate.tsx
│ │ └── CitizenAuth.tsx
│ │
│ └── styles/
│ ├── global.css
│ ├── variables.css # color palette, spacing
│ ├── text-log.css
│ ├── map.css
│ └── mobile.css
│
└── public/
├── favicon.ico
└── sounds/ # optional: combat dings, notification chimes
Build & Deployment
Development
npm create vite@latest mud-client -- --template solid-ts
cd mud-client
npm install
npm run dev # dev server at localhost:3000
Production Build
npm run build # outputs to dist/
Deployment to Urbit
The built dist/ folder gets served as a glob by the %mud-world agent via Eyre. The agent’s on-peek handles /mud requests by serving the static files. This is the standard Urbit frontend deployment pattern — same as %groups, %talk, etc.
1. Build frontend → dist/
2. Package as glob (tar)
3. Upload to ship
4. %mud-world agent serves it on /mud path
5. Player visits https://your-ship.urbit.org/mud
Open Decisions
Command autocomplete: Fuzzy match against known commands? Or just prefix match? Do we show a dropdown or just complete on Tab?
Sound: Optional sound effects for combat, notifications, tells? Start without, add later? Use Web Audio API or just
<audio>elements?Theming: Support light mode? Probably not — dark theme is the MUD aesthetic. But accessibility guidelines suggest offering it.
Offline/reconnect: When SSE disconnects, show overlay? Auto-reconnect with backoff? What state do we preserve vs refetch?
Local storage: Cache explored map data for citizens in localStorage? What about guest map caches?
Mobile input: Virtual keyboard covers the screen on mobile. How do we handle this? Sticky input at bottom? Quick-action buttons for common commands (N/S/E/W/look/flee)?
Implementation Notes (2026-03-29)
The following changes were made during implementation that supersede or extend the original design:
Map: Three.js instead of Canvas 2D
The map panel was upgraded from Canvas 2D to Three.js WebGL. Rooms render as colored cubes, connections as lines. Camera at (-35, 35, 35) with OrbitControls. Cardinal direction labels (N/S/E/W). Raycasting for hover/tap tooltips showing room names. NPC, mob, and note indicators on room cubes. Search bar with highlight. ResizeObserver for responsive sizing.
Level Editor
A 3D level editor at /mud/editor with room selection, property panel (name, description, sector, flags), exit management, NPC editing, and search. Uses the admin API endpoints (/mud/api/admin/world-data, /mud/api/admin/save-room, /mud/api/admin/save-npc).
Mobile Responsive
Input bar moved outside .app div as a sibling element with position: fixed to avoid overflow: hidden ancestor issues. interactive-widget=resizes-content viewport meta tag for Android Chrome. Mobile map overlay toggle. Tap-to-toggle tooltips on map cubes.
Additional Features
- Speedwalk:
3n2e1usyntax expands to repeated directions. Chain movementnnnwsealso supported. 150ms delay between moves. - Tab completion: Ghost text suggestions for commands.
- Themes: Dark (default), light, green (classic terminal) via settings panel.
- Room notes:
note <text>command, stored in localStorage, displayed on room entry. - PWA: manifest.json and service worker for installability.
- CDN: Frontend JS/CSS served from nisfeb.com with CORS headers.
Voice Input (Whisper STT)
- OpenAI Whisper API for speech-to-text command input
- Push-to-talk mic button in the command input area (tap to start recording, tap again to stop)
voiceToCommandtranslator converts natural language transcription to MUD commands (e.g. “go north” →north, “attack the goblin” →kill goblin)- Requires an OpenAI API key configured in the Settings modal
- Mic button is only visible when an API key is set — no broken UI for users without keys
Pre-generated Audio
- Room narration audio plays on room entry (one-shot playback, not looped)
- NPC dialogue audio plays when the player uses
talkoraskcommands - Audio toggle in Settings modal (off by default, opt-in)
- Uses programmatic
Audio()object playback to avoid browser autoplay restrictions (user interaction gates first play)
Editor Enhancements
- Shift+drag for vertical room movement (adjusts Z coordinate in the 3D editor)
- Auto-connect: exits are automatically created or removed based on room adjacency when dragging rooms. If two rooms end up in adjacent grid positions, matching exits are added; if dragged apart, exits are removed.
- Ctrl+Z / Cmd+Z undo with 50 levels of history. Tracks room property changes, exit edits, and position moves.
- Compass labels follow the camera target position (not fixed at world origin)
- Grid plane follows the camera target so it’s always visible under the working area
- JSON export includes
min-levelandmax-levelfields from the area metadata
Admin
- Star icon (☆) in the header bar, visible only for admin users
- Clicking opens the
/mud/admindashboard in a new browser tab - Admin status determined by
is_adminfield from the/mud/api/statusendpoint - Non-admin users never see the icon — no “access denied” dead ends