Skip to main content

Character and cameras

The character foundation is everything a 3D game needs for a person or creature that moves through a space: a level of boxes to collide with, a character controller driven by a command object, four camera rigs, and touch controls that write that command. Use it for parkour, arenas, tag, stealth, horror, top-down and side-view games, and as the base for bots and combat. Racing games use the racing module instead.

Everything on this page is exported from @/engine. The code lives in src/engine/level/, src/engine/character/ and src/engine/input/ of the game template.

Smallest working example​

A course of platforms over a void, a capsule body, a third-person camera, a stick on the left half, a look pad on the right half and a JUMP button. Falling respawns you at the start.

import React, { useMemo, useRef } from 'react';
import { StyleSheet, View } from 'react-native';
import { useFrame } from '@react-three/fiber';
import {
CharacterCamera,
CharacterControls,
CharacterMesh,
EngineCanvas,
Environment,
LevelMeshes,
TopBar,
WORLD_FRAME,
consumeInput,
createCharacterInput,
createCharacterState,
createCommand,
moveFrameFor,
resolveCameraRig,
resolveCharacterParams,
resolveLevel,
respawnCharacter,
stepCharacter,
type CharacterHandle,
type CharacterInput,
type CharacterState,
type GameDefinition,
type GameScreenProps,
} from '@/engine';

const LEVEL = resolveLevel({ preset: 'course', seed: 11 });
const PARAMS = resolveCharacterParams({ runSpeed: 6.5, jumpHeight: 1.6 });
const RIG = resolveCameraRig('third');

interface SimProps {
input: CharacterInput;
body: { current: CharacterState };
}

/** Inside the 3D canvas: one body, the level and the camera. */
const Simulation = ({ input, body }: SimProps) => {
const mesh = useRef<CharacterHandle>(null);
const cmd = useRef(createCommand());
const frame = useRef({ ...WORLD_FRAME });

useFrame((_, rawDt) => {
const dt = Math.min(rawDt, 1 / 30);
const s = body.current;
const f = moveFrameFor(RIG, s, frame.current); // what "forward" means for the stick
consumeInput(input, cmd.current); // thumbs (or keys) to a command
stepCharacter(s, cmd.current, dt, LEVEL, PARAMS, f);
if (s.y < LEVEL.killY) respawnCharacter(s, LEVEL.spawns[0]);

const h = mesh.current;
if (h?.group) {
h.group.position.set(s.x, s.y, s.z);
h.group.rotation.y = s.yaw;
}
});

return (
<>
<Environment preset="sunset" follow={() => body.current} />
<LevelMeshes level={LEVEL} />
<CharacterMesh ref={mesh} color="#7C5CFF" radius={PARAMS.radius} height={PARAMS.height} />
<CharacterCamera
rig={RIG}
target={body}
level={LEVEL}
bodyHeight={PARAMS.height}
speedRatio={() => body.current.speed / PARAMS.runSpeed}
/>
</>
);
};

const Hop = ({ onExit }: GameScreenProps) => {
const input = useMemo(() => createCharacterInput(), []);
const body = useRef<CharacterState>(createCharacterState(LEVEL.spawns[0]));
return (
<View style={styles.root}>
<EngineCanvas style={StyleSheet.absoluteFill} background={LEVEL.theme.sky}>
<Simulation input={input} body={body} />
</EngineCanvas>
<CharacterControls input={input} />
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
<TopBar title="Hop" status="Reach the green pad" onExit={onExit} />
</View>
</View>
);
};

const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#000' } });

const game: GameDefinition = {
id: 'hop',
title: 'Hop',
description: 'Jump across the platforms to the green pad.',
Component: Hop,
};

export default game;

Put it in src/game/index.tsx and run the game as described in the quickstart. On a desktop browser, WASD or the arrow keys move, Space jumps and dragging the mouse on the right half turns the camera.

The loop is always the same four steps, once per frame:

  1. moveFrameFor(rig, state, frame) says what "forward" means for the stick. The camera decides.
  2. Something writes a CharacterCommand: consumeInput for the player's thumbs, a bot brain (bots), or the dev autopilot.
  3. stepCharacter(state, cmd, dt, level, params, frame) moves the body and collides it with the level.
  4. You copy the state onto the mesh. CharacterCamera follows on its own.

The full version of this game, with checkpoints, a goal, a moving platform, a camera button, a best time and an autopilot, is the Rooftop Hop example (examples/rooftop-hop/index.tsx). Lantern Tag adds bots and a town laid out with gridMap, and Pulse Arena adds weapons. See Examples.

Units and conventions​

  • Units are metres and seconds. +Y is up.
  • A body's position is its feet.
  • Yaw 0 faces +Z. Yaw increases to the left. forwardOf(yaw) and rightOf(yaw) give the unit vectors on the ground plane, so at yaw 0 forward is +Z and right is -X.
  • The stick is a unit disc: +x is right on screen, +y is up on screen (forward).
  • All state lives in plain mutable objects and refs, never in React state. React renders once; useFrame does the rest.

Levels​

A level is a flat floor plus axis-aligned boxes. Boxes are the only collision shapes, which keeps collision, rays and bot pathfinding small and fast on a phone. Ramps, stairs, pillars and doors are pieces that resolve to boxes.

import { resolveLevel } from '@/engine';

const LEVEL = resolveLevel({
preset: 'course',
seed: 11,
theme: { sky: '#1B1440', platform: '#5B6BD6', block: '#E0A458', checkpoint: '#2EE6D6', goal: '#4ADE80' },
});

resolveLevel(def) fills every default and clamps every value, so any definition renders and is walkable. Resolve it once at module scope and pass the Level around.

LevelDef​

FieldTypeDefaultNotes
preset'arena', 'course' or 'corridors''arena'When you pass boxes and no preset, the level is custom: only your boxes.
boxesLevelBox[][]Added to the preset's boxes, or the whole layout for a custom level.
piecesLevelPiece[][]Ramps, stairs, cylinders, arches, walls with doors.
sizenumber22 arena, 24 course, 18 corridors, 20 customHalf-size of the playable square, clamped to 8 to 120 m.
floorYnumber0 (course: -30)A floor below -20 means no floor: a void.
fencebooleantrue (course: false)Walls around the square.
fenceHeightnumber3Clamped to 1 to 8 m.
killYnumberfloor minus 6 (course and voids: -8)The engine does not respawn for you: check state.y < level.killY.
spawnsLevelPoint[]per preset, else one at the origin
checkpointsLevelPoint[]per presetDrawn as rings by LevelMeshes.
goalLevelPoint or nullper preset (only course has one)null removes a preset's goal.
themeLevelThemesee below
seednumber7Seed for the presets' random placement.

A LevelPoint is { x, y?, z, yaw? }. y defaults to the floor (0 for a void) and yaw to 0.

A LevelBox is { x, y?, z, w, h, d, kind?, color? }: x and z are the centre, y is the bottom face (default 0), w, h, d are the full sizes along x, y and z. kind is one of:

kindMeaning
'wall'Tall, blocks movement and sight. Never a ledge to grab.
'platform'Something to stand on.
'block'A crate-sized obstacle. The default.

LevelTheme colours (any CSS colour) and their defaults: floor #2B3144, wall #5A627A, platform #3E7CB1, block #C98A4B, checkpoint #2EE6D6, goal #4ADE80, sky #141A2E, plus fog (default true). The engine does not apply sky or fog for you: pass level.theme.sky to EngineCanvas background and use level.theme.fog when you set up Environment, as Rooftop Hop does.

Presets​

PresetLayoutGood for
arenaA fenced square with four pillars, a low central stage and scattered crates. Four spawns, one per side.Tag, brawls, shooters.
courseNine platforms over a void, rising and drifting sideways with 2.2 to 3 m gaps, a crate on every other one. Checkpoints on platforms 3 and 6, the goal on the last.Parkour, obstacle courses.
corridorsA fenced small maze of long walls plus crates. Two spawns in opposite corners.Stealth, horror, hide-and-seek.

LEVEL_PRESETS holds a bare definition per preset, PRESET_SIZE the default sizes, and buildPreset(preset, size, seed) the raw layout.

The resolved Level​

FieldTypeNotes
presetpreset name or 'custom'
sizenumber
bounds{ minX, maxX, minZ, maxZ }The square, grown to hold every box (a course runs long in +Z).
floorY, hasFloor, killYnumber, boolean, number
boxesResolvedBox[]minX, maxX, minY, maxY, minZ, maxZ, kind, color, and the flags moving, hidden, climb.
piecesResolvedPiece[]Ramp wedges and cylinders drawn over their hidden collision boxes.
spawns, checkpointsResolvedPoint[]{ x, y, z, yaw } with every field filled.
goalResolvedPoint or null
themeRequired<LevelTheme>

ResolvedBox flags: hidden boxes collide but LevelMeshes does not draw them (a ramp's steps, a cylinder's box, a model's bounds). moving boxes belong to a MovingPlatform and are drawn by it. climb marks ramp and stair steps that bots may climb between.

Pieces​

Pieces are shapes beyond plain boxes. Each one becomes boxes the controller, the bots and the rays already understand. For every piece, x and z are the centre, y the bottom (default 0) and color overrides the theme.

kindFieldsWhat it becomes
'ramp'w, d, rise, facingHidden steps 0.1 m tall (RAMP_STEP) under a smooth drawn wedge, so the body walks up without hopping. d runs along facing. Theme colour platform.
'stairs'w, d, rise, facing, steps?Visible steps. steps defaults to rise / 0.25 (STAIR_STEP), rounded up. Theme colour platform.
'cylinder'radius, hA hidden wall box (0.85 of the radius each side) under a drawn cylinder. Theme colour block.
'arch'w, h, axis, post?, lintel?Two posts and a lintel around an opening w wide and h tall. post and lintel default to 0.5 m. Theme colour wall.
'wall-door'length, h, axis, thickness?, door?A wall with an opening. thickness defaults to 0.3 m; door is { width?, height?, offset? }, default 1.2 by 2.2 m, centred. Theme colour wall.

facing is 'x+', 'x-', 'z+' or 'z-': the direction the ramp or stairs climbs toward. axis is 'x' or 'z': the direction the wall line runs.

The controller walks up ramps and stairs, because each step is under its step height, and walks down them without hopping, because a body that was walking snaps down to the surface up to one step below.

Grid maps​

gridMap(rows, options) lays out boxes and pieces from a string map, one character per cell. Rows go +Z (row 0 is the north edge, at -Z) and columns go +X. Spread the result into resolveLevel. This is the town block in Lantern Tag:

import { gridMap, resolveLevel } from '@/engine';

/** A hut with a door, a raised yard reached by two ramps, and a pillar. */
const TOWN = gridMap(
[
'##D##>PP',
'#...#.PP',
'#...#.^.',
'#####..O',
],
{ cell: 1.2, origin: { x: 12, z: 16.6 }, wallHeight: 2.6, platformHeight: 1 },
);

const LEVEL = resolveLevel({ preset: 'arena', seed: 4, boxes: TOWN.boxes, pieces: TOWN.pieces });
CharacterCell
#Wall filling the cell, wallHeight tall.
DWall with a door through it, along the row.
|Wall with a door through it, along the column.
BCrate, 0.8 m.
PPlatform, platformHeight tall.
OPillar (a cylinder), wallHeight tall.
^, >, v, <Ramp up to the north, east, south, west, rising to platformHeight.
N, E, S, WStairs up to the north, east, south, west, rising to platformHeight.
. or spaceEmpty.
OptionDefaultNotes
cell2Metres per cell.
origincentred on the originWorld { x, z } of the map's north-west corner.
y0Bottom of every piece.
wallHeight3
platformHeight1
legendDEFAULT_LEGENDAdds or replaces characters. Each entry is a GridCell: a function that gets { x, z, cell, y, wallHeight, platformHeight } for the cell centre and returns { boxes?, pieces? }.

An unknown character throws an error that names its row and column.

Drawing the level​

<LevelMeshes level={LEVEL} /> draws the floor, one mesh per visible box, the ramp wedges and cylinders, the checkpoint rings and the goal pad.

PropTypeDefaultNotes
levelLevelrequired
nextCheckpointnumber0Rings with a lower index draw dim, as passed.
showGoalbooleantrue

For scenery beyond boxes, two procedural shapes are drawn only (no collision): add a box or a piece where they stand.

ComponentProps
Extrudedoutline ([x, z][] footprint), height, color (#C9CED8), position ([0, 0, 0]), rotationY (0), bevel (0 m). A footprint pushed up: a house, a plinth, a fence.
Lathedprofile ([radius, y][] from bottom to top), segments (24), color (#C9CED8), position ([0, 0, 0]). A profile spun around the vertical axis: a vase, a tower, a lamp post.
import { Extruded, Lathed } from '@/engine';

const Props = () => (
<>
<Extruded outline={[[0, 0], [4, 0], [4, 3], [0, 3]]} height={2.5} position={[8, 0, 8]} />
<Lathed profile={[[0.6, 0], [0.4, 1.2], [0.7, 2]]} position={[-6, 0, 4]} />
</>
);

Generated .glb props can join the level as hidden boxes with <Model collides="box" level={level}>. See Models and scenery.

Collision and rays​

These are the functions the controller, cameras and bots use. Call them yourself for line of sight, pickups or custom bodies.

FunctionReturnsWhat it does
collideCapsule(level, body, prevY, capsule, out?, snapDown = 0)CollisionResultPushes a standing capsule ({ radius, height, stepHeight }) out of every box and the floor. Mutates body (feet position). prevY is the feet height before this frame's move: it decides landing on a box versus sliding along its side. snapDown keeps a walking body on the ground up to that far below.
createCollisionResult()CollisionResultA reusable result: grounded, hitWall, hitCeiling, wallNormalX, wallNormalZ, standingOn.
raycastLevel(level, a, b, out?)RayHit or nullThe first box or floor a segment from a to b touches. RayHit is { t, x, y, z, nx, ny, nz, box }, with t from 0 to 1 along the segment and box null for the floor. Hidden boxes count.
segmentClear(level, a, b)booleanTrue when nothing blocks the segment.
groundHeightAt(level, x, z, y, tolerance = 0.05)number or nullThe highest surface under (x, z) at or below y + tolerance, or the floor; null over a void.
capsuleOf(params)CapsuleThe capsule a CharacterParams describes.

For physics bodies that the character should shove (crates, barrels), see Physics.

The character controller​

resolveCharacterParams(config)​

Fills defaults and clamps a CharacterConfig into CharacterParams. Resolve once, at module scope.

FieldRangeDefaultFeel
walkSpeed1 to 8 m/s3.2A gentle stick.
runSpeed2 to 14 m/s6.5A full stick, or sprint.
acceleration8 to 80 m/s²32How snappy starts and stops are. Releasing the stick brakes 1.4 times harder.
airControl0 to 10.35Share of acceleration you get in the air.
jumpHeight0 to 4 m1.60 turns jumping off.
gravity5 to 40 m/s²22High gravity with a high jump reads as arcade.
maxFallSpeed5 to 40 m/s22
coyoteTime0 to 0.3 s0.12A jump still works this long after walking off an edge.
jumpBuffer0 to 0.3 s0.15A press this long before landing fires on landing.
radius0.2 to 1 m0.4Body capsule.
height0.8 to 3 m1.7Body capsule.
stepHeight0 to 0.6 m0.35Ledges this high are stepped onto instead of bumped into.
turnRate2 to 30 rad/s12How fast the body turns to face its motion.

CharacterParams has every field above plus eyeHeight (0.9 times height).

With the defaults, a running jump clears about 4 m across and 0.7 m up. Keep platform gaps under 3 m and rises under 0.8 m, or raise jumpHeight.

CharacterCommand​

What a body should do this frame. The player's thumbs, a bot brain and the autopilot all write the same object, so they all obey the same physics. Create one with createCommand() and empty it with clearCommand(cmd).

FieldTypeMeaning
moveX, moveYnumberStick on the unit disc, +y forward.
lookDX, lookDYnumberLook turn this frame, radians. +x turns left, +y looks down.
jumpbooleanTrue for the one frame it was pressed.
actionA, actionBbooleanTrue for the one frame they were pressed. The controller ignores them: your rules read them.
sprintbooleanHeld: forces run speed whatever the stick magnitude.

CharacterState​

Create one with createCharacterState(spawn) (a Partial<ResolvedPoint>; it starts grounded and still). Keep it in a ref.

FieldMeaning
x, y, zFeet position.
vx, vy, vzVelocity.
yawBody facing.
lookYaw, lookPitchWhere the camera looks. Pitch is clamped to -1.2 to 1.25 rad, + looks down.
grounded, hitWallContact this frame.
standingOnThe ResolvedBox under the feet, or null for the floor or the air.
speedPlanar speed, m/s.
airTimeSeconds since last on the ground.
jumped, landedTrue on the one frame a jump started or the body landed. Use them for sounds, dust and bot noise.
coyote, jumpBufferTimers the controller keeps.
ledge'leap', 'hang', 'climb' or null, when you use ledges.

Functions​

FunctionWhat it does
stepCharacter(state, cmd, dt, level, params, frame, collision?)One frame of motion. Mutates state. Clamps dt to 1/30 s and is frame-rate independent below that. Pass a CollisionResult to reuse your own.
respawnCharacter(state, point)Puts the body at a point, standing still. Sets yaw when the point has one; keeps the look direction.
speedForStick(magnitude, params, sprint)The planar speed a stick magnitude asks for: 0 under 0.02, walk speed scaled up to 0.6, then blended to run speed at 1.
forwardOf(yaw), rightOf(yaw)Unit vectors { x, z }.
capsuleOf(params)The collision capsule.

What one step does, in order: it turns the look by the command's look delta; turns the stick into a wanted velocity in the frame's forward and right directions and accelerates toward it (airControl in the air); starts a jump if one was pressed within jumpBuffer and the body is grounded or within coyoteTime of an edge, reaching exactly jumpHeight at any frame rate; applies gravity up to maxFallSpeed; moves and collides, removing the part of the velocity that goes into a wall so the body slides along it; then turns the body toward its motion at turnRate, or to the look direction in first person.

MoveFrame​

MoveFrame is { forwardYaw, faceLook, axis }: what the stick's forward means, whether the body faces the look direction (first person), and 'free' or 'x' (side view ignores the stick's vertical axis). You rarely build one by hand: moveFrameFor(rig, state, out) writes the right one for a camera, and WORLD_FRAME (forwardYaw: 0, stick up is +Z) is the starting value to copy into a ref.

Ledges: leap, hang and climb​

A wall too tall to clear with a jump is a cliff. Jumping at one becomes a leap with the arms reaching up; the hands catch the edge and the body hangs. Then the player decides: JUMP or the stick toward the wall climbs, the stick away lets go. Call stepLedge first and skip stepCharacter while it returns true:

import {
createLedge,
leapAtCliff,
stepCharacter,
stepLedge,
type CharacterCommand,
type CharacterParams,
type CharacterState,
type Level,
type LedgeState,
type MoveFrame,
} from '@/engine';

export function stepWithLedges(
ledge: LedgeState, // createLedge(), kept in a ref
s: CharacterState,
cmd: CharacterCommand,
dt: number,
level: Level,
params: CharacterParams,
frame: MoveFrame,
) {
if (!stepLedge(ledge, s, cmd, level, params, frame, dt)) {
stepCharacter(s, cmd, dt, level, params, frame);
leapAtCliff(ledge, s, level, params);
}
}

A top can be caught when it is between minReach and maxReach above the feet and higher above the ground you jumped from than jumpHeight. Only boxes that are not wall and not hidden have ledges, so make cliffs out of platform (or block) boxes. A body standing on the ground never catches a ledge.

LedgeConfig fieldDefaultMeaning
minReach1.1 mLowest ledge above the feet; anything lower is a step.
maxReach2.25 mHighest ledge above the feet.
gap0.3 mHow close to the wall the body must be, beyond its radius.
hangDrop1.8 mFeet below the ledge while hanging.
hangOff0.32 mChest off the wall while hanging.
climbTime1.58 sSeconds to climb. Match it to the climb clip.

stepLedge(ledge, s, cmd, level, params, frame, dt, config?) and leapAtCliff(ledge, s, level, params, range = 3, config?) both take the config last. findLedge(level, s, params, config?, groundY?) returns the nearest edge the body could catch right now. CLIMB_PATH and climbPathAt(t) describe the path of the body up the edge, which the climb clip is posed along.

A driven CharacterMesh plays hang, climb and leap clips from state.ledge when the model has them. The capsule fallback has no clips, so it hangs and climbs as a sliding capsule.

Drawing the body: CharacterMesh​

CharacterMesh draws a capsule with a visor and a belt so its facing reads from any camera, or a generated model in its place. The feet are at the group origin and the nose points to +Z, so group.rotation.y is the body yaw. It never moves itself: set group.position and group.rotation.y every frame from the state.

PropTypeDefaultNotes
colorstringrequiredBody colour.
accentstring#F3F4FFBelt and shoulder stripe.
radiusnumber0.4Pass params.radius.
heightnumber1.7Pass params.height.
hiddenbooleanfalseHide the body, for first person.
emissivestringnoneA glow (a chasing bot, the player who is "it").
modelnumbernoneA bundled .glb (require('../../assets/models/hero.glb')). The capsule shows until it loads.
animationstringnoneThe model's clip to play. Ignored while drive is set.
clipsRecord<string, number>noneExtra clips from sibling files.
modelYawnumber0Turns the model to face +Z when it was made facing another way.
drive{ current: CharacterState }noneAnimates the model from the controller: idle, walk, run by speed, then jump, fall, land, hang, climb and leap, from whichever clips it has.
speeds{ walk, run }{ walk: 3, run: 6.5 }Blend speeds. Pass the controller's walkSpeed and runSpeed.
motionMotionConfigsee belowHow the body reads the controller.
onAnimationEvent(event: string) => voidnoneClip events from the driven graph, such as footstep.
footIKLevelnonePlants the model's feet on ramps and steps (needs drive).
lookAt() => Vec3 or nullnoneTurns the head toward a point while it returns one. Capsules turn their visor.

The ref is a CharacterHandle:

MemberMeaning
groupThe THREE.Group to position.
yawThe smoothly turned heading (with drive). Draw with group.rotation.y = handle.yaw for a body that turns like a body instead of snapping.
hardLandingTrue for the one frame of a landing hard enough to show. Use it for squash and shake.
resetMotion()After a respawn: stand facing the state's yaw now, with no leftover speed or turn.
overlay(clip, layer = 'upper')Plays a clip once over the base motion (hit, wave). Does nothing without a driven model.
import { CharacterMesh, type CharacterHandle, type CharacterParams, type CharacterState } from '@/engine';

const Hero = ({ body, params, handle }: { body: { current: CharacterState }; params: CharacterParams; handle: React.RefObject<CharacterHandle | null> }) => (
<CharacterMesh
ref={handle}
color="#7C5CFF"
radius={params.radius}
height={params.height}
model={require('../../assets/models/hero.glb')}
drive={body}
speeds={{ walk: params.walkSpeed, run: params.runSpeed }}
onAnimationEvent={(e) => {
if (e === 'footstep') {
// play a step sound
}
}}
/>
);

Models, clips and the animation graph are covered in Models and scenery.

Motion: how the body reads the controller​

The controller is exact and instant, which is right for gameplay and wrong for animation. stepMotion sits between them, and a driven CharacterMesh runs it for you. It holds a stop and start gap so speed never flickers near zero and settles on exactly 0 (so the walk clip gets no weight), turns the heading smoothly, and only reports a hard landing after a real drop.

MotionConfig fieldDefaultMeaning
stopBelow0.35 m/sA moving body below this counts as stopped.
startAbove0.7 m/sA stopped body above this counts as moving.
response12How fast the animated speed follows the real one, per second.
softLandBelow7 m/sLanding slower than this is soft: no landing clip, no hardLanding. About a 2.5 m drop.
turnRate14How fast the drawn heading turns, per second.

createMotion(yaw?) and stepMotion(motion, state, dt, config?) are exported for bots and custom meshes; MotionState is { speed, moving, yaw, hardLanding, fallSpeed }. pickMotionClips(names) finds the idle, walk, run, jump, fall, land, hang, climb and leap clips by name, and treats a model's own clip with an unrecognised name (exporters call it "Animation") as its idle.

Cameras​

Four rigs. Resolve one with resolveCameraRig(mode, config) and give it to CharacterCamera.

ModeWhat it doesStick frame
'third'Orbits behind the body. Dragging the look pad turns it. Pulls in when the level blocks the view, never closer than 1.1 m. Widens its field of view with speed.Camera-relative.
'first'At eye height, looking along the look direction. Hide the body with hidden.Camera-relative; the body faces the look.
'top'High and tilted, at a fixed yaw.World axes: stick up is +Z at yaw 0.
'side'Side view at a fixed yaw.World x axis only: the stick's vertical axis does nothing.

CameraRigConfig​

Defaults are listed as third / first / top / side. A dash means the mode ignores the field.

FieldRangeDefaultMeaning
distancethird 2 to 12, top 6 to 40, side 4 to 30 m6 / - / 16 / 14Metres from the pivot.
pivot0.3 to 1.20.85 / 0.9 / 0.5 / 0.5Pivot height as a share of body height.
pitchthird -20 to 60, top 30 to 89, side -10 to 30 degrees14 / - / 62 / 6Degrees looking down. In third person the look pitch adds to it.
fov30 to 11060 / 78 / 50 / 45Field of view, degrees.
lag1 to 409 / 40 / 6 / 7Follow smoothing; higher is tighter.
shoulder-2 to 2 m0 / - / - / -Sideways offset of the pivot, + right. 0.5 to 0.9 feels over the shoulder.
yawdegrees- / - / 0 / 0Fixed yaw for top and side. 0 looks along +Z.
speedFov0 to 208 / 0 / 0 / 0Extra field of view at full run speed, degrees.

resolveCameraRig returns a CameraRig with the same fields plus mode, angles in radians. Pulse Arena uses an over-the-shoulder rig:

import { resolveCameraRig } from '@/engine';

const rig = resolveCameraRig('third', { shoulder: 0.9, distance: 5.6, pitch: 10, speedFov: 4 });

CharacterCamera​

Put it inside the canvas. It drives the R3F camera every frame with frame-rate independent smoothing.

PropTypeNotes
rigCameraRigWhen the rig object changes (a mode switch), the camera cuts to the new pose instead of swinging through the level. Keep it in useMemo.
target{ current: CharacterState }The body to follow.
levelLevelFor the third-person pull-in.
bodyHeightnumberparams.height.
speedRatio() => number0 to 1, widens the field of view while running.
snapCount{ current: number }Increase current to cut to the new pose once, with no smoothing. Use it after a respawn.

Helpers for a camera button: CAMERA_MODES (['third', 'first', 'top', 'side']), nextCameraMode(mode) and cameraModeLabel(mode) ('third person', 'first person', 'top-down', 'side view'). Rooftop Hop keeps the mode in React state and builds the rig with useMemo(() => resolveCameraRig(cameraMode), [cameraMode]).

computeCameraPose(rig, state, level, bodyHeight, speedRatio, out) and createCameraPose() give you the pose without touching a camera, for a custom camera or a test.

Respawning cleanly​

Coming back should feel like a cut. After respawnCharacter, set yaw, lookYaw and lookPitch on the state, call handle.resetMotion(), and bump the snapCount ref you pass to CharacterCamera:

import { respawnCharacter, type CharacterHandle, type CharacterState, type ResolvedPoint } from '@/engine';

export function respawn(s: CharacterState, at: ResolvedPoint, mesh: CharacterHandle | null, snapCount: { current: number }) {
respawnCharacter(s, at);
s.lookYaw = at.yaw;
s.lookPitch = 0;
mesh?.resetMotion();
snapCount.current++;
}

Camera shots​

A shot is a moment when the camera stops following behind and frames something: the side of a hang so you see the drop, a vista from a clifftop. Give stepShots this frame's shot, or null to follow again; it eases both ways. Call it after the character steps.

import { createShotDirector, hangShot, stepShots, zoneShot, type CameraRig, type CameraZone, type CharacterState } from '@/engine';

const VISTAS: CameraZone[] = [{ x: 0, z: 40, radius: 4, yaw: Math.PI, pitch: 20, distance: 9 }];
const director = createShotDirector();

export function frameShots(s: CharacterState, rig: CameraRig, dt: number) {
const shot = s.ledge === 'hang' ? hangShot(s.yaw) : zoneShot(VISTAS, s);
stepShots(director, shot, s, rig, dt);
}
ExportMeaning
CameraShot{ yaw, pitch, distance }: yaw in radians (like lookYaw), pitch in degrees, distance in metres.
CameraZoneA CameraShot plus { x, z, radius }: a place in the level with its own shot.
zoneShot(zones, state)The first zone the body is in, or null.
hangShot(wallYaw, side = 1)The camera beside a wall, looking along it. side picks which way it stands. While hanging, state.yaw faces the wall.
stepShots(director, shot, state, rig, dt, rate = 3.2)Eases toward the shot. Returns true while a shot has the camera. It changes rig.distance and the state's look, so pass the same rig object CharacterCamera uses.
createShotDirector()The ShotDirector state: { blend, homeDistance, homePitch }.

Controls​

CharacterControls​

The multi-touch control layer for character games. One gesture tracks every finger, so moving, looking and jumping happen at once and fingers may slide between buttons. The first finger on the stick half becomes a floating stick where it lands, fingers on the other half drag to look, and buttons sit in the bottom corner opposite the stick. It writes into a CharacterInput and never re-renders React per frame.

Render it over the canvas and render the HUD over it, so HUD buttons still take taps:

import { StyleSheet, View } from 'react-native';
import { CharacterControls, createCharacterInput, type ControlButton } from '@/engine';

const input = createCharacterInput();

const BUTTONS: ControlButton[] = [
{ id: 'jump', label: 'JUMP', accessibilityLabel: 'Jump', size: 92 },
{ id: 'a', label: 'DASH', accessibilityLabel: 'Dash' },
];

export const Controls = ({ over }: { over: boolean }) => (
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
<CharacterControls input={input} buttons={BUTTONS} disabled={over} />
</View>
);
PropTypeDefaultNotes
inputCharacterInputrequiredCreate once with createCharacterInput().
disabledbooleanfalseIgnore touches and keys (a finish screen).
stick'left' or 'right''left'Which half the stick lives in. 'right' for left-handed players.
lookbooleantrueDragging on the other half turns the camera. false hides the look zone and touches there do nothing (for top and side cameras, or a 2D game); buttons with look: true still aim.
buttonsControlButton[]one JUMP button, 92 pt
stickRadiusnumber56Stick travel in points, clamped to 40 to 90.
deadZonenumber0.12Share of the radius ignored, rescaled so there is no jump.
secondThumbbooleanfalseNo fixed buttons: the first touch anywhere is the stick, a second touch while it is held presses the first button under that thumb, and dragging that thumb turns the camera.
lookArea{ left, top, right, bottom }{ left: 0.26, top: 0.32, right: 0.74, bottom: 0.72 }With secondThumb: where a first touch looks around instead of moving, as shares of the screen.
onTouchCount(count: number) => voidnoneFingers down, for dev telemetry.

A ControlButton is { id, label, accessibilityLabel?, size?, color?, look? }. size is the diameter in points (default 76). With look: true the finger on the button also turns the camera while it drags, and the button stays held until that finger lifts, wherever it slides (a shooter's FIRE: hold to shoot, drag to aim); fingers on other buttons slide between buttons and release when they leave them all. The ids jump, sprint, a and b map onto the command (see consumeInput); any other id you read from input.held[id] and input.pressed[id] yourself.

Test IDs for device automation: character-controls (the layer), stick-zone (label "Move stick"), look-zone (label "Look"), btn-<id> for each button (its accessibilityLabel, or its label), and btn-thumb with secondThumb.

CharacterInput and consumeInput​

CharacterInput is what the thumbs did since the loop last looked: { moveX, moveY, lookDX, lookDY, held, pressed }. lookDX and lookDY are pixels of drag; held and pressed are records by button id.

FunctionWhat it does
createCharacterInput()A new, empty input.
consumeInput(input, cmd, lookSensitivity?)Writes this frame's command and clears what it consumed: the look deltas and every pressed flag. jump, actionA and actionB come from pressed.jump, pressed.a and pressed.b; sprint from held.sprint. Dragging right turns the view right.
resetCharacterInput(input)Zeroes everything, for a restart.
DEFAULT_LOOK_SENSITIVITY0.0048 radians per pixel of drag, the default for consumeInput.

Keyboard in a desktop browser​

On the web, CharacterControls also listens to the keyboard and feeds the same input, so a game built on it plays with keys without extra code. On phones no listener is added.

KeysAction
WASD or the arrow keysThe stick (eight directions, full tilt). A thumb on the stick wins over the keys.
SpaceThe first button in buttons.
J, E or FThe second button.
K or QThe third button.
LThe fourth button.
ShiftA button with id sprint, wherever it is listed.
Mouse drag on the look halfTurns the camera.

Keys are ignored while focus is in a text field or a modifier (Cmd, Ctrl, Alt) is held, and everything is released when the tab loses focus. See Input for the keyboard layer shared by all controls.

A joystick instead​

For top-down games where the whole screen is the stick, <Joystick input={input} /> also writes moveX and moveY into a CharacterInput, and consumeInput reads it the same way. The kingdom module drives its sorties this way with a top camera. The joystick's props are on the Input page.

Dev autopilot​

waypointCommand writes a command that walks a body along a list of points. It runs on straights, slows near a point, and jumps when the ground ahead drops away, when the next point is higher, when a wall blocks it, or when it has been stuck for four seconds. It returns true once the last point is reached. Behind __DEV__ and an AUTO button, it lets a script play the game with no thumb. Rooftop Hop walks the platform tops in order:

import { createWalker, resolveLevel, waypointCommand, type Waypoint } from '@/engine';

const LEVEL = resolveLevel({ preset: 'course', seed: 11 });

/** Platform tops in order, then the goal. */
const WAYPOINTS: Waypoint[] = [
...LEVEL.boxes
.filter((b) => b.kind === 'platform')
.sort((a, b) => a.minZ - b.minZ)
.map((b) => ({ x: (b.minX + b.maxX) / 2, y: b.maxY, z: (b.minZ + b.maxZ) / 2 })),
...(LEVEL.goal ? [{ x: LEVEL.goal.x, y: LEVEL.goal.y, z: LEVEL.goal.z }] : []),
];

const walker = createWalker();
// In useFrame, instead of consumeInput:
// waypointCommand(state, LEVEL, WAYPOINTS, walker, PARAMS, frame, cmd, dt);
ExportMeaning
waypointCommand(state, level, points, walker, params, frame, cmd, dt)Writes cmd. A point counts as reached within 0.7 m across and 1.2 m up or down.
createWalker()A WaypointWalker: { index, stuck }. Set index to resume from a point after a respawn.
worldDirToStick(dx, dz, frame, cmd, magnitude = 1)Writes the stick that moves a body toward a world direction. Bots use it too.
Waypoint{ x, y, z }.

Extras: ripples and birds​

<Ripples> spreads rings on the ground from a small pool of flat meshes: ink from a landing, a shockwave, a splash. Props: color (#0B0B0D) and count (8 at once). The ref is a RipplesHandle with play(x, y, z, strength = 0.5); strength 0 to 1 sets the size and darkness, and above 0.6 a second ring follows.

<Flock> places birds that sit until the player comes near, then burst away. It costs two draw calls whatever the count.

Flock propDefaultMeaning
perchesrequired[x, y, z] per bird.
nearrequired() => { x, y, z }, usually () => body.current.
radius6 mFlush when something is this close.
life7 sSeconds of flight before a bird is gone.
color#0D0D0F
size0.5 mBird length.
onFlushnoneCalled once when they take off: play the wings here.
import { useRef } from 'react';
import { Flock, Ripples, type CharacterState, type RipplesHandle } from '@/engine';

export const Life = ({ body }: { body: { current: CharacterState } }) => {
const rings = useRef<RipplesHandle>(null);
// after a hard landing: rings.current?.play(s.x, s.y, s.z, 0.8);
return (
<>
<Ripples ref={rings} />
<Flock perches={[[2, 1.2, 6], [2.4, 1.2, 6.3], [1.8, 1.2, 6.5]]} near={() => body.current} />
</>
);
};

createFlock(perches) and stepFlock(flock, near, dt, config?) are the logic without React.

Export reference​

All from @/engine.

AreaValuesTypes
LevelresolveLevel, groundHeightAt, LEVEL_PRESETS, PRESET_SIZE, buildPreset, resolvePiece, RAMP_STEP, STAIR_STEP, gridMap, DEFAULT_LEGEND, LevelMeshes, Extruded, LathedLevelDef, Level, LevelBox, LevelPiece, LevelPoint, LevelPreset, LevelTheme, BoxKind, Facing, ResolvedBox, ResolvedPiece, ResolvedPoint, Vec3, GridMapOptions, GridCell, ExtrudedProps, LathedProps
CollisioncollideCapsule, createCollisionResult, raycastLevel, segmentClearCapsule, CollisionResult, RayHit
ControllerresolveCharacterParams, createCharacterState, respawnCharacter, stepCharacter, speedForStick, capsuleOf, forwardOf, rightOf, createCommand, clearCommand, WORLD_FRAMECharacterCommand, CharacterConfig, CharacterParams, CharacterState, MoveFrame
LedgescreateLedge, stepLedge, leapAtCliff, findLedge, CLIMB_PATH, climbPathAtLedgeConfig, LedgeState
Motion and meshCharacterMesh, createMotion, stepMotion, pickMotionClipsCharacterHandle, MotionConfig, MotionInput, MotionState
CamerasresolveCameraRig, CharacterCamera, computeCameraPose, createCameraPose, moveFrameFor, CAMERA_MODES, cameraModeLabel, nextCameraModeCameraMode, CameraRig, CameraRigConfig, CameraPose
ShotscreateShotDirector, stepShots, zoneShot, hangShotCameraShot, CameraZone, ShotDirector
ControlsCharacterControls, createCharacterInput, resetCharacterInput, consumeInput, DEFAULT_LOOK_SENSITIVITYCharacterInput, ControlButton, StickSide
AutopilotwaypointCommand, createWalker, worldDirToStickWaypoint, WaypointWalker
ExtrasRipples, Flock, createFlock, stepFlockRipplesHandle, Bird, FlockConfig, FlockState

Gotchas​

  • The engine never respawns or finishes for you. Check state.y < level.killY yourself, and use zones for checkpoints and goals (Rooftop Hop does both). Zones, paths and MovingPlatform are on the Utilities page.
  • CharacterMesh does not follow the state. Set group.position and group.rotation.y in your useFrame after stepCharacter, or the body stays at the origin.
  • Keep the rig stable. CharacterCamera cuts whenever the rig object changes. Build it with useMemo or at module scope; a new object every render makes the camera snap every render.
  • Read custom buttons before consumeInput. It clears every pressed flag, including ids it does not know. Read input.pressed.myButton first, or read input.held at any time.
  • Button order sets the keys. On the web, Space presses the first button in buttons, not the one with id jump. Pulse Arena lists FIRE first, so Space fires there. Put JUMP first if Space should jump.
  • speeds on CharacterMesh defaults to 3 and 6.5, not to the controller's 3.2 walk speed. Pass { walk: params.walkSpeed, run: params.runSpeed } when you change them.
  • LevelMeshes reads nextCheckpoint at render. A ref's value does not re-render it; the rings dim when the component renders again.
  • Levels are boxes. Round or sloped scenery collides as its boxes: a cylinder as a box 0.85 of its radius each side, a ramp as 0.1 m steps. Extruded, Lathed and plain meshes do not collide at all until you add a box or a piece.
  • Doors and bots. The player fits through any door wider than the body, but bots path on a grid: doors narrower than about a cell plus a body are closed to them. See Bots.
  • Clamp dt. stepCharacter clamps to 1/30 s on its own, but clamp in your loop too so your timers and the step agree.

See also​

  • Examples: Rooftop Hop (this page in full), Lantern Tag (bots, gridMap), Pulse Arena (combat).
  • Input: touch controls, Joystick and the keyboard layer.
  • Physics: rigid bodies the character can shove.
  • Models and scenery: generated characters, clips, the animation graph and instanced scenery.
  • Bots: bots that drive the same controller.
  • Combat: health, weapons and aim for character games.
  • Game feel: juice('land'), squash and stretch for jumps and landings.
  • Kingdom module and Shooter module: modules built on this foundation.