Skip to main content

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.

PropTypeDefaultWhat it does
titlestringrequiredSmall caps line
statusstringrequiredThe big centre line (testID hud-status)
rightLabelstringnoneSmall label on the right (TIME, LIVES)
rightValuestringnoneThe value under it (testID hud-right)
rightIconIconNamenoneA symbol before the value
rightIconColorstringcolors.textIts colour
onExit() => voidrequiredThe exit button (testID btn-exit)
exitLabelstring'Exit game'Its accessibility label
childrenReact.ReactNodenoneExtra buttons on the right (camera, mute)

RoundButton​

A small round button for the top bar.

PropTypeDefaultWhat it does
testIDstringrequiredFor automation
accessibilityLabelstringrequiredWhat it does
onPress() => voidrequired
iconIconNamenoneA symbol (preferred over text children)
childrenReact.ReactNodenoneText, when there is no icon
activebooleanfalseFills it with accent
accentstringcolors.accent2The active colour

FinishCard​

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

PropTypeDefaultWhat it does
titlestringrequiredThe big word (testID hud-finished)
linesstring[]requiredLines under it
restartLabelstringrequiredThe primary button (testID btn-restart)
onRestart() => voidrequired
onExit() => voidrequiredThe secondary button, labelled Back to games (testID btn-finish-exit)
accentstringcolors.accent2The primary button's colour

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.

PropTypeDefaultWhat it does
ratioSharedValue<number>requiredFill, 0 to 1
valueSharedValue<number>requiredThe number in the middle
unitstring'km/h'The line under it
sizenumber140Width and height in points
colorstringcolors.accent2The 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.

PropTypeDefaultWhat it does
nameIconNamerequiredAn SF Symbol name
sizenumber24Points
colorColorValue'#FFFFFF'Tint
weightSymbolWeight'bold'Match the text next to it
type'monochrome', 'hierarchical', 'palette', 'multicolor''monochrome'Rendering mode; palette takes colors
colorsColorValue[]noneFor palette
animationSpecAnimationSpecnoneA native symbol effect; it plays when the object changes, so pass a new one per event
styleStyleProp<ViewStyle>none
accessibilityLabelstringnoneOnly 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
}
FamilyFaces (module names)
Chakra PetchchakraPetchBold, chakraPetchSemiBold
OrbitronorbitronBlack, orbitronBold
FredokafredokaBold, fredokaSemiBold
NunitonunitoBlack, nunitoBold
CinzelcinzelBlack, cinzelBold
Single facesrussoOne, 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).

PropTypeDefaultWhat it does
valuenumberrequiredThe number
fontFamilystringrequiredThe game's face
fontSizenumberrequired
colorstringrequired
kind'roll', 'ticker', 'slots''roll'roll for numbers that grow, ticker for many changes a second, slots for a rare big reveal
prefix, suffixstring''Text around the number (suffix=" m")
format(n: number) => stringthousands separatorsHow the number becomes digits
fromnumbernoneShown first, then animated to value after mount (a results score counting up from 0)
align'left', 'center', 'right''left'
style, testIDOn 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​

ExportWhat it does
PressScaleThe 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
BumpWrap 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 Floatersconst { 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:

ExportValue
DURATIONblink 50, press 120, small 180, panel 260, flight 420 (ms)
SCALEpress 0.96, pressBig 0.92, pop 1.08, popBig 1.15, loop 1.02, alert 1.06
STAGGER70 ms between items arriving one after another
SPRINGsettle, snap, pop (Reanimated spring configs, damping ratio 0.8 to 1)
EASE_OUT, EASE_IN_OUTEasing curves for withTiming
CSS_EASE_OUT, CSS_EASE_IN_OUTThe 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.

PropTypeDefaultWhat it does
sourcenumberrequiredThe require()d sheet
cols, rowsnumberrequiredThe grid
framenumberrequiredCell index, reading order
sizenumberrequiredRendered size in points (cells are square)
fallbackReact.ReactNodenullShown while the sheet is a 1 by 1 placeholder image
style, accessibilityLabelOn 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.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).
  • Haptics come from expo-haptics. Failures are caught, so a platform without haptics stays silent.