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
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
Physics.supported // static boolean — whether this host build has 3D physicsOn 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)
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 onconfigure()must run before any body exists —maxBodiesresizes the world and is ignored afterwards.interpolationsmooths 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)
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 scalePure 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).
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).
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
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 positionAttach 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'
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
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 pointCreates 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({ 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.
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 supportYou 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.
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()
})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
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:
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
// ✗ 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-fitSee also
- Aspects — attach/get/has/removeAspect,
updatephases - Pointer events — what a Shape's pick body enables
- Node — transform, events
- Ray, Plane & Noise — camera rays for
Physics.raycast - Conventions — "physics owns dynamic transforms", host gating