Appearance
How to Build: HUD
The HUD is the only persistent UI element on screen, and it needs to be as unobtrusive as possible. This is a mobile game with a beautiful hex terrain — I don't want to clutter the viewport with health bars and skill panels. All combat feedback (HP bars, damage numbers) renders in-world, attached to entities. The HUD is just a collapsible menu bar at the bottom of the screen.
Tap to expand, pick what you need, tap to collapse. That's the whole interaction model.
Why React State, Not ECS
This might surprise you given that the rest of the UI system is ECS-driven. But the HUD's expand/collapse state and which menu is open are purely visual concerns — they don't affect game logic at all. No system needs to query "is the inventory panel open?" to make gameplay decisions.
Using React state here keeps things simple and avoids polluting the ECS world with UI bookkeeping:
typescript
const [expanded, setExpanded] = useState(false)
const [openMenu, setOpenMenu] = useState<MenuType | null>(null)
type MenuType = 'inventory' | 'equipment' | 'skills' | 'vendor' | 'checkpoints'The HUD reads from the ECS world (for things like the auto-battle toggle state), but its own visibility state lives in React.
The Overlay Layer
The HUD lives in a div positioned over the R3F Canvas. This is the HudOverlay component — the root of all screen-space UI:
typescript
function HudOverlay(): JSX.ElementIt renders as an absolutely positioned div covering the full viewport with pointer-events: none. Only interactive children (buttons, panels) set pointer-events: auto. This way, taps pass through to the 3D scene except where UI elements explicitly capture them.
Building the Collapsible Menu Bar
The menu bar sits at the bottom center of the screen. When collapsed, it shows a single "Menu" button. When expanded, it fans out to reveal everything:
typescript
function MenuBar(props: {
onOpenMenu: (type: MenuType) => void
}): JSX.ElementThe expanded state shows:
- 5 menu buttons: Inventory, Equipment, Skills, Shop, Map
- Auto-battle toggle: reads
AutoBattle.enabledfrom the player entity viauseTrait - Close button: collapses the bar
The Shop and Map buttons are HUD shortcuts — once the overworld hub is built as a physical location, these could move to in-world NPCs. For now, the HUD is the access point.
Notice how the menu bar component receives an onOpenMenu callback rather than managing panel state itself. The parent HudOverlay owns which panel is open, so it can enforce the "one panel at a time" rule.
The Auto-Battle Toggle
This is a small but important detail. The toggle reads the player's AutoBattle trait and displays the current state:
typescript
function toggleAutoBattle(player: Entity): voidWhen tapped, it flips AutoBattle.enabled. If enabling, it also calls reEnableAutoBattle to find the nearest road waypoint. If disabling, the hero just stops following the road and stands still until the player taps somewhere.
Mobile Touch Targets
This is a mobile-first game. Every button in the menu bar needs a minimum touch target of 44x44 pixels — that's Apple's Human Interface Guidelines minimum, and it's the difference between "easy to use" and "why did I open the inventory when I wanted skills."
Use responsive sizing that works on small screens. The bar itself should be horizontally scrollable or wrap gracefully if it doesn't fit. Test on a phone-sized viewport early and often.
File Structure
src/features/ui/
├── HudOverlay.tsx # Root UI layer outside the Canvas
├── MenuBar.tsx # Collapsible bar with toggle and buttons
└── MenuBarButton.tsx # Individual menu button componentWhat This System Depends On
- Menu Panels — the HUD buttons open panel overlays (inventory, equipment, and so on)
- No ECS systems needed. This is pure React UI that reads from world state.
We now have a way for the player to access all game menus from a clean, minimal interface. But the menu bar just opens panels — we still need to build the panels themselves.