# Utilities

> The small shared pieces of the engine, math helpers, paths and the things that follow them (followers, moving platforms, camera dollies, path meshes), trigger zones and typed signals, and the development debug overlay.

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 |

```ts
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.

```tsx
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](https://docs.moonspell.app/engine/physics.md)).

```tsx
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:

```ts
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).

```tsx
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](https://docs.moonspell.app/engine/hud-and-ui.md).

## 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.
