HUD and UI
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:
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.
<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:
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.
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.tsdraws 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
Keyframetoenteringin 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.riseandlanduse built-in entrances on the web for this reason. HudChromeandGaugeuse the template'ssrc/theme.tscolours 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 theGaugelabel is Nunito there (Platform layer). - Haptics come from
expo-haptics. Failures are caught, so a platform without haptics stays silent.