Utilities
Four small parts of the engine that every genre uses, all exported from @/engine:
| Part | Folder | For |
|---|---|---|
| Math | math.ts | Clamping, smoothing, angles, time strings, a seeded random generator |
| Paths | paths/ | Smooth curves in metres and things that move along them |
| Zones and signals | zones/ | Trigger areas without physics, and a typed event bus |
| Debug | debug/ | The development readout: fps, frame time, draw calls, lines from each module |
Math (math.ts)
| Export | What it does |
|---|---|
clamp(v, min, max) | v limited to the range |
lerp(a, b, t) | Linear blend |
damp(a, b, lambda, dt) | Frame-rate independent smoothing toward b; higher lambda is snappier. Use it instead of lerp with a fixed factor inside useFrame |
TAU | Math.PI * 2 |
angleDelta(a, b) | The shortest signed angle from a to b, in radians |
formatTime(seconds) | '01:05.3'; '--:--.-' for negative or non-finite values |
makeRng(seed) | A deterministic random function (mulberry32) returning 0 to 1, so procedurally placed scenery is the same on every load |
import { damp, makeRng } from '@/engine';
const rng = makeRng(11);
const trees = Array.from({ length: 40 }, () => ({ x: (rng() - 0.5) * 80, z: (rng() - 0.5) * 80 }));
export function followCamera(current: number, target: number, dt: number) {
return damp(current, target, 6, dt);
}
Paths (paths/)
A Path is a smooth curve through points, measured in metres: pointAt(d) is the point d metres along it, whatever the spacing of the control points. Moving platforms, camera dollies, patrol routes and the racing track all use the same type.
import React from 'react';
import { CameraDolly, PathFollow, createPath } from '@/engine';
// A slow orbit over the arena for the title screen (from the Lantern Tag example).
const ORBIT = createPath(
Array.from({ length: 12 }, (_, i) => [Math.cos((i / 12) * Math.PI * 2) * 24, 12, Math.sin((i / 12) * Math.PI * 2) * 24] as [number, number, number]),
{ closed: true },
);
const PATROL = createPath([[-6, 0, 0], [6, 0, 0], [6, 0, 6]], { curve: 'linear' });
export const TitleScene = ({ onTitle }: { onTitle: () => boolean }) => (
<>
<CameraDolly path={ORBIT} active={onTitle} speed={4} lookAt={{ x: 0, y: 0, z: 0 }} />
<PathFollow path={PATROL} speed={2} mode="pingpong" pause={0.5}>
<mesh position={[0, 0.5, 0]}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#FFB454" />
</mesh>
</PathFollow>
</>
);
createPath(points, options?)
points are [x, y, z] triples or { x, y, z } objects; at least two. Returns a Path (also constructible with new Path(points, options)).
| Option | Default | What it does |
|---|---|---|
closed | false | Join the end back to the start |
curve | 'centripetal' | 'centripetal' (no loops or cusps), 'catmullrom', 'chordal', or 'linear' (straight segments) |
tension | 0.5 | Catmull-Rom tension, for 'catmullrom' |
samples | one per half metre, 64 to 2048 | Size of the arc-length table (more makes closest more exact) |
Path member | What it does |
|---|---|
length, closed, points, curve | The path's length in metres, whether it loops, its control points, and three's curve |
pointAt(d, target?) | The point d metres along (wrapped on a closed path, clamped on an open one) |
tangentAt(d, target?) | The unit direction of travel there |
frameAt(d) | { position, tangent, right }; right lies on the ground plane |
tOf(d) | Metres to 0..1 |
sample(count) | Evenly spaced points |
closest(point, hint?, window?) | { distance, t, point, offset, lateral }: where a point is relative to the path (lateral is signed, + right of travel). Pass the last distance as hint to search near it |
Things that follow a path
| Export | What it is |
|---|---|
PathFollower | Pure state: new PathFollower(path, options), then step(dt) each frame; read distance, position(target?), tangent(target?); reset(start?); direction, done |
usePathFollower(path, options?) | A PathFollower stepped every frame for you |
PathFollow | Moves its children along a path. orient: 'yaw' (upright, default), 'full' (flyers), 'none' |
MovingPlatform | A platform solid for the character controller that moves along a path and carries its riders |
CameraDolly | A camera on rails for title screens and intros |
PathMesh | Draws a path: a ribbon on the ground or a tube in the air |
createMovingBox(path, size, options?) | The moving platform without React (a level box plus a follower) |
FollowerOptions (for PathFollower, usePathFollower, PathFollow and MovingPlatform):
| Option | Default | What it does |
|---|---|---|
speed | 2 | Metres per second |
mode | 'loop' | 'loop' wraps, 'pingpong' turns around at the ends, 'once' stops at the end |
start | 0 | Starting distance in metres |
pause | 0 | Seconds to wait at each end in pingpong |
onEnd | none | Called when once reaches the end, or each time pingpong turns |
MovingPlatform props: level (required; the platform joins level.boxes while mounted, flagged moving so LevelMeshes does not draw it twice), path (the top centre's path), size (default [3, 0.4, 3]), color (default '#8A93B8'), riders (refs to character states it carries), plus FollowerOptions. It defaults to ping-pong with a 0.6 s pause.
CameraDolly props: path, active (a boolean or a function read every frame), speed (default 1.5 m/s), lookAt (a point or a function; default the origin). Render it after the game's camera rig so it wins while active; when it turns off, the rig takes over.
PathMesh props: path, kind ('ribbon' default or 'tube'), width (ribbon, default 1), radius (tube, default 0.06), color (default '#E8E2D0'), lift (default 0.02 for ribbons, avoids z-fighting with the ground), emissive (default 0).
Path movers run at useFrame priority -15, before default-priority simulations, so a rider is carried before it moves.
Zones and signals (zones/)
Zones
Trigger areas without the physics module: checkpoints, pickups, kill floors, "you are near the shop". Each update is a cheap overlap test between a few shapes and the actors you pass in (the player's capsule, bots). For physics bodies use TriggerVolume instead (Physics).
import { useEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import { actorOf, useZones, type CharacterState } from '@/engine';
export function useLanterns(spots: { x: number; z: number }[], player: { current: CharacterState }, onLit: (i: number) => void) {
const zones = useZones();
useEffect(() => {
zones.clear();
spots.forEach((spot, i) =>
zones.add({
shape: { kind: 'cylinder', base: { x: spot.x, y: -1, z: spot.z }, radius: 0.8, height: 4 },
once: true,
onEnter: () => onLit(i),
}),
);
}, [zones, spots, onLit]);
useFrame(() => {
zones.update([actorOf('player', player.current, { radius: 0.35, height: 1.7 })]);
});
}
ZoneShape:
kind | Fields |
|---|---|
'box' | min, max (an axis-aligned box) |
'sphere' | center, radius |
'cylinder' | base, radius, height (upright, standing on base) |
ZoneDef: shape, data? (free data for the handlers), filter?(actor), once? (removed after its first enter), enabled? (default true), onEnter?(actor, zone), onExit?(actor, zone).
A ZoneActor is { id, x, y, z, radius?, height? }: an upright capsule with its feet at y (radius and height 0 make it a point). actorOf(id, state, { radius, height }) builds one from a character state.
ZoneSet member | What it does |
|---|---|
add(def) | Returns a ZoneId |
update(actors) | Fires onEnter and onExit and returns the ZoneEvent[] ({ type: 'enter' or 'exit', zone, actor }) |
remove(id) | Remove a zone; actors inside get no exit |
get(id) | The Zone (id, shape, data, enabled) |
setEnabled(id, enabled) | Turning it off fires exits for whoever is inside |
setShape(id, shape) | Move or resize; takes effect on the next update |
inside(id) | Actor ids inside now |
isInside(actorId, match?) | Whether an actor is inside any (matching) zone |
clear() | Remove every zone |
size | How many zones |
useZones() gives a component its own set, cleared on unmount. createZones() makes one without React, and overlaps(shape, actor) is the test itself.
Signals
A typed event bus, so the HUD, audio, particles and game feel react to gameplay without passing callbacks down. Declare the events once as a map from name to payload:
import { createSignals, juice, useSignal } from '@/engine';
type Events = { lantern: { index: number; left: number }; tagged: { lives: number }; died: void };
export const events = createSignals<Events>();
// gameplay
events.emit('lantern', { index: 2, left: 3 });
events.emit('died'); // void events take no payload
// any component
export function useLanternJuice() {
useSignal(events, 'lantern', () => juice('pickup')); // unsubscribes on unmount
}
Signals member | What it does |
|---|---|
emit(name, payload?) | Call every handler, in subscription order; the payload is type-checked |
on(name, handler) | Subscribe; returns the unsubscribe |
once(name, handler) | For the next emit only |
clear(name?) | Drop the handlers of one event, or of all |
count(name?) | How many handlers |
A handler that throws is reported with an [engine/signals] warning and the others still run. useSignal(signals, name, handler) subscribes for a component's lifetime; the handler may change every render.
Debug (debug/)
A development readout over the game: fps, average and worst frame time, three's draw calls and triangles (post-processing passes included), the Hermes heap when available, and one line per module (physics, particles, audio, feel) or per game. It is text, so device automation can wait for fps 60. Nothing renders in production builds (__DEV__ is false there).
import React, { useEffect } from 'react';
import { EngineCanvas, registerDebugLine } from '@/engine';
const EnemyCount = ({ alive }: { alive: () => number }) => {
useEffect(() => registerDebugLine('enemies', () => `${alive()} alive`), [alive]);
return null;
};
export const Scene = ({ alive }: { alive: () => number }) => (
<EngineCanvas debug="top-right">
<EnemyCount alive={alive} />
</EngineCanvas>
);
| Export | What it does |
|---|---|
<EngineCanvas debug> | true, 'top-left', 'top-right' or 'bottom-left' shows the overlay |
DebugOverlay | The overlay on its own: position (default 'top-left'), rate (updates per second, default 4) |
registerDebugLine(name, provider) | Add a line; provider returns a string or null (skipped). Returns the remover |
debugStats | The engine-wide DebugStats; snapshot() returns { fps, frameMs, maxFrameMs, drawCalls, triangles, heapMB } |
formatFrameLine(stats) | The first line: fps 60 · 16.7 ms (max 18) · 42 calls · 12.3k tris · heap 48 MB |
PerfProbe | The frame timer EngineCanvas mounts in development |
The overlay's testID is debug-overlay. For the HUD's own development row (an AUTO toggle and a telemetry line), see DebugRow in HUD and UI.
Gotchas
damp, notlerp, for smoothing in a frame loop: a fixedlerpfactor runs faster at 120 Hz than at 60 Hz.- A
Pathmust have at least two points, orcreatePaththrows. MovingPlatformandModel levelchangelevel.boxeswhile mounted. Build a bots' nav grid after they mount, or rebuild it.zones.updateruns where you call it. Call it once per simulation step with every actor that can trigger zones.- Removing a zone fires no exit; disabling it does.
- The debug overlay and
registerDebugLinecost a few numbers per frame and nothing when no overlay is mounted, but they are development tools: do not build gameplay on them.