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:
moveFrameFor(rig, state, frame)says what "forward" means for the stick. The camera decides.- Something writes a
CharacterCommand:consumeInputfor the player's thumbs, a bot brain (bots), or the dev autopilot. stepCharacter(state, cmd, dt, level, params, frame)moves the body and collides it with the level.- You copy the state onto the mesh.
CharacterCamerafollows 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)andrightOf(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;
useFramedoes 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
| Field | Type | Default | Notes |
|---|---|---|---|
preset | 'arena', 'course' or 'corridors' | 'arena' | When you pass boxes and no preset, the level is custom: only your boxes. |
boxes | LevelBox[] | [] | Added to the preset's boxes, or the whole layout for a custom level. |
pieces | LevelPiece[] | [] | Ramps, stairs, cylinders, arches, walls with doors. |
size | number | 22 arena, 24 course, 18 corridors, 20 custom | Half-size of the playable square, clamped to 8 to 120 m. |
floorY | number | 0 (course: -30) | A floor below -20 means no floor: a void. |
fence | boolean | true (course: false) | Walls around the square. |
fenceHeight | number | 3 | Clamped to 1 to 8 m. |
killY | number | floor minus 6 (course and voids: -8) | The engine does not respawn for you: check state.y < level.killY. |
spawns | LevelPoint[] | per preset, else one at the origin | |
checkpoints | LevelPoint[] | per preset | Drawn as rings by LevelMeshes. |
goal | LevelPoint or null | per preset (only course has one) | null removes a preset's goal. |
theme | LevelTheme | see below | |
seed | number | 7 | Seed 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:
kind | Meaning |
|---|---|
'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
| Preset | Layout | Good for |
|---|---|---|
arena | A fenced square with four pillars, a low central stage and scattered crates. Four spawns, one per side. | Tag, brawls, shooters. |
course | Nine 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. |
corridors | A 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
| Field | Type | Notes |
|---|---|---|
preset | preset name or 'custom' | |
size | number | |
bounds | { minX, maxX, minZ, maxZ } | The square, grown to hold every box (a course runs long in +Z). |
floorY, hasFloor, killY | number, boolean, number | |
boxes | ResolvedBox[] | minX, maxX, minY, maxY, minZ, maxZ, kind, color, and the flags moving, hidden, climb. |
pieces | ResolvedPiece[] | Ramp wedges and cylinders drawn over their hidden collision boxes. |
spawns, checkpoints | ResolvedPoint[] | { x, y, z, yaw } with every field filled. |
goal | ResolvedPoint or null | |
theme | Required<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.
kind | Fields | What it becomes |
|---|---|---|
'ramp' | w, d, rise, facing | Hidden 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, h | A 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 });
| Character | Cell |
|---|---|
# | Wall filling the cell, wallHeight tall. |
D | Wall with a door through it, along the row. |
| | Wall with a door through it, along the column. |
B | Crate, 0.8 m. |
P | Platform, platformHeight tall. |
O | Pillar (a cylinder), wallHeight tall. |
^, >, v, < | Ramp up to the north, east, south, west, rising to platformHeight. |
N, E, S, W | Stairs up to the north, east, south, west, rising to platformHeight. |
. or space | Empty. |
| Option | Default | Notes |
|---|---|---|
cell | 2 | Metres per cell. |
origin | centred on the origin | World { x, z } of the map's north-west corner. |
y | 0 | Bottom of every piece. |
wallHeight | 3 | |
platformHeight | 1 | |
legend | DEFAULT_LEGEND | Adds 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.
| Prop | Type | Default | Notes |
|---|---|---|---|
level | Level | required | |
nextCheckpoint | number | 0 | Rings with a lower index draw dim, as passed. |
showGoal | boolean | true |
For scenery beyond boxes, two procedural shapes are drawn only (no collision): add a box or a piece where they stand.
| Component | Props |
|---|---|
Extruded | outline ([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. |
Lathed | profile ([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.
| Function | Returns | What it does |
|---|---|---|
collideCapsule(level, body, prevY, capsule, out?, snapDown = 0) | CollisionResult | Pushes 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() | CollisionResult | A reusable result: grounded, hitWall, hitCeiling, wallNormalX, wallNormalZ, standingOn. |
raycastLevel(level, a, b, out?) | RayHit or null | The 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) | boolean | True when nothing blocks the segment. |
groundHeightAt(level, x, z, y, tolerance = 0.05) | number or null | The highest surface under (x, z) at or below y + tolerance, or the floor; null over a void. |
capsuleOf(params) | Capsule | The 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.
| Field | Range | Default | Feel |
|---|---|---|---|
walkSpeed | 1 to 8 m/s | 3.2 | A gentle stick. |
runSpeed | 2 to 14 m/s | 6.5 | A full stick, or sprint. |
acceleration | 8 to 80 m/s² | 32 | How snappy starts and stops are. Releasing the stick brakes 1.4 times harder. |
airControl | 0 to 1 | 0.35 | Share of acceleration you get in the air. |
jumpHeight | 0 to 4 m | 1.6 | 0 turns jumping off. |
gravity | 5 to 40 m/s² | 22 | High gravity with a high jump reads as arcade. |
maxFallSpeed | 5 to 40 m/s | 22 | |
coyoteTime | 0 to 0.3 s | 0.12 | A jump still works this long after walking off an edge. |
jumpBuffer | 0 to 0.3 s | 0.15 | A press this long before landing fires on landing. |
radius | 0.2 to 1 m | 0.4 | Body capsule. |
height | 0.8 to 3 m | 1.7 | Body capsule. |
stepHeight | 0 to 0.6 m | 0.35 | Ledges this high are stepped onto instead of bumped into. |
turnRate | 2 to 30 rad/s | 12 | How 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).
| Field | Type | Meaning |
|---|---|---|
moveX, moveY | number | Stick on the unit disc, +y forward. |
lookDX, lookDY | number | Look turn this frame, radians. +x turns left, +y looks down. |
jump | boolean | True for the one frame it was pressed. |
actionA, actionB | boolean | True for the one frame they were pressed. The controller ignores them: your rules read them. |
sprint | boolean | Held: 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.
| Field | Meaning |
|---|---|
x, y, z | Feet position. |
vx, vy, vz | Velocity. |
yaw | Body facing. |
lookYaw, lookPitch | Where the camera looks. Pitch is clamped to -1.2 to 1.25 rad, + looks down. |
grounded, hitWall | Contact this frame. |
standingOn | The ResolvedBox under the feet, or null for the floor or the air. |
speed | Planar speed, m/s. |
airTime | Seconds since last on the ground. |
jumped, landed | True on the one frame a jump started or the body landed. Use them for sounds, dust and bot noise. |
coyote, jumpBuffer | Timers the controller keeps. |
ledge | 'leap', 'hang', 'climb' or null, when you use ledges. |
Functions
| Function | What 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 field | Default | Meaning |
|---|---|---|
minReach | 1.1 m | Lowest ledge above the feet; anything lower is a step. |
maxReach | 2.25 m | Highest ledge above the feet. |
gap | 0.3 m | How close to the wall the body must be, beyond its radius. |
hangDrop | 1.8 m | Feet below the ledge while hanging. |
hangOff | 0.32 m | Chest off the wall while hanging. |
climbTime | 1.58 s | Seconds 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.
| Prop | Type | Default | Notes |
|---|---|---|---|
color | string | required | Body colour. |
accent | string | #F3F4FF | Belt and shoulder stripe. |
radius | number | 0.4 | Pass params.radius. |
height | number | 1.7 | Pass params.height. |
hidden | boolean | false | Hide the body, for first person. |
emissive | string | none | A glow (a chasing bot, the player who is "it"). |
model | number | none | A bundled .glb (require('../../assets/models/hero.glb')). The capsule shows until it loads. |
animation | string | none | The model's clip to play. Ignored while drive is set. |
clips | Record<string, number> | none | Extra clips from sibling files. |
modelYaw | number | 0 | Turns the model to face +Z when it was made facing another way. |
drive | { current: CharacterState } | none | Animates 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. |
motion | MotionConfig | see below | How the body reads the controller. |
onAnimationEvent | (event: string) => void | none | Clip events from the driven graph, such as footstep. |
footIK | Level | none | Plants the model's feet on ramps and steps (needs drive). |
lookAt | () => Vec3 or null | none | Turns the head toward a point while it returns one. Capsules turn their visor. |
The ref is a CharacterHandle:
| Member | Meaning |
|---|---|
group | The THREE.Group to position. |
yaw | The smoothly turned heading (with drive). Draw with group.rotation.y = handle.yaw for a body that turns like a body instead of snapping. |
hardLanding | True 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 field | Default | Meaning |
|---|---|---|
stopBelow | 0.35 m/s | A moving body below this counts as stopped. |
startAbove | 0.7 m/s | A stopped body above this counts as moving. |
response | 12 | How fast the animated speed follows the real one, per second. |
softLandBelow | 7 m/s | Landing slower than this is soft: no landing clip, no hardLanding. About a 2.5 m drop. |
turnRate | 14 | How 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.
| Mode | What it does | Stick 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.
| Field | Range | Default | Meaning |
|---|---|---|---|
distance | third 2 to 12, top 6 to 40, side 4 to 30 m | 6 / - / 16 / 14 | Metres from the pivot. |
pivot | 0.3 to 1.2 | 0.85 / 0.9 / 0.5 / 0.5 | Pivot height as a share of body height. |
pitch | third -20 to 60, top 30 to 89, side -10 to 30 degrees | 14 / - / 62 / 6 | Degrees looking down. In third person the look pitch adds to it. |
fov | 30 to 110 | 60 / 78 / 50 / 45 | Field of view, degrees. |
lag | 1 to 40 | 9 / 40 / 6 / 7 | Follow smoothing; higher is tighter. |
shoulder | -2 to 2 m | 0 / - / - / - | Sideways offset of the pivot, + right. 0.5 to 0.9 feels over the shoulder. |
yaw | degrees | - / - / 0 / 0 | Fixed yaw for top and side. 0 looks along +Z. |
speedFov | 0 to 20 | 8 / 0 / 0 / 0 | Extra 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.
| Prop | Type | Notes |
|---|---|---|
rig | CameraRig | When 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. |
level | Level | For the third-person pull-in. |
bodyHeight | number | params.height. |
speedRatio | () => number | 0 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);
}
| Export | Meaning |
|---|---|
CameraShot | { yaw, pitch, distance }: yaw in radians (like lookYaw), pitch in degrees, distance in metres. |
CameraZone | A 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>
);
| Prop | Type | Default | Notes |
|---|---|---|---|
input | CharacterInput | required | Create once with createCharacterInput(). |
disabled | boolean | false | Ignore touches and keys (a finish screen). |
stick | 'left' or 'right' | 'left' | Which half the stick lives in. 'right' for left-handed players. |
look | boolean | true | Dragging 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. |
buttons | ControlButton[] | one JUMP button, 92 pt | |
stickRadius | number | 56 | Stick travel in points, clamped to 40 to 90. |
deadZone | number | 0.12 | Share of the radius ignored, rescaled so there is no jump. |
secondThumb | boolean | false | No 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) => void | none | Fingers 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.
| Function | What 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_SENSITIVITY | 0.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.
| Keys | Action |
|---|---|
| WASD or the arrow keys | The stick (eight directions, full tilt). A thumb on the stick wins over the keys. |
| Space | The first button in buttons. |
| J, E or F | The second button. |
| K or Q | The third button. |
| L | The fourth button. |
| Shift | A button with id sprint, wherever it is listed. |
| Mouse drag on the look half | Turns 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);
| Export | Meaning |
|---|---|
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 prop | Default | Meaning |
|---|---|---|
perches | required | [x, y, z] per bird. |
near | required | () => { x, y, z }, usually () => body.current. |
radius | 6 m | Flush when something is this close. |
life | 7 s | Seconds of flight before a bird is gone. |
color | #0D0D0F | |
size | 0.5 m | Bird length. |
onFlush | none | Called 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.
| Area | Values | Types |
|---|---|---|
| Level | resolveLevel, groundHeightAt, LEVEL_PRESETS, PRESET_SIZE, buildPreset, resolvePiece, RAMP_STEP, STAIR_STEP, gridMap, DEFAULT_LEGEND, LevelMeshes, Extruded, Lathed | LevelDef, Level, LevelBox, LevelPiece, LevelPoint, LevelPreset, LevelTheme, BoxKind, Facing, ResolvedBox, ResolvedPiece, ResolvedPoint, Vec3, GridMapOptions, GridCell, ExtrudedProps, LathedProps |
| Collision | collideCapsule, createCollisionResult, raycastLevel, segmentClear | Capsule, CollisionResult, RayHit |
| Controller | resolveCharacterParams, createCharacterState, respawnCharacter, stepCharacter, speedForStick, capsuleOf, forwardOf, rightOf, createCommand, clearCommand, WORLD_FRAME | CharacterCommand, CharacterConfig, CharacterParams, CharacterState, MoveFrame |
| Ledges | createLedge, stepLedge, leapAtCliff, findLedge, CLIMB_PATH, climbPathAt | LedgeConfig, LedgeState |
| Motion and mesh | CharacterMesh, createMotion, stepMotion, pickMotionClips | CharacterHandle, MotionConfig, MotionInput, MotionState |
| Cameras | resolveCameraRig, CharacterCamera, computeCameraPose, createCameraPose, moveFrameFor, CAMERA_MODES, cameraModeLabel, nextCameraMode | CameraMode, CameraRig, CameraRigConfig, CameraPose |
| Shots | createShotDirector, stepShots, zoneShot, hangShot | CameraShot, CameraZone, ShotDirector |
| Controls | CharacterControls, createCharacterInput, resetCharacterInput, consumeInput, DEFAULT_LOOK_SENSITIVITY | CharacterInput, ControlButton, StickSide |
| Autopilot | waypointCommand, createWalker, worldDirToStick | Waypoint, WaypointWalker |
| Extras | Ripples, Flock, createFlock, stepFlock | RipplesHandle, Bird, FlockConfig, FlockState |
Gotchas
- The engine never respawns or finishes for you. Check
state.y < level.killYyourself, and use zones for checkpoints and goals (Rooftop Hop does both). Zones, paths andMovingPlatformare on the Utilities page. CharacterMeshdoes not follow the state. Setgroup.positionandgroup.rotation.yin youruseFrameafterstepCharacter, or the body stays at the origin.- Keep the rig stable.
CharacterCameracuts whenever therigobject changes. Build it withuseMemoor at module scope; a new object every render makes the camera snap every render. - Read custom buttons before
consumeInput. It clears everypressedflag, including ids it does not know. Readinput.pressed.myButtonfirst, or readinput.heldat any time. - Button order sets the keys. On the web, Space presses the first button in
buttons, not the one with idjump. Pulse Arena lists FIRE first, so Space fires there. Put JUMP first if Space should jump. speedsonCharacterMeshdefaults 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.LevelMeshesreadsnextCheckpointat 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,Lathedand 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.stepCharacterclamps 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,
Joystickand 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.