3D physics
Jolt-backed 3D physics as node aspects: Shape is the collision geometry,
plus Physics for a rigid body, Trigger for a sensor zone, CharacterController for a walking
player. A car is a Vehicle, a falling skeleton a Ragdoll; 2D games
use Shape2D / Physics2D.
- make objects fall and collide under gravity
- push or throw an object with an impulse, or teleport it
- detect when two objects touch or something enters a zone
- move a player character that walks, jumps, climbs stairs and crouches
- knock a character back, or let it fly or swim
- find what a ray or a tap hits in the 3D scene
- fit a collider to a model, or collide with its real triangles
- make a floor slippery or grippy
Platforms: desktop, the web (Web Lite, lecodes web), the web player, the le.codes viewer app,
headless tests; iOS and Android in the full engine variant only (a shell's -3d variant has no
physics) — check Physics.supported.
At a glance
Physics.configure({ gravity: [0, -9.81, 0] }) // before the first body
const scene = new Scene()
const mat = Material.lit({ color: '#8a9bb0' })
const ground = Mesh.plane({ material: mat, normal: [0, 1, 0], scale: 20, name: 'ground' })
.aspect(Shape, { box: [10, 0.1, 10] }) // HALF-extents, world units
.aspect(Physics, { motion: 'static' })
const crate = Mesh.box({ material: mat, position: [0, 4, 0], name: 'crate' })
.aspect(Shape, {}) // auto: a box fitted to the mesh
.aspect(Physics, { mass: 2 }) // dynamic by default
crate.physics.applyImpulse([0, 6, 0]) // kg·m/s — 2 kg: +3 m/s up
crate.addEventListener('enter', other => console.log('hit', other.name)) // → "hit ground"
scene.add(ground, crate)
scene.open()Hosts without physics: Physics.supported
Physics.supported // static boolean — whether this host build has 3D physicsOn a build without physics the aspects attach without error and do nothing: id stays 0,
velocity reads [0,0,0], no events fire. The static calls — Physics.configure,
Physics.raycast and setting Physics.interpolation — throw there (the engine has no such
method), so guard them, and physics-dependent gameplay, with Physics.supported.
Such a build is an iOS / Android shell on the -3d engine variant. lecodes app picks the
variant from the bundle (a body, a character or a ray cast puts it on full), so this happens only
when app.json forces "ios": { "variant": "3d" } / "android": { "variant": "3d" } — the sync
warns.
The engine is JoltPhysics (creator-physics). The native hosts link it — desktop, iOS, Android and
the windowless lecodes-server behind lecodes render --sim / lecodes test --sim — and the web
player runs it compiled to wasm; Web Lite (lecodes web) fetches a wasm copy when the bundle names
a physics call. The headless renderer (lecodes render / lecodes test without --sim or
--desktop) runs a separately built wasm copy that lags the native engine:
| on the headless renderer | |
|---|---|
rigid bodies, impulses, friction, contact and trigger events, raycasts, mesh colliders, CharacterController |
the same numbers as --sim |
Vehicle |
the host refuses the car's settings — it never moves |
Ragdoll |
the host refuses the parts — activate() returns false |
shape.fit() on a node without a mesh of its own (a Model root) |
nothing to measure — it warns and changes nothing |
Test physics with --sim (the real engine, no GPU, no window) or --desktop.
Gravity and world setup: Physics.configure
Physics.configure(config?: {
gravity?: Vec3Like // world units/s², Y-up (down is negative); default [0, -9.81, 0]
maxBodies?: number // world capacity; default 4096
}): void
Physics.interpolation = true // static get/set; default ongravityapplies whenever you callconfigure()(an omittedgravitymeans the default again).maxBodiesrebuilds the world, so it only takes effect before the first body exists and is ignored afterwards. Past the limit the engine refuses new bodies (theiridstays0) andPhysicswarns once — raise it before the level loads.interpolationsmooths rendered body transforms between the fixed 60 Hz steps: what a frame draws is a blend of the two most recent steps by how far the frame clock sits into the next one, so a frame that ran two steps (or none) is drawn as smoothly as one that ran exactly one. Turn it off to save per-frame transform writes with many moving bodies; they then advance in discrete steps.
Collision shapes: Shape (box, sphere, capsule, mesh)
node.aspect(Shape, {
box?: Vec3Like // HALF-extents [hx, hy, hz], world units
sphere?: number // radius
cylinder?: { halfHeight: number, radius: number } // Y-aligned
capsule?: { halfHeight: number, radius: number } // Y-aligned; halfHeight = cylinder section
mesh?: true | 'convex' // the node's own triangles (Mesh or Model) × world scale
heightfield?: true // the node's Terrain grid — see terrain.md
origin?: Vec3Like // the collider's centre, offset from the node; default [0,0,0]
raycast?: boolean // pointer rays can hit it; default true
})
// {} = auto: a box from the mesh AABB × world scale
node.shape.kind // 'auto' | 'box' | 'sphere' | 'cylinder' | 'capsule' | 'mesh' | 'convex' | 'heightfield'Pure geometry — a Shape alone is not rigid and not a sensor. Pick exactly one kind, or pass {}
to auto-fit a box to the node's geometry (its rendered size, world scale included; nodes without
geometry fall back to half-extents 0.5, still multiplied by the node's world scale). A
heightfield collider is a Terrain's own grid, holes included.
Move the collider off the pivot: origin
// a barrel modelled standing ON its origin: lift the collider to wrap the art
barrel.aspect(Shape, { capsule: { halfHeight: 0.5, radius: 0.4 }, origin: [0, 0.9, 0] })origin is the shape's centre relative to the node, in world units on the node's rotated frame
— the same convention as the dimensions above, so the node's scale doesn't touch it. Most art
pivots sit at a character's feet or a wheel's axle rather than at the centre of volume, and a
collider centred on the pivot is then half sunk into the floor.
On a dynamic body the offset moves the centre of mass too, so a lopsided collider tips the way
you'd expect. An auto shape ({}) already centres itself on the mesh bounds it measured; origin
overrides that.
Fit the collider to a model: shape.fit()
const main = async () => {
const tree = await Model.load(asset('./tree.glb'))
tree.aspect(Shape, {}).shape.fit() // box around what it actually draws
hero.aspect(Shape, {}).shape.fit('capsule') // capsule of the same height and girth
}
main()shape.fit(kind?) sizes the collider from the node's rendered subtree — its dimensions and
its origin — which is what {} can't do on its own: an auto box only measures the node's own
mesh, so a Model root (whose triangles live in child entities) stays a 1×1×1 stub until fitted.
Without an argument it keeps the current kind; auto becomes an explicit box, and mesh /
convex have nothing to size (their geometry is the mesh) so they only lose their origin.
A model loads asynchronously — fit after its load() resolves, or there is nothing to measure
(the call warns and does nothing). A sphere takes the largest half-extent and a cylinder/capsule
the larger of X/Z, so the result reads as the object's silhouette rather than as an enclosing ball.
The scene editor's Shape card has the same thing as a Fit to mesh button, plus an origin
field. Re-configuring a live shape (node.aspect(Shape, { box }), or fit() itself) rebuilds the
geometry in place: the body keeps its id, its velocity and its place in the world.
Collide with a model's real triangles: mesh
const main = async () => {
const level = await Model.load(asset('./level.glb'))
level.aspect(Shape, { mesh: true }).aspect(Physics, { motion: 'static' }) // walk on the actual floor
ramp.aspect(Shape, { mesh: true }).aspect(Physics, { motion: 'static' }) // a Mesh.plane / custom Geometry
rock.aspect(Shape, { mesh: 'convex' }).aspect(Physics, { mass: 5 }) // dynamic prop: convex hull
}
main()mesh: trueis an exact triangle mesh — for level geometry, terrain, ramps, anything that doesn't move under simulation. It works forstatic/kinematicbodies,Triggers, the bare pick body (precise clicks) and as ground for aCharacterController. A dynamic body cannot be a triangle mesh (the engine has no mass model for one) —Physicsthrows; use'convex'.mesh: 'convex'is the convex hull of the vertices (Jolt keeps ≤ 256 hull points) — works for every motion type, the right choice for dynamic props that a box/sphere fits badly.- The triangles come from the node itself: a
Mesh'sgeometryor aModel's GLB (every non-skinned triangle primitive, baked with its sub-node transforms — the bind pose; skinned parts are skipped and the collider never follows animation, so animated characters keep a capsule). The node's world scale is applied, like{}. - Build cost is paid once at attach (a BVH for big meshes): attach level colliders during loading,
not mid-gameplay. Clones of the same
Modelshare one built shape. - Nodes with neither geometry nor a GLB (or non-triangle geometry) fall back to the auto box with a console warning.
explicit dims are world-space half-extents, not scaled by the node — a box: [0.5, 0.5, 0.5] is a 1×1×1 collider no matter the node's scale. Mesh.box() defaults to a
1×1×1 mesh, so its matching collider is box: [0.5, 0.5, 0.5] (or just {}).
Make a 3D object clickable: the Shape pick body
A Shape creates a lightweight pick-only body (collides with nothing), so any shaped node
receives click / touchstart — see pointer events. Attaching
Physics / Trigger replaces that body with the real one (still pickable). Set raycast: false
to opt out of picking (also hides it from Physics.raycast).
the pick-only body is static — it follows node.position writes (they move the body
too), but not motion applied any other way. For an object the simulation moves, give it a
Physics body; the bare pick body won't follow it.
Objects that fall and collide: Physics rigid bodies
Physics turns a shaped node into a rigid body that falls under gravity, collides with other
bodies and is pushed by impulses.
node.aspect(Physics, {
motion?: 'static' | 'dynamic' | 'kinematic' // default 'dynamic'
mass?: number // kg, dynamic only; default 1
friction?: number // surface friction; default 0.6 (see below)
})
node.physics.id // native Jolt body id; 0 = not attached / no physics support
node.physics.friction // get / set at runtime — an ice patch that melts
node.physics.velocity // Vec3, world units/s — get (fresh copy) / set
node.physics.angularVelocity // Vec3, DEGREES/s about each world axis — get / set
node.physics.velocity.y = 5 // component write (either vector) — compiled to a read + set on
// the body; direct spelling only, a stored copy stays a copy
node.physics.applyImpulse(v): this // instantaneous impulse (kg·m/s), wakes the body
node.physics.applyImpulseAt(v, point) // the same, at a WORLD point: the lever arm also spins it
node.position = [x, y, z] // place / teleport it — there is no moveTo()Attach order matters: Physics requires a Shape on the node already — attaching it first
throws "Physics requires a Shape aspect". Same for Trigger and CharacterController.
Teleport or reset a body: node.position
node.position is how you place anything. A Shape hands its body id to the node, so a position
write reaches the collider as well as the node — one rule for a bare Shape, a Physics body of any
motion type, a Trigger zone and a CharacterController. Writing position on a physics node used to
be a silent no-op (the next sync overwrote it); now it is the move.
quaternion / eulerAngles writes are routed the same way, and so are the translation and
rotation halves of a matrix / worldMatrix write. Two things are deliberately not routed: a
node's scale (a Jolt shape is built at a fixed size — that is Shape's job, via its dimensions or
fit()), and a CharacterController's rotation, which stays node-side because a capsule is
symmetric about its up axis and the engine never simulates its yaw.
animateTo / animateFrom tweens are not routed: they move the node and leave the
collider where it was. Tween a plain node, or move the body yourself.
Placing an object is three writes, not one. A position write is a pure teleport — it does not
touch either velocity — so a crate that was tumbling keeps tumbling at its new home, and one that was
falling keeps falling. There is deliberately no reset(): a body's whole state is these three lines,
and every game wants a different subset of them.
crate.physics.velocity = [0, 0, 0]
crate.physics.angularVelocity = [0, 0, 0] // degrees/s — the SDK's angle unit, not radians
crate.position = [0, 5, 0](A Vehicle is the exception: car.vehicle.reset(p) exists because a car also carries
engine RPM and wheel spin, which nothing else can clear.)
Push, throw and spin a body: impulses and motion types
A central impulse never spins a body. applyImpulse acts at the centre of mass, so a crate
thrown by it translates with its faces frozen. Use applyImpulseAt(v, worldPoint) when the push has
a real point of contact — a bullet's hit point, a blast pushing a prop at its base — and the lever
arm becomes angular impulse. It falls back to the central impulse on a host that predates it.
Physics owns the transform of a dynamic body. A position write is a teleport — it lands, but
the sim keeps the velocity and takes over again immediately, so don't drive motion that way: use
velocity / applyImpulse. Reading node.position / node.worldPosition is always correct
(native re-syncs it). See conventions.
Motion types: static never moves (walls, floors); dynamic is fully simulated (gravity,
collisions, mass); kinematic is script-driven — write its position each frame (from an
updateBefore), or give it a velocity it keeps — and pushes dynamic bodies without being pushed
back.
Slippery and grippy surfaces: friction
ground.aspect(Physics, { motion: 'static', friction: 1 }) // dry tarmac
rink.aspect(Physics, { motion: 'static', friction: 0.02 }) // and back: rink.physics.friction = 10 is frictionless, ~1 is grippy asphalt, above 1 is allowed (rubber on tarmac). Two things about it surprise people:
- The two bodies COMBINE as
sqrt(a × b), so the lower value wins the argument — a grippy crate on a slippery floor still slides. - The default
0.6is a neutral solid surface, not a real one. It is deliberately higher than the raw Jolt default of 0.2, which is ice by game standards — a 0.2 floor caps everything standing on it, and aVehicle's tires (which read the GROUND body's friction) would deliver onlysqrt(1.2 × 0.2) ≈ 0.49at the road tire's peak, half a g of cornering. Still name the surface where it matters: tarmac a car should really corner on is1.
Detect collisions: 'enter' / 'exit' events
node.addEventListener('enter', (other: Node) => { /* contact / overlap began */ })
node.addEventListener('exit', (other: Node) => { /* contact / overlap ended */ })Fired on both nodes of a contact (rigid-rigid) or overlap (body-trigger), each receiving the
other node as the argument. The node needs a Shape plus a Physics or Trigger body (a
Vehicle chassis counts); a CharacterController is not a body and fires none.
Detect what enters a zone: Trigger
A Trigger makes a shaped node a zone that reports the bodies entering and leaving it — a goal, a
checkpoint, a pickup — without blocking anything. A CharacterController is not a body: a trigger
does not see it (the note under Crouch).
node.aspect(Trigger) // requires a Shape; no options
node.trigger.id // native body id; 0 = no physics support
node.position = [x, y, z] // reposition the zone (node + sensor body together)Creates a static sensor body from the node's Shape: physics bodies overlapping it fire the
node's 'enter' / 'exit' instead of colliding. A trigger never blocks movement and stays
pointer-pickable.
const goal = Mesh.box({ material: mat, position: [0, 1, -6] })
.aspect(Shape, { box: [1, 1, 0.2] })
.aspect(Trigger)
goal.addEventListener('enter', other => console.log(`${other.name} scored`))Player character movement: CharacterController
Jolt CharacterVirtual — a collide-and-slide controller, not a rigid body: no bouncing or
tipping, crisp control, built-in stair stepping and slope handling. Requires a Shape (a capsule
is recommended; mesh: true throws) and does not use Physics.
node.aspect(CharacterController, {
gravityScale?: number // multiplier over the WORLD gravity; default 2. 0 = free mode (see below)
maxSlope?: number // max walkable slope in degrees; default 45. Live — change it any time
})
node.controller.move(x: number, z: number): void // this frame's horizontal velocity, world units/s
node.controller.move(v: Vec2Like | Vec3Like): void // [x, z] — or [x, y, z] in free mode
node.controller.velocityY // rw, world units/s — jump / dash
node.controller.velocity // READ: what the last step actually did
// WRITE: latch the whole velocity — a knockback
node.controller.velocity.y = 7 // same jump as velocityY = 7 (compiled to the owner's setter;
// .x / .z latch the whole vector). Direct spelling only — a
// stored copy (`const v = c.velocity; v.y = 7`) is just a copy
node.controller.grounded // boolean — standing on walkable ground
node.controller.groundState // 'ground' | 'slope' | 'unsupported' | 'air'
node.controller.gravityScale // rw
node.controller.maxSlope // rw
node.controller.resizing // a requested capsule resize hasn't fit yet
node.controller.id // native character id; 0 = not attached / no support
node.position = [x, y, z] // spawn / respawn — drops the fall speed and any latched velocityWalk and jump: move() and velocityY
They have different natures, so they have different channels — this is the whole design:
move(x, z) |
velocityY |
velocity = |
|
|---|---|---|---|
| what | horizontal | vertical | all three |
| physics | muscle-driven, not conserved | ballistic, conserved | ballistic, conserved |
| lifetime | expires every frame | latched — gravity works on it | latched until move() takes the axis |
| you call it | every frame, from an updateBefore |
on an event: jump, dash | on an event: knockback, wall jump |
So releasing the keys needs no explicit zero: no move() this frame means standing still. And a jump
is just velocityY = 7 (or velocity.y = 7 written directly on the controller — the compiler routes
that component write to the same setter) — no built-in ground check, because coyote
time, double jumps and wall jumps all need your own condition. velocityY += 3 stacks an explosion on
top of the current motion.
Everything runs on the engine's fixed clock: the character advances in the same 60 Hz sub-steps as every rigid body (identical trajectory at 30 or 144 fps), gravity is integrated in the engine, and the aspect has no per-frame update — a hundred characters cost zero JS work per frame.
const hero = Mesh.capsule({ material: mat, radius: 0.3, length: 1.2, position: [0, 1, 0] })
.aspect(Shape, { capsule: { halfHeight: 0.6, radius: 0.3 } }) // the same 1.8 m capsule
.aspect(CharacterController)
const SPEED = 6
class HeroControls extends System<'heroControls'> {
updateBefore() { // EARLY: this frame's step walks on it
const x = (Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0)
const z = (Input.key('KeyS') ? 1 : 0) - (Input.key('KeyW') ? 1 : 0)
hero.controller.move(x * SPEED, z * SPEED) // units/s; no call = standing still
}
}
Input.on('keydown', e => {
if (e.code === 'Space' && hero.controller.grounded) hero.controller.velocityY = 7
})
scene.add(hero).system(HeroControls)
move()takes a VELOCITY (units/s), not a per-frame displacement. Unity'sMove()takes the displacement — passingv * dthere gives a character 60× too slow, silently.
Fly or swim: free mode, gravityScale = 0
For a swimmer, a flyer or a drone the vertical is muscle-driven too, so the whole velocity becomes a
per-frame command and move() takes three components. Zero gravity also turns off stick-to-floor
and stair stepping — otherwise Jolt would keep snapping a flying character down to the floor it
passes over.
drone.controller.gravityScale = 0 // or aspect(CharacterController, { gravityScale: 0 })
const key = (code: string) => (Input.key(code) ? 1 : 0)
class DroneControls extends System<'droneControls'> {
updateBefore() { // stops when you stop
drone.controller.move([(key('KeyD') - key('KeyA')) * SPEED, (key('KeyE') - key('KeyQ')) * SPEED, (key('KeyS') - key('KeyW')) * SPEED])
}
}The 3-component form with gravity ON overwrites the falling speed every frame, so the character hangs
in the air; the SDK warns once if you do it. velocityY is the ballistic channel — with a 3-component
move() driving the vertical each frame it has nothing to hold, but gravityScale = 0 plus the
2-component move() plus velocityY is a valid third combination: weightless flight that keeps its
momentum until you cancel it.
Knockback, wall jumps, launch pads: latched velocity
Writing velocity is the third mode: motion nobody is commanding. It does not expire like
move(), so the character keeps flying, and gravity still pulls the vertical down into a real
ballistic arc.
One rule keeps the two horizontal sources from ever fighting — the last writer owns the axis. A
latch persists until move() claims X/Z back, so a game simply doesn't call move() while the throw
lasts, and ends it on its own terms:
let thrown: 'no' | 'launched' | 'flying' = 'no'
const blast = (dx: number, dz: number) => { // a unit direction on the ground
hero.controller.velocity = [dx * 12, 6, dz * 12]
thrown = 'launched'
}
class HeroControls extends System<'heroControls'> {
updateBefore() {
const c = hero.controller
if (thrown === 'launched' && !c.grounded) thrown = 'flying' // it has left the ground…
else if (thrown === 'flying' && c.grounded) thrown = 'no' // …and landed: the throw is over
if (thrown === 'no') c.move((Input.key('KeyD') ? SPEED : 0) - (Input.key('KeyA') ? SPEED : 0), 0)
}
}Nothing clears the latch on its own — landing included. A kinematic controller has no friction, so a thrown character that never gets a
move()keeps sliding along the ground. That is deliberate: one rule (last writer wins) instead of two. End the throw yourself — but not ongroundedalone: in the frame after the blast the character has not left the ground yet, so that test ends the throw at once and the nextmove()cancels it (measured: 0.2 m of a 7 m knockback).
gravityScale = 0 plus a latch is weightless flight that keeps its momentum — the "spaceship", as
opposed to the swimmer driven by a 3-component move().
Detect a blocked character: reading velocity
It is the velocity the solver ended up with after the most recent step, not what you commanded: walk into a wall and it reads ~0, slide along one and it reads the tangent. Compare it against what you asked for to detect being blocked.
Unlike Unity's synchronous Move(), our step runs later in the frame, so a read is up to one frame
old — irrelevant for animation speed, fall damage or "am I blocked", which is what it is for. (Exact
wall contact would need a contact normal; the engine does not surface one yet.)
Reading is not the inverse of writing:
c.velocity = c.velocityis not a no-op, because the read reports the measured result — against a wall it is ~0 and assigning it back would cancel the throw. In flight the two agree closely, so read-modify-write mid-air behaves as you'd expect.
Rotation and scale stay yours. The engine writes only the character's position, so eulerAngles
/ quaternion / scale on the rig behave like on any other node — turn the character directly, no
child node needed to carry the visuals. (A capsule is symmetric about its up axis, so its yaw is
physically irrelevant; the collider is unaffected either way.)
Crouch: resize the capsule
Resizing goes through the Shape aspect itself; there is no separate call:
hero.aspect(Shape, { capsule: CROUCHED }) // shrinking always fits
hero.aspect(Shape, { capsule: STANDING }) // may be REFUSED under a low ceiling
if (hero.controller.resizing) { /* still crouched — call it again next frame */ }A refusal changes nothing — no half-applied state to recover from — so the retry is simply the same call again, and that retry is the headroom test. It is exact, because it tries the real capsule; a hand-rolled raycast can only approximate it (a ray is an infinitely thin line, a capsule has girth).
The feet stay planted across a resize: the character shifts by the change in the collider's lower bound, so it neither hovers after crouching nor sinks after standing.
the controller collides with solid bodies but passes through triggers, and (v1) it is not itself detected by triggers and is not pointer-pickable while active — a ray through it hits whatever is behind.
What a ray or a tap hits: Physics.raycast
Physics.raycast(origin: Vec3Like, dir: Vec3Like, maxDist = 1000): RayHit | null
interface RayHit {
node: Node | null // the hit node (null if it's no longer registered)
point: Vec3 // world-space hit point
normal: Vec3 // world-space surface normal
fraction: number // 0..1 along the ray (hit distance = fraction × maxDist × |dir|)
}Hits the closest pickable body (any Shape with raycast left true — including pick-only
and trigger bodies, and a ragdoll's parts, which report the bone). Combine with
camera.getRay for tap-to-select:
scene.addEventListener('click', ev => {
const ray = scene.camera.getRay(ev.clientX, ev.clientY)
const hit = Physics.raycast(ray.origin, ray.dir, 100)
if (hit?.node) console.log('selected', hit.node.name)
})Pitfalls
// ✗ attach order — Physics/Trigger/CharacterController before Shape throws
node.aspect(Physics).aspect(Shape, {})
// ✓ Shape first
node.aspect(Shape, {}).aspect(Physics)
// ✗ moving a dynamic body through its transform every frame — the sim takes it back each step
crate.y += 0.1
// ✓ drive the body
crate.physics.velocity = [0, 3, 0]
// ✗ full extents where half-extents are expected — collider twice the mesh
crate.aspect(Shape, { box: [1, 1, 1] }) // 2×2×2 collider around a 1×1×1 box mesh
// ✓
crate.aspect(Shape, { box: [0.5, 0.5, 0.5] }) // or {} to auto-fitSee also
- Aspects — attach/get/has/removeAspect,
update/updateBeforephases - Systems — scene-wide logic such as player controls
- Pointer events — what a Shape's pick body enables
- Node — transform, events
- Vehicle, Ragdoll, Navigation — built on these bodies
- Terrain — the
heightfieldcollider - Ray, Plane & Noise — camera rays for
Physics.raycast - Conventions — "physics owns dynamic transforms", host gating