Skip to main content

Utilities

Four small parts of the engine that every genre uses, all exported from @/engine:

PartFolderFor
Mathmath.tsClamping, smoothing, angles, time strings, a seeded random generator
Pathspaths/Smooth curves in metres and things that move along them
Zones and signalszones/Trigger areas without physics, and a typed event bus
Debugdebug/The development readout: fps, frame time, draw calls, lines from each module

Math (math.ts)​

ExportWhat 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
TAUMath.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)).

OptionDefaultWhat it does
closedfalseJoin the end back to the start
curve'centripetal''centripetal' (no loops or cusps), 'catmullrom', 'chordal', or 'linear' (straight segments)
tension0.5Catmull-Rom tension, for 'catmullrom'
samplesone per half metre, 64 to 2048Size of the arc-length table (more makes closest more exact)
Path memberWhat it does
length, closed, points, curveThe 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​

ExportWhat it is
PathFollowerPure 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
PathFollowMoves its children along a path. orient: 'yaw' (upright, default), 'full' (flyers), 'none'
MovingPlatformA platform solid for the character controller that moves along a path and carries its riders
CameraDollyA camera on rails for title screens and intros
PathMeshDraws 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):

OptionDefaultWhat it does
speed2Metres per second
mode'loop''loop' wraps, 'pingpong' turns around at the ends, 'once' stops at the end
start0Starting distance in metres
pause0Seconds to wait at each end in pingpong
onEndnoneCalled 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:

kindFields
'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 memberWhat 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
sizeHow 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 memberWhat 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>
);
ExportWhat it does
<EngineCanvas debug>true, 'top-left', 'top-right' or 'bottom-left' shows the overlay
DebugOverlayThe 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
debugStatsThe 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
PerfProbeThe 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, not lerp, for smoothing in a frame loop: a fixed lerp factor runs faster at 120 Hz than at 60 Hz.
  • A Path must have at least two points, or createPath throws.
  • MovingPlatform and Model level change level.boxes while mounted. Build a bots' nav grid after they mount, or rebuild it.
  • zones.update runs 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 registerDebugLine cost a few numbers per frame and nothing when no overlay is mounted, but they are development tools: do not build gameplay on them.