LeCodesdocs

3D physics

Jolt-backed physics as node aspects. Shape is the collision geometry; pairing it with Physics makes a rigid body, with Trigger a sensor zone, with CharacterController a kinematic player capsule. A Shape on its own already makes the node pointer-pickable (click / touchstart).

At a glance

TypeScript
Physics.configure({ gravity: [0, -9.81, 0] })    // once, before creating bodies

const ground = Mesh.plane({ material: mat, normal: [0, 1, 0], scale: 20 })
  .aspect(Shape, { box: [10, 0.1, 10] })
  .aspect(Physics, { motion: 'static' })

const crate = Mesh.box({ material: mat, position: [0, 4, 0] })
  .aspect(Shape, {})                             // auto box from the mesh
  .aspect(Physics, { mass: 2 })                  // dynamic by default

crate.physics.applyImpulse([0, 6, 0])
crate.addEventListener('enter', other => console.log('hit', other.name))
scene.add(ground, crate)

Host gating — Physics.supported

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

On a build without physics, every API on this page silently no-ops: aspects attach without error, id stays 0, velocity reads [0,0,0], raycasts miss, no events fire. Guard physics-dependent gameplay with Physics.supported.

World setup (static)

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
  • configure() must run before any body existsmaxBodies resizes the world and is ignored afterwards.
  • interpolation smooths rendered body transforms between the fixed 60 Hz steps. Turn it off to save per-frame transform writes with many moving bodies; they then advance in discrete steps.

Shape — collision geometry (and pickability)

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
  raycast?: boolean                                 // pointer rays can hit it; default true
})
// {} = auto: a box from the mesh AABB × world scale

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

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

Pickability: 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 a static snapshot at attach time. For a moving pickable object, give it a Physics body (which follows the sim) — don't rely on the bare pick body.

Physics — rigid body

TypeScript
node.aspect(Physics, {
  motion?: 'static' | 'dynamic' | 'kinematic'   // default 'dynamic'
  mass?: number                                 // kg, dynamic only; default 1
})

node.physics.id                        // native Jolt body id; 0 = not attached / no physics support
node.physics.velocity                  // Vec3, world units/s — get (fresh copy) / set
node.physics.applyImpulse(v): this     // instantaneous impulse (kg·m/s), wakes the body
node.physics.moveTo(p): this           // drive a kinematic body (or teleport) to a world position

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.

Physics owns the transform of a dynamic body. Don't set node.position per frame — drive it with velocity / applyImpulse; drive kinematic bodies with moveTo. 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 via moveTo and pushes dynamic bodies without being pushed back.

Contact events — 'enter' / 'exit'

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.

Trigger — sensor zone

TypeScript
node.aspect(Trigger)          // requires a Shape; no options

node.trigger.id               // native body id; 0 = no physics support
node.trigger.moveTo(p): this  // reposition the zone to a world point

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({ position: [0, 1, -6] })
  .aspect(Shape, { box: [1, 1, 0.2] })
  .aspect(Trigger)
goal.addEventListener('enter', other => win(other))

CharacterController — kinematic player

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) and does not use Physics.

TypeScript
node.aspect(CharacterController, {
  speed?: number       // horizontal move speed, world units/s; default 5
  jumpSpeed?: number   // jump take-off speed, world units/s; default 7
  gravity?: number     // character-only gravity, world units/s²; default -20 (separate from world gravity)
  maxSlope?: number    // max walkable slope in degrees; default 45 — set at attach, not later
})

node.controller.move(x: number, z: number): void   // horizontal intent, each in [-1, 1]; STICKY — send 0 to stop
node.controller.jump(): void                       // queued; consumed next frame if grounded
node.controller.teleport(x, y, z): this            // world position; clears vertical velocity
node.controller.grounded                           // boolean — standing on walkable ground
node.controller.groundState                        // 0 on-ground, 1 steep-slope, 2 touching-unsupported, 3 in-air
node.controller.velocity                           // Vec3, world units/s (fresh copy)
node.controller.id                                 // native character id; 0 = not attached / no support

You feed horizontal intent + jump; the controller integrates its own gravity each frame and Jolt resolves collisions against the world. It updates in the EARLY aspect phase, so input set this frame is consumed by this frame's step.

TypeScript
const hero = Mesh.cylinder({ material: mat, position: [0, 1, 0] })
  .aspect(Shape, { capsule: { halfHeight: 0.6, radius: 0.3 } })
  .aspect(CharacterController, { speed: 6, jumpSpeed: 8 })

setLoop(() => {
  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, z)
  if (Input.key('Space')) hero.controller.jump()
})
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.

Raycasts

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). 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) select(hit.node)
})

Pitfalls

TypeScript
// ✗ attach order — Physics/Trigger/CharacterController before Shape throws
node.aspect(Physics).aspect(Shape, {})
// ✓ Shape first
node.aspect(Shape, {}).aspect(Physics)

// ✗ writing the transform of a dynamic body — physics owns it
setLoop(() => { 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