# HUD and UI

> The HUD pieces in @/engine (TopBar, RoundButton, FinishCard, Banner, DebugRow, the Skia Gauge) and the game UI kit in @/engine/ui (SF Symbol icons, bundled fonts, rolling counters, press and pop motion, sprite-sheet images).

A game's HUD and menus are React Native views drawn over the canvas. The engine gives you two sets of pieces:

- **HUD chrome** in `src/engine/hud/`, exported from `@/engine`: a top bar with an exit button, round buttons, a finish card, a banner, a development row, and a Skia speedometer.
- **The game UI kit** in `src/engine/ui/`, imported from `@/engine/ui`: icons, bundled game fonts, rolling number counters, press and pop motion, floating "+10" text and sprite-sheet images.

Use the chrome to get a playable screen fast; use the kit to give the game its own look.

## Smallest working example

A HUD with an exit button, a lives counter and a score that rolls when it changes, in one bundled font family:

```tsx
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { TopBar, type GameScreenProps } from '@/engine';
import { Bump, Counter, GAME_ICONS, Icon, useGameFonts } from '@/engine/ui';
import { nunitoBlack } from '@/engine/ui/fonts/nunitoBlack';
import { nunitoBold } from '@/engine/ui/fonts/nunitoBold';

const fonts = { heavy: nunitoBlack.family, light: nunitoBold.family };

export const Hud = ({ onExit, score, lives }: GameScreenProps & { score: number; lives: number }) => {
  const ready = useGameFonts(nunitoBlack, nunitoBold);
  if (!ready) return null;
  return (
    <View style={StyleSheet.absoluteFill} pointerEvents="box-none">
      <TopBar title="Lantern Run" status="Find the lanterns" rightLabel="LIVES" rightIcon="heart.fill" rightIconColor="#FF5C7A" rightValue={String(lives)} onExit={onExit} />
      <View style={{ position: 'absolute', top: 110, alignSelf: 'center', flexDirection: 'row', alignItems: 'center', gap: 6 }} pointerEvents="none">
        <Icon name={GAME_ICONS.star} size={22} color="#FFD23F" />
        <Bump value={score}>
          <Counter value={score} fontFamily={fonts.heavy} fontSize={28} color="#FFFFFF" />
        </Bump>
      </View>
    </View>
  );
};
```

## HUD chrome (`@/engine`)

These pieces use the template's `colors` from `src/theme.ts`. Keep per-frame numbers out of them: they are React state, updated on change and at most a few times a second.

### `TopBar`

Exit button, title and status in the middle, an optional value on the right.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `title` | `string` | required | Small caps line |
| `status` | `string` | required | The big centre line (`testID` `hud-status`) |
| `rightLabel` | `string` | none | Small label on the right (`TIME`, `LIVES`) |
| `rightValue` | `string` | none | The value under it (`testID` `hud-right`) |
| `rightIcon` | `IconName` | none | A symbol before the value |
| `rightIconColor` | `string` | `colors.text` | Its colour |
| `onExit` | `() => void` | required | The exit button (`testID` `btn-exit`) |
| `exitLabel` | `string` | `'Exit game'` | Its accessibility label |
| `children` | `React.ReactNode` | none | Extra buttons on the right (camera, mute) |

### `RoundButton`

A small round button for the top bar.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `testID` | `string` | required | For automation |
| `accessibilityLabel` | `string` | required | What it does |
| `onPress` | `() => void` | required | |
| `icon` | `IconName` | none | A symbol (preferred over text children) |
| `children` | `React.ReactNode` | none | Text, when there is no icon |
| `active` | `boolean` | `false` | Fills it with `accent` |
| `accent` | `string` | `colors.accent2` | The active colour |

### `FinishCard`

A centred card for the end of a round: a big word, a few lines, Play again and Back to games.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `title` | `string` | required | The big word (`testID` `hud-finished`) |
| `lines` | `string[]` | required | Lines under it |
| `restartLabel` | `string` | required | The primary button (`testID` `btn-restart`) |
| `onRestart` | `() => void` | required | |
| `onExit` | `() => void` | required | The secondary button, labelled `Back to games` (`testID` `btn-finish-exit`) |
| `accent` | `string` | `colors.accent2` | The primary button's colour |

### `Banner`

A big centred word (a countdown, GO, a hint). Props: `text` (an empty string renders nothing), `color` (default `colors.text`), `testID` (default `hud-banner`).

### `DebugRow`

The development row: an AUTO toggle (`testID` `btn-autopilot`) and a telemetry line (`testID` `hud-debug`) that device automation reads. Props: `debug` (the line; `undefined` hides the row, which is how you keep it out of production), `autopilot`, `onToggleAutopilot`, `accent`.

### `Gauge`

A Skia speedometer: a 240 degree arc and a number. Reanimated shared values drive the arc and the label on the UI thread, so the game loop never re-renders React.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `ratio` | `SharedValue<number>` | required | Fill, 0 to 1 |
| `value` | `SharedValue<number>` | required | The number in the middle |
| `unit` | `string` | `'km/h'` | The line under it |
| `size` | `number` | `140` | Width and height in points |
| `color` | `string` | `colors.accent2` | The arc colour |

## The game UI kit (`@/engine/ui`)

### `Icon` and `GAME_ICONS`

Every icon is an SF Symbol drawn with `expo-symbols`. On iOS it is the native symbol; on Android and the web the same name draws its Material Symbol twin from `src/engine/ui/androidSymbols.ts`. `name` is type-checked against the SF Symbols list, so a typo fails `tsc`.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `name` | `IconName` | required | An SF Symbol name |
| `size` | `number` | `24` | Points |
| `color` | `ColorValue` | `'#FFFFFF'` | Tint |
| `weight` | `SymbolWeight` | `'bold'` | Match the text next to it |
| `type` | `'monochrome'`, `'hierarchical'`, `'palette'`, `'multicolor'` | `'monochrome'` | Rendering mode; `palette` takes `colors` |
| `colors` | `ColorValue[]` | none | For `palette` |
| `animationSpec` | `AnimationSpec` | none | A native symbol effect; it plays when the object changes, so pass a new one per event |
| `style` | `StyleProp<ViewStyle>` | none | |
| `accessibilityLabel` | `string` | none | Only for icons that stand alone; icons next to a label are hidden from accessibility |

`GAME_ICONS` maps common game concepts to symbols so every game names them the same way: `close`, `pause`, `play`, `restart`, `back`, `next`, `settings`, `soundOn`, `soundOff`, `music`, `haptics`, `share`, `help`, `hint`, `camera`, `lock`, `unlock`, `check`, `score`, `star`, `starEmpty`, `trophy`, `crown`, `medal`, `coin`, `gem`, `gift`, `level`, `finish`, `record`, `leaderboard`, `combo`, `heart`, `heartEmpty`, `shield`, `energy`, `timer`, `stopwatch`, `speed`, `boost`, `slow`, `target`, `key`, `map`, `pin`, `players`, `player`, `online`, `bomb`, `magic`, `moon`, `sun`, `water`, `ice`, `leaf`, `dice`, `cards`, `gamepad`.

```tsx
<Icon name={GAME_ICONS.coin} size={20} color="#FFD23F" />
```

### Fonts: `useGameFonts` and the bundled faces

The engine bundles 18 open-licence (OFL) faces, one module per face in `@/engine/ui/fonts/`, so a game's bundle carries only the faces it imports. Each is a `GameFont`:

```ts
interface GameFont {
  family: string; // the fontFamily it registers as, e.g. 'Nunito_900Black'
  source: number; // the .ttf asset, a require() id
}
```

| Family | Faces (module names) |
| --- | --- |
| Chakra Petch | `chakraPetchBold`, `chakraPetchSemiBold` |
| Orbitron | `orbitronBlack`, `orbitronBold` |
| Fredoka | `fredokaBold`, `fredokaSemiBold` |
| Nunito | `nunitoBlack`, `nunitoBold` |
| Cinzel | `cinzelBlack`, `cinzelBold` |
| Single faces | `russoOne`, `bungee`, `lilitaOne`, `bangers`, `blackOpsOne`, `vt323`, `pressStart2P`, `creepster` |

`useGameFonts(...faces)` registers the faces with `expo-font` and returns `true` once they are ready, or once loading failed, so a game never waits forever. Render the title screen after that so text does not jump from the system font. It warns in development when the faces come from more than one family: the template's rule is one family per game, with hierarchy from its weights and sizes. `familyOf(font)` returns the family part of a face (`ChakraPetch_700Bold` becomes `ChakraPetch`).

Use `font.family` as `fontFamily` and never set `fontWeight` on it: the weight is part of the face. For Skia text, load the same face with Skia's `useFont(font.source, size)`.

### `Counter`

Every number in the UI that changes: the changed digits roll to the new value on the UI thread (built on react-native-laminar).

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `value` | `number` | required | The number |
| `fontFamily` | `string` | required | The game's face |
| `fontSize` | `number` | required | |
| `color` | `string` | required | |
| `kind` | `'roll'`, `'ticker'`, `'slots'` | `'roll'` | `roll` for numbers that grow, `ticker` for many changes a second, `slots` for a rare big reveal |
| `prefix`, `suffix` | `string` | `''` | Text around the number (`suffix=" m"`) |
| `format` | `(n: number) => string` | thousands separators | How the number becomes digits |
| `from` | `number` | none | Shown first, then animated to `value` after mount (a results score counting up from 0) |
| `align` | `'left'`, `'center'`, `'right'` | `'left'` | |
| `style`, `testID` | | | On the wrapping view |

Use Laminar only through `Counter`: importing `react-native-laminar` directly makes TypeScript check its raw source, which fails the strict typecheck.

### Motion pieces

| Export | What it does |
| --- | --- |
| `PressScale` | The base for every menu and HUD button: a `Pressable` that sinks while held (`scale`, default `SCALE.press`) and fires a haptic on press-in (`haptic`: `'tap'` default, `'select'`, `'heavy'` or `false`). Takes every `Pressable` prop; `hitSlop` defaults to 8 |
| `Bump` | Wrap a counter: it swells to `amount` (default `SCALE.pop`) and settles each time `value` changes, never on mount |
| `useShake()` | Returns `{ style, shake(strength = 5) }`. Put `style` on an `Animated.View`; `strength` is points of travel (3 a bump, 6 a crash, 10 a boss hit) |
| `useFloaters()` and `Floaters` | `const { floaters, spawn } = useFloaters()`; `spawn(text, x, y, color?)` adds a "+10" that rises and fades over 750 ms; render `<Floaters items={floaters} textStyle={...} />` above the game. Keeps the last 12 |

Reduced motion (the system setting) is respected: `Bump` and `useShake` do nothing, `Floaters` fade in place.

### The motion vocabulary (`motion.ts`)

Shared values so all games move alike, exported from `@/engine/ui`:

| Export | Value |
| --- | --- |
| `DURATION` | `blink` 50, `press` 120, `small` 180, `panel` 260, `flight` 420 (ms) |
| `SCALE` | `press` 0.96, `pressBig` 0.92, `pop` 1.08, `popBig` 1.15, `loop` 1.02, `alert` 1.06 |
| `STAGGER` | 70 ms between items arriving one after another |
| `SPRING` | `settle`, `snap`, `pop` (Reanimated spring configs, damping ratio 0.8 to 1) |
| `EASE_OUT`, `EASE_IN_OUT` | Easing curves for `withTiming` |
| `CSS_EASE_OUT`, `CSS_EASE_IN_OUT` | The same curves for Reanimated CSS transitions and animations |
| `rise(delay = 0, duration = 260)` | An entrance: fade in, rise 10 points, grow from 0.96. Use as `entering={rise(i * STAGGER)}` |
| `land(delay = 0)` | A banner's entrance: fade in while landing from 1.15 |
| `leave(duration = 180)` | The exit: a fade |
| `popTo(amount = SCALE.pop)` | A pop for a shared value at rest at 1: `scale.set(popTo())` |
| `transition(property = 'transform', duration = 120)` | A CSS transition object to spread into an `Animated` style |
| `breathe(duration = 1800)` | An idle scale loop to spread into an `Animated` style |
| `pulse(duration = 500)` | An urgent scale loop (low time, low health) |

`rise`, `land` and `leave` return a new animation each call: call them in JSX, do not share one instance.

### `SheetImage` and `isPlaceholderImage`

One cell of a sprite sheet as a React Native view: resource icons, reward items, portraits. It crops the cell the CSS-sprite way, so nothing is decoded per cell.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `source` | `number` | required | The `require()`d sheet |
| `cols`, `rows` | `number` | required | The grid |
| `frame` | `number` | required | Cell index, reading order |
| `size` | `number` | required | Rendered size in points (cells are square) |
| `fallback` | `React.ReactNode` | `null` | Shown while the sheet is a 1 by 1 placeholder image |
| `style`, `accessibilityLabel` | | | On the wrapping view |

`isPlaceholderImage(source)` is true when a bundled image is 1 by 1 pixels: the hosted Moonspell pipeline writes such placeholders before the real art is generated. For sprites in the 3D scene, see `Billboard` in [Models and scenery](https://docs.moonspell.app/engine/models-and-scenery.md).

## Gotchas

- No emoji and no text symbols as icons (a cross, heart or star character): they render differently per device and cannot be tinted. Use `Icon`. SF Symbols has no swords or skulls; draw those, or pick the nearest symbol.
- An SF Symbol missing from `androidSymbols.ts` draws a plain circle on Android and the web. When you use a symbol that is not in that map, add its Material Symbol name (any name from fonts.google.com/icons).
- Never pass a Reanimated `Keyframe` to `entering` in a game. In a browser, Reanimated's web build pins the element where it stood when the keyframe ends, and the next items slide under it. `rise` and `land` use built-in entrances on the web for this reason.
- `HudChrome` and `Gauge` use the template's `src/theme.ts` colours and the system font. For a game with its own look, build its screens from the UI kit with its palette and font.
- In a browser `matchFont()` in Skia answers with the engine's Nunito, so the `Gauge` label is Nunito there ([Platform layer](https://docs.moonspell.app/engine/platform-layer.md)).
- Haptics come from `expo-haptics`. Failures are caught, so a platform without haptics stays silent.
