Game UI · React · TypeScript · renderer independent

A game HUD is an overlay, an input arbiter, and a pile of math — not a web page.

The markup for a health bar is trivial. What breaks projects is everything around it: clicks leaking into the canvas, the inventory hotkey firing while a text field is focused, a controller that cannot reach the close button, and an art pass that requires rewriting components. Separate the overlay, the pure math, the engine bindings, and the skin, and each of those problems stays solved.

Game interface state is not simulation state.

A quest exists whether or not a tracker is mounted. An item stack exists whether or not the bag window is open. Keep three layers apart and the HUD stops being the place where gameplay bugs hide:

Engine state

Quests, dialogue, inventory, achievements — owned by headless engines and persisted in saves.

Chrome state

Which windows are open, stacking order, focus, and toasts — owned by a UI store, never saved as progress.

Frame state

Cast timers, damage numbers, and nameplate positions — recomputed per frame, not stored globally.

Presentation

Tokens, skins, and layout — swappable CSS, so art direction never forces a logic rewrite.

This split is what makes the UI layer reusable. @overworld-engine/ui ships with zero runtime dependencies on other engine packages: components that display quests or inventories accept structurally typed props instead of importing those packages, and a dependency rule in CI keeps it that way.

Mount one pointer-transparent overlay above the canvas, then anchor widgets into it.

import { Hud, Bar, Hotbar, QuestTracker } from '@overworld-engine/ui'
import '@overworld-engine/ui/styles.css'
import '@overworld-engine/ui/themes/hextech.css'

<div style={{ position: 'relative', inset: 0 }}>
  <Canvas>{/* React Three Fiber scene */}</Canvas>

  <Hud theme="hextech">
    <Hud.Anchor anchor="top-left">
      <Bar value={hp} max={100} variant="hp" label="HP" showValue />
      <QuestTracker engine={quests} max={3} />
    </Hud.Anchor>
    <Hud.Anchor anchor="bottom">
      <Hotbar>{slots}</Hotbar>
    </Hud.Anchor>
  </Hud>
</div>

The overlay itself does not receive pointer events; only the widgets inside an anchor do. That single rule prevents the most common HUD defect in canvas games, where a full-screen interface div silently swallows every click meant for the world. Nine anchor positions cover the standard screen regions, and the overlay sits above the canvas inside one relatively positioned container instead of relying on ad-hoc z-index values scattered across the app.

See how the overlay fits the wider React Three Fiber architecture →

Export the hard parts as functions that render nothing.

import {
  castProgress,
  buffSweepPct,
  formatBuffTime,
  positionTooltip,
  edgeAnchor,
} from '@overworld-engine/ui'

// Normal casts fill 0 → 100%; channels drain 100 → 0%.
castProgress(1.2, 2, { channel: true })
// → { fillPct: 40, remainingSeconds: 0.8 }

// Compact countdowns: "1:23", "45s", "3.2".
formatBuffTime(83)   // '1:23'
formatBuffTime(9.97) // '10s'

// Flip above → below when the tooltip would clip the viewport.
positionTooltip(anchorRect, tipSize, viewport)
// → { x: 312, y: 84, placement: 'above' }

// Pin an off-screen objective arrow to the screen edge.
edgeAnchor(bearingRadians)
// → { xPct, yPct, rotationDeg }

Every one of these is a pure function of numbers. They can be unit tested without a DOM, a renderer, or a test-library harness, and they stay correct when the markup around them is replaced. Boundary behavior is the reason to centralize them: rounding a countdown at 59.5 seconds should produce 1:00 rather than 60s, and a tooltip near the top edge should flip rather than clip. Those are the details every project re-implements slightly wrong.

The same approach covers selectors. trackerRows joins quest definitions with active progress, drops hidden objectives, skips actives with no definition, defaults missing progress to zero, and sorts oldest quest first — as a function you can assert on directly.

Bind components to engines by shape, not by import.

// The UI package declares only what it reads.
export interface ReadableStore<T> {
  getState(): T
  getInitialState(): T
  subscribe(listener: (s: T, prev: T) => void): () => void
}

export interface QuestEngineLike {
  store: ReadableStore<{
    definitions: Record<string, QuestDefinitionLike>
    active: Record<string, ActiveQuestLike>
    completed: readonly string[]
  }>
}

// A real engine satisfies it structurally — no import required.
const quests = createQuestEngine({ quests: [], conditions, effects })

<QuestTracker engine={quests} />
<InventoryWindow engine={inventory} />
<DialogueBox engine={dialogue} />
<ToastViewport store={useToastStore} anchor="top-right" />

Structural typing keeps the UI package installable on its own and keeps the coupling honest: a component that only reads quest definitions and progress declares exactly that, so any store of the same shape — a mock, a replay harness, a networked mirror — works in its place. Components subscribe through selectors, so a health change does not re-render the quest log.

Compare the store, selector, and frame-loop boundaries in detail →

Give windows one registry with real z-order instead of scattered booleans.

import {
  GameWindow,
  useUiStore,
  selectAnyWindowOpen,
} from '@overworld-engine/ui'

const toggleWindow = useUiStore((s) => s.toggleWindow)
const anyOpen = useUiStore(selectAnyWindowOpen)

<button onClick={() => toggleWindow('inventory')}>Bag</button>

<GameWindow id="inventory" title="Inventory">
  <SlotGrid columns={5}>{slots}</SlotGrid>
</GameWindow>

Opening a window assigns it the next topmost z value; clicking an already-open window raises it again; closing is a no-op for unknown ids. The registry is keyed by string, so a game adds a map, a skill tree, or a settings panel without touching the store's shape. Because the reducers are pure functions over { windows, topZ }, stacking rules are testable without rendering anything.

selectAnyWindowOpen is the hook that connects interface state back to gameplay: when any window is open, the host can mute movement input, pause ambient interaction prompts, or dim the world.

Decide who owns a key press before the HUD grows a third overlay.

import {
  KEYBOARD_PRIORITY,
  useKeyboardLayer,
  useHotkey,
} from '@overworld-engine/input'

function DialogueOverlay() {
  // Blocks lower-priority handlers and acquires the shared input lock.
  useKeyboardLayer('dialogue', KEYBOARD_PRIORITY.NPC_DIALOGUE, {
    lockInput: true,
  })
  ...
}

function GameControls() {
  useHotkey('i', () => toggleWindow('inventory'), {
    priority: KEYBOARD_PRIORITY.GAME_CONTROLS,
  })
}

Layers are registered by id with a numeric priority; a higher layer blocks lower handlers, optionally only for named keys. Registration is tied to component lifetime, so an overlay that unmounts cannot leave the game deaf. Hotkeys ignore key presses that originate in editable elements by default, which removes the classic bug where typing a character name opens the inventory.

The shared input lock covers non-keyboard surfaces too — a cutscene that acquires it also suppresses the virtual joystick on touch builds.

See input locks applied to proximity interaction and dialogue →

Controller navigation and accessibility are the same problem.

import {
  FocusProvider,
  Focusable,
  useGamepadFocus,
  useSpatialFocus,
} from '@overworld-engine/ui/focus'

function PauseMenu() {
  // Left stick / D-pad move focus; A dispatches Enter.
  useGamepadFocus({ deadZone: 0.5, repeatMs: 180 })
  const { setFocus } = useSpatialFocus()

  return (
    <FocusProvider>
      <Focusable focusKey="resume" onEnterPress={resume}>
        {({ ref, focused }) => (
          <Button ref={ref} data-focused={focused}>
            Resume
          </Button>
        )}
      </Focusable>
      <Focusable focusKey="quit" onEnterPress={quit}>
        {({ ref, focused }) => (
          <Button ref={ref} data-focused={focused}>
            Quit
          </Button>
        )}
      </Focusable>
    </FocusProvider>
  )
}

Spatial navigation ships on a separate entry point with an optional peer dependency, so a mouse-and-keyboard-only game never pays for it. The gamepad bridge polls with a dead zone and a repeat interval and no-ops when no pad is connected, so the same build runs on desktop and console-style input without branching.

Standard semantics carry the rest. Resource and cast bars expose role="progressbar" with value bounds; modal surfaces own the backdrop, trap Tab, dismiss on Escape, and restore focus on close; decorative icons are marked aria-hidden; dismiss controls carry labels. A HUD that a screen reader can describe is usually the same HUD a controller can traverse.

Reskin with CSS variables, and escape the markup with one prop.

import '@overworld-engine/ui/styles.css'
import '@overworld-engine/ui/themes/xianxia.css'

// Switch the whole HUD's skin from one attribute.
<Hud theme="xianxia">...</Hud>

// Render your own element while keeping behavior.
<Button asChild>
  <a href="/inventory">Open inventory</a>
</Button>

<Modal.Close asChild>
  <Button variant="ghost">Cancel</Button>
</Modal.Close>

A base stylesheet defines --ow-* tokens; each skin overrides them under a data-ow-theme attribute. Four skins ship as examples — hextech, pixel, tactical, and xianxia — but the contract that matters is the token set, because a game with its own art direction overrides tokens rather than forking components.

When markup itself must change, asChildmerges a component's props and ref onto a single child element you provide. The underlying slot primitive is exported, so your own components can offer the same escape hatch instead of accretingas props and wrapper divs.

Keep world-to-screen projection out of the widgets.

import { Nameplate, WaypointIndicator } from '@overworld-engine/ui'

// The host projects world → screen each frame and positions the plate.
<div style={{ position: 'absolute', left: x, top: y }}>
  <Nameplate name="Bandit" hp={62} hpMax={100} level={7} showLevel />
</div>

// Bearing in radians, 0 = up, clockwise — the same value the
// minimap package's computeOffscreenIndicator() returns.
<WaypointIndicator angle={bearing} label="Elder" distance="42m" />

Nameplates, waypoint arrows, and compass strips are the seam where 3D and DOM meet, and the seam is where coupling accumulates. Components here accept plain screen-space numbers: a nameplate renders a name and a health bar and lets the host decide where it sits; a waypoint arrow converts a bearing into an edge position and rotation with the same pure function you can test in isolation. Projection stays in the frame loop, where it belongs, and the widgets stay renderer independent.

React game HUD and headless UI questions

What does “headless” mean for a game UI library?

Headless means the layout math, selectors, and state machines are exported as plain functions and stores that render nothing. A cast bar is a number pair turned into a fill percentage; a tooltip is a rectangle solver; a quest tracker is a join between definitions and progress. Styled components are a thin optional layer on top, so a project can keep the logic and replace every element.

Should HUD state live in the same store as game state?

No. Which windows are open, which panel is on top, and which element has focus are presentation state that no simulation should depend on. Keep them in a UI store, keep quests, inventory, and dialogue in their engines, and let the HUD subscribe to engine stores through selectors.

Why not use a general React component library for a game HUD?

Application component libraries assume a document that owns the pointer, scrolls, and takes keyboard focus. A HUD is an overlay above a running canvas: it must stay pointer-transparent except where widgets sit, arbitrate keys with gameplay controls, survive controller-only navigation, and reskin per art direction. Those constraints, not the button markup, are the hard part.

How do you make a React HUD usable with a gamepad?

Wrap the navigable region in a spatial-navigation provider, register each interactive element as focusable, and bridge stick and D-pad input to directional focus moves with a dead zone and a repeat interval. The same focus model then serves keyboard players, and modal surfaces trap focus while they are open.

Does a DOM overlay hurt frame rate?

Only if it re-renders like game state. Subscribe with narrow selectors so a health bar does not re-render the quest log, keep per-frame values such as cast timers in the component that displays them, and drive continuous motion with CSS transitions instead of React state updates every frame.

See the HUD wired to real engines in a complete slice

Movement, dialogue, quests, inventory, rewards, HUD, windows, and tests together.

Open the runnable React Three Fiber RPG starter →