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

TypeScript
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

TypeScript
Physics.supported   // static boolean — whether this host build has 3D physics

On 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

TypeScript
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 on
  • gravity applies whenever you call configure() (an omitted gravity means the default again). maxBodies rebuilds the world, so it only takes effect before the first body exists and is ignored afterwards. Past the limit the engine refuses new bodies (their id stays 0) and Physics warns once — raise it before the level loads.
  • interpolation smooths 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)

TypeScript
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

TypeScript
// 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()

TypeScript
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

TypeScript
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: true is an exact triangle mesh — for level geometry, terrain, ramps, anything that doesn't move under simulation. It works for static / kinematic bodies, Triggers, the bare pick body (precise clicks) and as ground for a CharacterController. A dynamic body cannot be a triangle mesh (the engine has no mass model for one) — Physics throws; 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's geometry or a Model'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 Model share one built shape.
  • Nodes with neither geometry nor a GLB (or non-triangle geometry) fall back to the auto box with a console warning.
Note

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).

Note

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.

TypeScript
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.

Note

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.

TypeScript
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

TypeScript
ground.aspect(Physics, { motion: 'static', friction: 1 })    // dry tarmac
rink.aspect(Physics, { motion: 'static', friction: 0.02 })   // and back: rink.physics.friction = 1

0 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.6 is 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 a Vehicle's tires (which read the GROUND body's friction) would deliver only sqrt(1.2 × 0.2) ≈ 0.49 at the road tire's peak, half a g of cornering. Still name the surface where it matters: tarmac a car should really corner on is 1.

Detect collisions: 'enter' / 'exit' events

TypeScript
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).

TypeScript
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.

TypeScript
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.

TypeScript
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 velocity

Walk 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.

TypeScript
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's Move() takes the displacement — passing v * dt here 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.

TypeScript
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:

TypeScript
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 on grounded alone: in the frame after the blast the character has not left the ground yet, so that test ends the throw at once and the next move() 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.velocity is 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:

TypeScript
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.

Note

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

TypeScript
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:

TypeScript
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

TypeScript
// ✗ 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-fit

See also