Physics
The physics module (src/engine/physics/, exported from @/engine) makes the world react: crates that fall and stack, balls that bounce, doors on hinges, pickups through trigger volumes, impacts that thud. It has two tiers behind one typed vocabulary:
- Rigid bodies for the few objects that matter, on cannon-es (pure JavaScript, no WebAssembly), stepped at a fixed 60 Hz from the frame loop.
- GPU debris for the many small things (crash pieces, coins, glass, hail, confetti): thousands of pieces simulated in a TypeGPU compute shader, drawn in one call. They bounce off the world, not off each other.
Units are metres, kilograms and seconds; +Y is up. Gameplay rules (scores, lives) stay in your code. Physics is what the world does when it is hit. The player's character keeps the character controller for movement (Character and cameras); its capsule shoves dynamic bodies and is never pushed.
Smallest working example
A floor and a crate that falls onto it, with a thud on impact:
import React from 'react';
import { EngineCanvas, Environment, Physics, RigidBody, StaticBody, impactStrength, useCollisions } from '@/engine';
const Thuds = () => {
useCollisions((event) => {
const strength = impactStrength(event, 1, 10); // 0..1
if (strength > 0.1) console.log('thud', strength.toFixed(2));
});
return null;
};
export const CrateScene = () => (
<EngineCanvas camera={{ position: [0, 4, 8] }}>
<Environment preset="day" />
<Physics>
<Thuds />
<StaticBody shape={{ kind: 'box', w: 20, h: 1, d: 20 }} position={[0, -0.5, 0]}>
<mesh receiveShadow>
<boxGeometry args={[20, 1, 20]} />
<meshStandardMaterial color="#5A6A5A" />
</mesh>
</StaticBody>
<RigidBody shape={{ kind: 'box', w: 0.8, h: 0.8, d: 0.8 }} mass={4} material="wood" position={[0, 4, 0]} rotation={[0.3, 0.4, 0]}>
<mesh castShadow>
<boxGeometry args={[0.8, 0.8, 0.8]} />
<meshStandardMaterial color="#A86F3A" />
</mesh>
</RigidBody>
</Physics>
</EngineCanvas>
);
Put the meshes at the body's origin inside RigidBody: the body moves the group. In a level made with the character foundation, add <LevelBodies level={level} /> inside <Physics> and the level's boxes become solid.
Components
Physics
Owns one PhysicsWorld and steps it every frame before anything reads positions (priority -10), then dispatches the frame's collision and trigger events in order.
| Prop | Type | Default | What it does |
|---|---|---|---|
gravity | Vec3 | { x: 0, y: -9.81, z: 0 } | m/s² |
fixedStep | number | 1/60 | Seconds per physics step |
maxSubsteps | number | 4 | Steps one frame may run to catch up before time is dropped |
paused | boolean | false | Freeze the simulation (a pause menu) |
timeScale | number | 1 | Simulation speed |
world | PhysicsWorld | its own | A world you made yourself (tests, gameplay code that shares it) |
Frame deltas are capped at 0.1 s before stepping.
Bodies
| Component | What it is |
|---|---|
RigidBody | A dynamic body: falls, rolls, gets knocked about |
StaticBody | Never moves: walls, floors. May use any shape, trimeshes included |
TriggerVolume | An invisible static zone that reports bodies entering and leaving; collides with nothing |
LevelBodies | Makes a character-foundation Level solid: a static box per level box and a ground slab under the floor. Moving platforms are skipped |
Props shared by RigidBody and StaticBody:
| Prop | Type | What it does |
|---|---|---|
shape | a Shape (below) | Required. RigidBody takes any shape except trimesh |
position | [x, y, z] or Vec3 | Where it starts |
rotation | [x, y, z] | Euler radians |
material | 'default', 'wood', 'metal', 'rubber', 'ice' or { friction?, restitution? } | Surface. Default friction 0.4, restitution 0.1 |
shapeOffset | Vec3 | Where the shape sits relative to the body origin |
name | string | For debugging and events |
isTrigger | boolean | Report overlaps instead of colliding |
onBody | (id: BodyId) => void | The body's id, for world.positionOf(id) and joints |
onCollide | (event, other) => void | This body started touching another |
children | React.ReactNode | What to draw |
Extra RigidBody props: mass (kg, default 1), velocity, angularVelocity, damping (default 0.01 per second), allowSleep (default true), ignoredByWheels (vehicle wheels roll through it instead of climbing it: cones, bottles).
TriggerVolume props: shape (not a trimesh), position, rotation, onEnter(other), onExit(other), onBody, children.
<TriggerVolume shape={{ kind: 'sphere', radius: 0.7 }} position={[3, 0.7, 2]} onEnter={(other) => { if (other === playerBody.current) collect(); }}>
<mesh>
<sphereGeometry args={[0.3]} />
<meshStandardMaterial color="#FFD23F" />
</mesh>
</TriggerVolume>
Shapes
kind | Fields | Notes |
|---|---|---|
'box' | w, h, d | Full size along x, y, z |
'sphere' | radius | |
'capsule' | radius, height | Upright; height includes the caps |
'cylinder' | radius, height | Upright |
'convex' | points: Vec3[] | The convex hull of the points, relative to the body origin |
'trimesh' | vertices: number[], indices: number[] | Exact triangles; static bodies only |
Helpers: convexFromPoints(points, maxVertices = 64), meshPoints(object3d) (the points of a mesh, for a hull) and shapeHalfExtents(shape). MATERIALS holds the named materials. When two bodies touch, friction is multiplied and bounce takes the larger value.
Joint
A joint between two bodies, by id (from onBody), removed on unmount. It is only added once both bodies exist.
kind | Fields | What it is |
|---|---|---|
'hinge' | pivot, axis, pivotB?, limits?: { min, max }, motor?: { speed, maxTorque? } | A door, a wheel, a pendulum. Limits are radians from the rest pose |
'point' | pivot, pivotB | Ball and socket |
'distance' | distance? | A rope or rod (default: the current distance) |
'lock' | A weld |
All joints take a, b, collide? (let the two bodies hit each other, default false) and onJoint?(id).
The player's capsule: useCharacterBody
const state = useRef(createCharacterState(spawn));
const playerBody = useCharacterBody(state, params); // a ref to the capsule's BodyId
Inside <Physics>, call it in the component that steps the player. A kinematic capsule mirrors the controller's state right before each step, with its velocity, so crates the player walks into are shoved. Options: { material?, name? }. A kinematic body pushes any dynamic body whatever its mass, so things that must stop the player are static bodies or level boxes. attachCharacter(world, params, options) is the same thing without React.
Hooks
| Hook | What it does |
|---|---|
usePhysics() | The nearest <Physics> world; throws outside one |
usePhysicsOptional() | The same, or null outside one |
useCollisions(fn) | Every CollisionEvent in the world, after each step |
useTriggers(fn) | Every TriggerEvent (enter and exit) |
impactStrength(event, soft = 1, hard = 10) | How hard a hit was, 0 to 1, from the closing speed in m/s |
A CollisionEvent is { kind: 'collision', a, b, impulse, speed, point, normal }, reported once per contact (a landing is one event), not per contact point. A TriggerEvent is { kind: 'trigger', trigger, other, phase: 'enter' | 'exit' }.
PhysicsWorld
The world behind <Physics>, usable headless (tests, gameplay reads and pokes). new PhysicsWorld({ gravity?, fixedStep?, maxSubsteps? }).
| Method | What it does |
|---|---|
addBody(def), removeBody(id), has(id), kindOf(id) | Bodies by BodyDef (kind: 'static', 'dynamic' or 'kinematic') |
positionOf(id), velocityOf(id), quaternionOf(id), isSleeping(id) | Exact state after the last step |
transformOf(id) | Interpolated for rendering between the last two steps |
setTransform(id, position, quaternion?) | Teleport; wakes it |
moveKinematic(id, position, velocity, quaternion?) | Move a kinematic body for this frame, before the step |
applyImpulse(id, impulse, worldPoint?) | An instant kick in N·s |
applyForce(id, force, worldPoint?) | A force in N for this step; call every frame while it acts |
setVelocity(id, velocity, angular?) | |
raycast(from, to, options?) | Nearest hit { body, point, normal, distance } or null |
raycastAll(from, to, options?) | Every hit, nearest first |
addJoint(def), removeJoint(id), hasJoint(id), hingeAngle(id) | Joints |
onCollision(fn), onTrigger(fn) | Subscribe; returns the unsubscribe |
step(dt) | Advance by real seconds in fixed steps (<Physics> calls it) |
stepFixed(n) | Exactly n fixed steps (tests) |
drainEvents(), dispatchEvents() | The event queue (<Physics> dispatches once per frame) |
stats | { bodies, awake, joints, stepMs, substeps } |
dispose() | Release everything |
Ray options: filter(body) to skip bodies, skipTriggers (default true). fromLevel(world, level) is what LevelBodies calls; it returns the ids it added.
Vehicle
A drivable car: a chassis on four sprung raycast wheels that drives, slides, drifts with the handbrake, flies off ramps, lands and rights itself after a while on its roof. It hits every body in the world. Use it for driving games that are not lap races; the racing module keeps its own arcade car.
import React, { useMemo, useRef } from 'react';
import { Physics, Vehicle, impactStrength, shake, type VehicleBody, type VehicleInput } from '@/engine';
export const Buggy = () => {
const input = useMemo<VehicleInput>(() => ({ throttle: 0, steer: 0 }), []); // your controls write it
const car = useRef<VehicleBody | null>(null);
return (
<Physics>
<Vehicle
position={[0, 1, 0]}
input={input}
color="#FF5C3A"
onVehicle={(v) => (car.current = v)}
onLand={(airTime) => shake(Math.min(0.6, airTime * 0.4))}
onCollide={(event) => {
if (impactStrength(event) > 0.3) shake(0.3);
}}
/>
</Physics>
);
};
| Prop | Type | Default | What it does |
|---|---|---|---|
position | [x, y, z] or Vec3 | required | Where it starts; later changes are ignored (call reset) |
yaw | number | 0 | Heading in radians, 0 faces +z |
input | VehicleInput or (state, dt) => VehicleInput | required | throttle -1 to 1, steer -1 to 1 (positive toward +x), brake? 0 to 1, handbrake? |
children | React.ReactNode | a body and cabin | The chassis look, in chassis space (nose toward +z) |
color, wheelColor | string | '#E8ECF4', '#15161C' | Default look |
showWheels | boolean | true | Hide them when your model has its own |
onVehicle | (vehicle: VehicleBody) => void | none | The vehicle: state, rideHeight, wheelPoses(), reset(position, yaw?) |
onCollide | (event, other) => void | none | The chassis hit something |
onLand | (airTime: number) => void | none | Landed after airTime seconds in the air |
chassis | { w?, h?, d?, mass? } | 1.8 by 0.5 by 3.6, 150 kg | |
wheel | { radius?, halfTrack?, halfBase?, suspensionRest?, stiffness?, friction?, rollInfluence?, mount? } | friction is grip, about the g the tyres hold | |
engine | { force?, brake?, coast?, maxSteer?, maxSpeed? } | Drive force per rear wheel, brake force, engine braking, steering in radians, top speed in m/s | |
centerOfMassDrop | number | Ballast: higher stops tipping | |
downforce | number | 0 | Grip at speed |
recoverAfter | number | 1.2 | Seconds on its roof before it rights itself; 0 turns it off |
VehicleBody.state has speed, yaw, steer, spin, wheelsDown, airborne, airTime, landed, flipped, recoveries. Headless: createVehicle(world, def), then drive(input, dt) before each step.
Debris
Many small pieces on the GPU that bounce off the world's static boxes and a few moving bodies. They settle, sleep and shrink away at the end of their life. Bursts reuse the oldest pieces when count is full.
import React, { useRef } from 'react';
import { Debris, type DebrisHandle } from '@/engine';
export const Coins = () => {
const coins = useRef<DebrisHandle>(null);
// later, from gameplay: coins.current?.burst({ x: 0, y: 1, z: 0 }, { x: 0, y: 1, z: 0 }, { count: 20 });
return <Debris ref={coins} preset="coins" count={200} />;
};
| Prop | Type | Default | What it does |
|---|---|---|---|
preset | 'crash', 'coins', 'shells', 'glass', 'hail', 'confetti' | 'crash' | The look and behaviour |
count | number | 300 | Pieces alive at once |
proxies | body ids or refs to them | [] | Moving bodies the pieces bounce off (the car, the player's capsule) |
groundY | number | 0 | The ground when there is no <Physics> world to read boxes from |
timeScale | number | 1 | |
source | { at, direction, rate, area? } | none | A continuous source: rate pieces per second (hail from a cloud) |
any DebrisConfig field | from preset | burst, speed, spread, size, sizeJitter, life, fade, gravity, restitution, friction, drag, spin, spinDamping, sleepSpeed, color, mesh ('chunk', 'coin', 'shard', 'ball') |
The handle has one method: burst(at, direction, { count?, speed?, spread? }). DEBRIS_PRESETS holds the presets. Colliders are fixed-size GPU arrays: at most 96 static boxes and 16 moving spheres.
PhysicsDebug
A development-only line (bodies awake and total, joints, step time, substeps) drawn outside the canvas: <PhysicsDebug world={world} extra="..." />. With <EngineCanvas debug>, <Physics> also adds a physics line to the debug overlay. Neither shows in production builds.
Gotchas
- Rigid bodies run in JavaScript, and Hermes on the phones has no JIT, so they are expensive. The template's physics skill measured 20 bodies at about 0.5 ms a step, while 200 toppling boxes cost 80 to 100 ms (the game drops to about 12 fps) and even 200 sleeping bodies cost 10 ms. Keep a level under about 60 bodies with 30 awake at once, let things sleep, and remove what left the level.
- Quantity goes into
Debris: thousands of pieces cost one draw call per<Debris>. - Trimeshes only on static bodies, and only when a box or a hull will not do.
- Dynamic bodies should be direct children of the scene, not nested under moving groups.
- A hinged gate swings only if no level box overlaps its leaf and it hangs a few centimetres above the floor (a leaf resting on the ground is pinned by friction).
<Model collides="box">,"sphere"or"hull"gives a generated model a body (Models and scenery).- The world costs nothing when
<Physics>is not rendered. Games without moving props do not need it. - For trigger areas that do not need bodies at all (checkpoints, "near the shop"), zones are cheaper (Utilities).