Skip to main content

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.

PropTypeDefaultWhat it does
gravityVec3{ x: 0, y: -9.81, z: 0 }m/s²
fixedStepnumber1/60Seconds per physics step
maxSubstepsnumber4Steps one frame may run to catch up before time is dropped
pausedbooleanfalseFreeze the simulation (a pause menu)
timeScalenumber1Simulation speed
worldPhysicsWorldits ownA world you made yourself (tests, gameplay code that shares it)

Frame deltas are capped at 0.1 s before stepping.

Bodies​

ComponentWhat it is
RigidBodyA dynamic body: falls, rolls, gets knocked about
StaticBodyNever moves: walls, floors. May use any shape, trimeshes included
TriggerVolumeAn invisible static zone that reports bodies entering and leaving; collides with nothing
LevelBodiesMakes 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:

PropTypeWhat it does
shapea Shape (below)Required. RigidBody takes any shape except trimesh
position[x, y, z] or Vec3Where 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
shapeOffsetVec3Where the shape sits relative to the body origin
namestringFor debugging and events
isTriggerbooleanReport overlaps instead of colliding
onBody(id: BodyId) => voidThe body's id, for world.positionOf(id) and joints
onCollide(event, other) => voidThis body started touching another
childrenReact.ReactNodeWhat 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​

kindFieldsNotes
'box'w, h, dFull size along x, y, z
'sphere'radius
'capsule'radius, heightUpright; height includes the caps
'cylinder'radius, heightUpright
'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.

kindFieldsWhat 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, pivotBBall 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​

HookWhat 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? }).

MethodWhat 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>
);
};
PropTypeDefaultWhat it does
position[x, y, z] or Vec3requiredWhere it starts; later changes are ignored (call reset)
yawnumber0Heading in radians, 0 faces +z
inputVehicleInput or (state, dt) => VehicleInputrequiredthrottle -1 to 1, steer -1 to 1 (positive toward +x), brake? 0 to 1, handbrake?
childrenReact.ReactNodea body and cabinThe chassis look, in chassis space (nose toward +z)
color, wheelColorstring'#E8ECF4', '#15161C'Default look
showWheelsbooleantrueHide them when your model has its own
onVehicle(vehicle: VehicleBody) => voidnoneThe vehicle: state, rideHeight, wheelPoses(), reset(position, yaw?)
onCollide(event, other) => voidnoneThe chassis hit something
onLand(airTime: number) => voidnoneLanded 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
centerOfMassDropnumberBallast: higher stops tipping
downforcenumber0Grip at speed
recoverAfternumber1.2Seconds 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} />;
};
PropTypeDefaultWhat it does
preset'crash', 'coins', 'shells', 'glass', 'hail', 'confetti''crash'The look and behaviour
countnumber300Pieces alive at once
proxiesbody ids or refs to them[]Moving bodies the pieces bounce off (the car, the player's capsule)
groundYnumber0The ground when there is no <Physics> world to read boxes from
timeScalenumber1
source{ at, direction, rate, area? }noneA continuous source: rate pieces per second (hail from a cloud)
any DebrisConfig fieldfrom presetburst, 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).