Taos Engine · Chapter 17 Physics

Chapter 17 · Part III · Game Engine

Rigid-Body Physics

How do things fall, collide and come to rest? A thin wrapper around Jolt Physics compiled to WebAssembly: collision layers, shapes and memory ownership, stepping, ray casts, constraints, sensors, scene coupling and a character controller.

Games want things to fall, collide, and come to rest: crates tumbling off a ledge, barrels rolling down a ramp, a chain swinging from a hook, balls bouncing across terrain. Taos Engine provides this through a thin wrapper around the Jolt Physics engine, living in src/physics/. It is a rigid-body simulator: it tracks the position and orientation of solid objects, gives them mass and inertia, detects when they touch, and resolves the contacts so they bounce, slide, settle, and stack the way real objects do. This chapter is about that wrapper — what Jolt is, how Taos Engine hides its sharp edges behind a scene-graph-friendly API, and the simulation concepts you need to drive it.

Not every game needs a general simulator. A world made of integer-addressed blocks can resolve collisions with a cheap grid lookup, and a hand-written kinematic controller is simpler and more predictable there — that is exactly what the voxel game in the companion book does (Crafty Chapter 3). Rigid-body physics is the general-purpose path for everything else.

The whole subsystem is five files and about 1,200 lines. It is exercised by the physics_test sample (a demo harness with shape-rain, mesh terrain, height fields, convex hulls, and a pendulum chain), the character_controller_test sample (a capsule character that walks, jumps, climbs stairs, and trips a trigger zone), and by geo_physics, which drops bouncing balls onto streamed Cesium terrain (Geospatial Streaming).

Physics subsystem layers
Physics subsystem layers
§ 17.1

Why a Third-Party Physics Engine#

Rendering, the engine writes itself: a renderer is the point of the project, and every pass is something we want to understand down to the WGSL. A rigid-body solver is different. A correct, stable, fast one is a multi-year specialty — the hard parts are not the high-school physics of F = ma but the numerical problems hiding underneath it:

These are solved problems — just not cheaply solved. So Taos Engine does for physics what it does for nothing else in the renderer: it takes a best-in-class implementation off the shelf. The chosen engine is Jolt.

§ 17.2

What Jolt Is#

Jolt Physics is an open-source (MIT) rigid-body and collision engine written in C++ by Jorrit Rouwe, and battle-tested as the physics engine of Horizon Forbidden World. It is multi-threaded, deterministic, and designed for games: stable large stacks, a fast multi-layer broad phase, continuous collision, a rich shape library (boxes, spheres, capsules, convex hulls, triangle meshes, height fields, compounds), and constraints (hinges, points, sliders, …).

We do not run the C++. Jolt ships a WebAssembly build — jolt-physics on npm — compiled with Emscripten, which exposes the C++ classes to JavaScript through a generated glue layer. Calling new jolt.BoxShape(...) from TypeScript constructs a C++ object on the WASM heap and hands back a JS handle to it. That arrangement is powerful but has two costs Taos Engine's wrapper exists to manage:

  1. The WASM blob is large. We don't want every Taos Engine app paying for it. The loader (§17.3) keeps Jolt out of the bundle unless the app actually asks for physics.
  2. WASM objects are not garbage-collected. A JS handle going out of scope does not free the C++ object behind it; you must call destroy() explicitly or leak WASM heap. The wrapper (§17.6, §17.7) encapsulates that discipline so application code never touches it.

Naming. Throughout this chapter, Jolt means the raw WASM module (the jolt object in the code), and PhysicsWorld / PhysicsScene mean Taos Engine's wrappers around it. The wrapper deliberately exposes only a small, engine-flavored slice of Jolt's very large API.

§ 17.3

Loading the WASM Backend#

jolt_backend.ts is the only file in the engine that names jolt-physics, and it names it through a dynamic import() so the bundler splits Jolt into its own chunk. An app that never initializes physics never pulls that chunk into its graph — it ships zero Jolt bytes.

export type JoltModule = Awaited<ReturnType<typeof import('jolt-physics')['default']>>;

let loadPromise: Promise<JoltModule> | null = null;
let loaded: JoltModule | null = null;

export function loadJolt(opts: PhysicsInitOptions = {}): Promise<JoltModule> {
  if (!loadPromise) {
    loadPromise = (async () => {
      const { default: factory } = await import('jolt-physics/wasm');
      const moduleArgs: { locateFile?: (path: string) => string; wasmBinary?: ArrayBuffer } = {};
      if (opts.wasmBinary) {
        moduleArgs.wasmBinary = opts.wasmBinary;
      } else if (opts.wasmUrl) {
        const url = opts.wasmUrl;
        moduleArgs.locateFile = () => url;
      } else {
        // Default (Vite): let Vite emit + hash the `.wasm` and hand back its URL.
        const wasmUrl = await import('jolt-physics/jolt-physics.wasm.wasm?url');
        moduleArgs.locateFile = () => wasmUrl.default;
      }
      loaded = await factory(moduleArgs);
      return loaded;
    })();
  }
  return loadPromise;
}

Three things are worth pulling out:

The public entry points are initializePhysics(opts?) (await it once at startup) and, more commonly, PhysicsWorld.create(), which calls loadJolt for you.

§ 17.4

Fundamentals of Rigid-Body Simulation#

Before the API, the concepts. A rigid body is a solid object that does not deform: a box, a ball, a length of terrain. The simulator tracks, for each dynamic body, a position and orientation (together, its transform) and their rates of change — linear velocity and angular velocity. Each body also has a shape (its collision geometry), a mass, and an inertia tensor (how mass is distributed, which governs how torques spin it).

Bodies come in three motion types, all three reachable through Taos Engine's BodyOptions:

Motion typeMoves?Driven byTaos Engine BodyOptions
Staticnevernothing — it's nailed downmotion: 'static' (or dynamic: false)
Dynamicyesforces, gravity, collisionsmotion: 'dynamic' (the default)
Kinematicyesyour code sets its velocity; physics doesn't push backmotion: 'kinematic'

A static floor never moves but other things collide with it; a dynamic crate falls, bounces, and tumbles; a kinematic body (a moving platform, a scripted door, or the inner body of the character controller in §17.13) follows the transform your code gives it and shoves dynamics aside without being pushed back. The original boolean dynamic flag is still honored — dynamic: false means static — but motion is the fuller knob.

Each simulation step advances time by a fixed dt and runs the same pipeline:

The simulation step pipeline
The simulation step pipeline
  1. Apply forces & integrate velocities. Gravity (and any applied forces) change each dynamic body's velocity: v += (F/m)·dt.
  2. Broad phase. Cheaply find pairs of bodies whose bounding volumes overlap — the candidates for an actual collision. This is the O(N)-ish cull that makes thousands of bodies tractable (§17.5).
  3. Narrow phase. For each candidate pair, do the exact shape-vs-shape test and, where they overlap, compute contact points and contact normals.
  4. Solve constraints. A contact is a constraint ("these two surfaces must not interpenetrate"). The solver computes impulses that, applied to the bodies' velocities, satisfy every contact and joint at once — iterating several times for stability. This is where restitution (bounciness) and friction enter: restitution scales how much normal velocity survives the bounce; friction opposes sliding along the contact tangent.
  5. Integrate positions. Finally each body's position and orientation move by the freshly-solved velocity: x += v·dt.

Two body properties tune step 4, both exposed by Taos Engine as BodyOptions:

The critical practical fact about a fixed-step solver is that the step size must be bounded and roughly constant. Feed it a huge dt (after a breakpoint or a backgrounded tab) and bodies integrate so far in one step that they tunnel through walls and the solver explodes. §17.8 is entirely about defending against that.

Interactive · Restitution and FrictionOpen ↗
Interactive demo · loads when scrolled into view
Drag the sliders; each change drops a fresh wave. Crates and balls fall onto a 22° ramp, simulated by Jolt through PhysicsWorld and drawn by PhysicsScene. Restitution decides how much of the impact speed the balls keep, so raise it and they bounce instead of thudding. Friction decides whether the crates hold or slide: Jolt combines the two surfaces' friction as a geometric mean, and the readout compares that μ with tan 22°, the point where a resting crate starts to slip.
§ 17.5

Collision Layers and the Broad Phase#

The broad phase needs a way to skip pairs that can never collide. Two pieces of static level geometry, for instance, never move, so testing them against each other every frame is pure waste — but the deeper need is gameplay filtering: a player's bullets should hit the world and enemies but pass straight through the player who fired them; a trigger volume should notice only the player. Jolt expresses all of this with object layers and broad-phase layers.

Taos Engine uses Jolt's mask-based scheme, the same layer/mask model common across game engines. Every body carries two bit-sets: a layer (which groups it belongs to) and a mask (which groups it collides with). The named bits live in CollisionLayer:

export const CollisionLayer = {
  World:   1 << 0,  // static level geometry (mapped to the non-moving broad phase)
  Player:  1 << 1,
  Enemy:   1 << 2,
  Bullet:  1 << 3,
  Prop:    1 << 4,  // dynamic debris — the default for dynamic bodies
  Trigger: 1 << 5,  // sensor / trigger volumes
  All:     0xffff,
} as const;

A BodyOptions then names a body's layer and mask (both default sensibly — World for statics, Prop for dynamics, and mask: All). The collision rule is symmetric: two bodies interact only when each one's layer is in the other's mask. So a bullet that should ignore the player is created with layer: Bullet, mask: World | Enemy — the player's layer (Player) is absent from that mask, so the pair is dropped regardless of what the player's own mask says. The matching machinery is one filter object, not a hand-built matrix:

// The object layer packs a (group, mask) pair; the filter decodes and ANDs them.
const objectFilter = new jolt.ObjectLayerPairFilterMask();
// ... later, per body:
const layer = this.layerFilter.sGetObjectLayer(group, mask);  // pack into one ObjectLayer
Collision layers and the broad phase
Collision layers and the broad phase

On top of object layers sit broad-phase layers, a coarser grouping the acceleration structure is actually built around. Jolt keeps a separate spatial tree per broad-phase layer; keeping all statics in one and everything dynamic in another lets it hold the (rarely-changing) static tree built once while it rebuilds the (constantly-moving) dynamic tree each step. Taos Engine routes the World group to the non-moving layer and every other group to the moving layer:

const bpInterface = new jolt.BroadPhaseLayerInterfaceMask(NUM_BROAD_PHASE_LAYERS);
bpInterface.ConfigureLayer(bpNonMoving, STATIC_GROUPS, 0);   // STATIC_GROUPS = World
bpInterface.ConfigureLayer(bpMoving, DYNAMIC_GROUPS, 0);     // everything else

settings.mObjectLayerPairFilter = objectFilter;
settings.mBroadPhaseLayerInterface = bpInterface;
settings.mObjectVsBroadPhaseLayerFilter = new jolt.ObjectVsBroadPhaseLayerFilterMask(bpInterface);

The broad-phase mapping is purely a performance hint — a static sensor parked in the "moving" layer is still correct, just slightly less efficient — so the partition can follow the World bit without worrying about each body's exact motion type. The three filter objects (object-pair, broad-phase interface, object-vs-broad-phase) are handed to a JoltInterface, which takes ownership of them.

One sharp edge worth flagging, because it bites: collision queries (raycasts, character sweeps) need a layer filter that honors these mask rules, built with makeObjectLayerFilter(layer) / makeBroadPhaseFilter(layer) (which wrap Jolt's DefaultObjectLayerFilter / DefaultBroadPhaseLayerFilter). Jolt's superficially-tempting SpecifiedObjectLayerFilter does exact-equality on the packed layer value, so it silently matches nothing and a query — or a whole character — quietly falls through the world. The staticOnly flag on rayCast/rayDownY is implemented with a mask filter that accepts only the World group.

§ 17.6

The PhysicsWorld Abstraction#

PhysicsWorld is the pure-physics layer: it creates the Jolt world, exposes shape factories, steps the simulation, and answers ray queries. No rendering. Its constructor is private — you make one through the async factory, which loads the backend first:

static async create(opts?: PhysicsInitOptions): Promise<PhysicsWorld> {
  const jolt = await loadJolt(opts);
  return new PhysicsWorld(jolt);
}

The constructor (abbreviated in §17.5) builds the layer filters, constructs a JoltInterface from a JoltSettings, and caches two handles every later call needs: the PhysicsSystem (the world) and its BodyInterface (the factory/registry for bodies). Gravity defaults to Earth's (0, −9.81, 0) and is settable:

setGravity(x: number, y: number, z: number): void {
  const g = new this.jolt.Vec3(x, y, z);
  this.system.SetGravity(g);
  this.jolt.destroy(g);   // the temporary Vec3 is copied in; free it immediately
}

That destroy(g) is the WASM-memory discipline in miniature, and the subject of the next section.

The body factories are the meat of the public surface. Each takes a Taos Engine Vec3/Quaternion, builds the matching Jolt shape, and returns a Jolt Body:

FactoryShapeStatic / dynamic
addBox(half, pos, rot, opts?)box (half-extents)either
addSphere(radius, pos, opts?)sphereeither
addCapsule(halfHeight, radius, pos, rot, opts?)capsule (Y axis)either
addConvexHull(points, pos, rot, opts?)convex hull of a point clouddynamic
addMeshCollider(positions, indices, pos, rot, opts?)arbitrary triangle "soup"static only
addHeightField(heights, sampleCount, cellSize, originXz, opts?)regular height gridstatic only

Every factory takes the same BodyOptions, which beyond the physical tunables (restitution, friction, gravityFactor, motionQuality) carries the gameplay knobs from §17.5: layer, mask, motion, and — for trigger volumes — isSensor/sensor (§17.12). The capsule is the actor/projectile shape, and the foundation the character controller in §17.13 is built on; halfHeight is the half-length of the cylindrical section, so a capsule stands 2·(halfHeight + radius) tall.

The static-only restriction on meshes and height fields is not a Taos Engine limitation — it is intrinsic to rigid-body simulation. A triangle soup has no well-defined volume or inertia tensor, so it cannot be given mass and tumbled; every physics engine allows it on static (or kinematic) bodies only. For a dynamic object with a custom shape you give it a convex hull (which does have a defined volume and inertia), decomposing concave dynamic objects into several hulls if needed. §17.9's terrain example uses addMeshCollider; the physics_test height-field demo uses addHeightField, the fast, memory-light path for terrain (Heightmap Terrain's heightmaps map straight onto it).

§ 17.7

Shapes and WASM Memory Ownership#

Because Jolt objects live on the WASM heap and the JS garbage collector can't see them, every Jolt allocation has exactly one rule: whoever makes it, frees it — the moment it has been copied into whatever will own it long-term. The wrapper follows that rule rigorously so callers never have to.

Watch it play out in addMeshCollider. Building a MeshShape means feeding Jolt a TriangleList; each triangle is a transient Triangle built from three transient Vec3s. Every one of those is destroyed as soon as Jolt has copied it:

const triangles = new J.TriangleList();
triangles.reserve(indices.length / 3);
const v0 = new J.Vec3(0, 0, 0);
const v1 = new J.Vec3(0, 0, 0);
const v2 = new J.Vec3(0, 0, 0);
for (let t = 0; t < indices.length; t += 3) {
  const a = indices[t] * 3, b = indices[t + 1] * 3, c = indices[t + 2] * 3;
  v0.Set(positions[a], positions[a + 1], positions[a + 2]);
  v1.Set(positions[b], positions[b + 1], positions[b + 2]);
  v2.Set(positions[c], positions[c + 1], positions[c + 2]);
  const tri = new J.Triangle(v0, v1, v2);
  triangles.push_back(tri);   // Jolt copies the triangle into the list…
  J.destroy(tri);             // …so the transient is free to go now.
}
J.destroy(v0); J.destroy(v1); J.destroy(v2);   // the three reusable corners, too

Notice the three Vec3s are allocated once and reused via .Set() across the whole loop — a mesh with 50 000 triangles still only ever holds three live corner objects. This is the difference between a leak-free wrapper and one that thrashes the WASM heap.

WASM object ownership lifecycle
WASM object ownership lifecycle

Shapes carry a second subtlety: reference counting. A Jolt ShapeSettings.Create() returns a result that holds the only reference to the new shape. If we destroyed that result before the body had taken its own reference, the shape would be freed out from under the body. The wrapper threads the ref-count carefully:

private shapeFromSettings(settings: initJolt.ShapeSettings): initJolt.Shape {
  const result = settings.Create();
  if (result.HasError()) {
    const message = result.GetError().c_str();
    this.jolt.destroy(result);
    this.jolt.destroy(settings);
    throw new Error(`Jolt shape build failed: ${message}`);
  }
  const shape = result.Get();
  shape.AddRef();              // our own reference, taken BEFORE freeing the result
  this.jolt.destroy(result);   // result's reference goes…
  this.jolt.destroy(settings); // …but the shape survives on ours
  return shape;
}

The companion bodyFromSettings builds the shape, makes a body with it, then Release()s the bridging reference so the body becomes the sole owner — the shape's memory is then freed automatically when the body is destroyed. The error path is just as careful: a bad shape (degenerate triangles, an empty hull) surfaces as a thrown JS Error carrying Jolt's own message, not a silent WASM abort.

Finally, createBody turns a shape into a live body: it packs a BodyCreationSettings with the transform, motion type, layer, and the BodyOptions tunables, creates the body through the bodyInterface, frees every transient, and registers the body for teardown:

const settings = new J.BodyCreationSettings(shape, rPos, qRot, motion, layer);
settings.mRestitution = opts.restitution ?? 0.2;
if (opts.friction !== undefined) { settings.mFriction = opts.friction; }
if (opts.gravityFactor !== undefined) { settings.mGravityFactor = opts.gravityFactor; }
// ... optional initial linearVelocity ...
const body = this.bodyInterface.CreateBody(settings);
J.destroy(settings); J.destroy(rPos); J.destroy(qRot);
this.bodyInterface.AddBody(body.GetID(),
  dynamic ? J.EActivation_Activate : J.EActivation_DontActivate);
this.bodies.push(body);

bodies and constraints arrays let clear() tear a whole scene down — removing and destroying every body — and removeBody(body) drop a single one. destroy() clears the world and then frees the JoltInterface itself. None of this bookkeeping leaks into user code: you call addSphere, you get a body, and the engine owns the cleanup.

§ 17.8

Stepping the Simulation#

step(dt) advances the world. Its whole job beyond calling Jolt is to keep the fixed-step solver inside its stable envelope:

step(deltaTime: number): void {
  // Clamp pathological frames (tab switch, breakpoint) so nothing explodes.
  const dt = Math.min(deltaTime, 1 / 30);
  const steps = dt > 1 / 55 ? 2 : 1;
  this.joltInterface.Step(dt, steps);
}

Two defenses, both from §17.4's "bounded, roughly constant" rule:

You call step once per frame, typically inside beforeFrame. Nothing renders yet — the bodies have new transforms on the WASM heap; getting them onto the screen is PhysicsScene's job (§17.10).

Interactive · Stepping and TunnelingOpen ↗
Interactive demo · loads when scrolled into view
Raise the frame time, then turn the clamp off. A launcher fires 24 m/s balls at a 0.2 m wall, and the demo steps the world once per simulated frame, as a game running at that frame rate would. With the clamp on, step() caps the frame at 1/30 s and splits it into two sub-steps, so each step moves the ball only 0.4 m and the wall stops it; time just runs slower. Turn the clamp off and the whole frame goes to Jolt in one step: once the ball travels farther per step than the wall and ball are thick, it lands on the far side without ever touching the wall. Linear-cast CCD sweeps the ball along its path and catches the wall at any frame time.
§ 17.9

Ray Casting#

The one spatial query the wrapper exposes is a closest-hit ray cast — the workhorse for grounding a character against terrain, line-of-sight checks, and click-to-pick:

rayCast(origin: Vec3, direction: Vec3, staticOnly = false): Vec3 | null {
  const J = this.jolt;
  const o = new J.RVec3(origin.x, origin.y, origin.z);
  const d = new J.Vec3(direction.x, direction.y, direction.z);
  const ray = new J.RRayCast(o, d);
  const settings = new J.RayCastSettings();
  const collector = new J.CastRayClosestHitCollisionCollector();
  // staticOnly → a mask filter accepting only the World group (see §17.5);
  // otherwise an accept-all object filter, plus default bp/body/shape filters.
  this.system.GetNarrowPhaseQuery().CastRay(
    ray, settings, collector, bpFilter, objFilter, bodyFilter, shapeFilter);
  let hit: Vec3 | null = null;
  if (collector.HadHit()) {
    const f = collector.mHit.mFraction;   // 0..1 along the ray
    hit = new Vec3(origin.x + direction.x * f, origin.y + direction.y * f, origin.z + direction.z * f);
  }
  // ... destroy every transient (o, d, ray, settings, collector, 4 filters) ...
  return hit;
}

direction is the full ray vector — its length is the maximum cast distance — and the result is a world-space hit point or null. Passing staticOnly: true restricts the cast to the World group, so a ray fired from above an actor doesn't hit the actor itself — exactly what grounding a character wants. The mFraction Jolt returns is the normalized distance along the ray, so the hit point is a simple lerp. Note the by-now-familiar tail: every WASM transient allocated is destroyed. (RVec3 is Jolt's "real" double-precision vector for world positions, distinct from the single-precision Vec3 used for directions and extents.)

A companion rayDownY(x, y, z, dist, staticOnly?) is the allocation-free straight-down variant: it reuses cached scratch objects instead of allocating per call, so it's safe to fire hundreds of times a frame — for example baking a collision heightmap by sampling streamed terrain colliders.

§ 17.10

PhysicsScene: Coupling to the Scene Graph#

PhysicsWorld simulates; it knows nothing about meshes or the scene. PhysicsScene is the bridge: every body it spawns gets a paired GameObject + MeshRenderer, and once per frame it copies each dynamic body's transform onto its GameObject so the renderer draws bodies where physics put them.

The physics frame loop
The physics frame loop

The spawn helpers wrap the PhysicsWorld factories and attach a visual:

spawnBox(half: Vec3, pos: Vec3, opts: SpawnOptions = {}): initJolt.Body {
  const body = this.world.addBox(half, pos, opts.rot ?? null, opts);
  const mesh = this.boxMesh(half.x * 2, half.y * 2, half.z * 2);
  this.attach(body, mesh, pos, opts);
  return body;
}

SpawnOptions extends BodyOptions (the physics tunables) with visual ones — color, roughness, metallic, castShadow, initial rot. There are matching helpers for spheres, an addGround() slab, addStaticBox for walls and ramps, addStaticMesh/addHeightField for static colliders that come with their own render mesh, and spawnConvexHull for dynamic custom shapes.

Two efficiencies hide in attach and its mesh/material helpers. Meshes and materials are cached by shape and appearance, so a tower of 200 identical gray boxes shares one GPU mesh and one PbrMaterial:

private boxMesh(w: number, h: number, d: number): Mesh {
  const key = `box:${w.toFixed(3)}:${h.toFixed(3)}:${d.toFixed(3)}`;
  let mesh = this.meshCache.get(key);
  if (!mesh) {
    mesh = Mesh.createBox(this.device, w, h, d);
    this.meshCache.set(key, mesh);
  }
  return mesh;
}

And only dynamic bodies are tracked for syncing — a static floor's transform never changes, so re-copying it every frame would be waste. attach pushes to the per-frame dynamic list only when the body is dynamic.

sync() is the per-frame copy, and it is deliberately tiny — reading the Jolt transform straight into the GameObject's position/rotation with no allocation:

sync(): void {
  for (const { body, go } of this.dynamic) {
    const p = body.GetPosition();
    const q = body.GetRotation();
    go.setPosition(p.GetX(), p.GetY(), p.GetZ());
    const r = go.rotation;
    r.x = q.GetX(); r.y = q.GetY(); r.z = q.GetZ(); r.w = q.GetW();
  }
}

The whole loop, then, is three lines in beforeFrame: advance the simulation, copy transforms, let the engine render the scene as usual.

typical wiring, from samples/physics_test.tsTypeScript
const world = await PhysicsWorld.create();
const scene = new PhysicsScene(engine, world);
scene.addGround();

engine.beforeFrame((frame) => {
  world.step(frame.dt);   // 1. simulate
  scene.sync();           // 2. copy body transforms onto GameObjects
});                       // 3. engine renders the scene (as for any GameObject)

clear() removes every GameObject and clears the world in one call, freeing demo-built meshes (the shared box/sphere caches are intentionally kept), which is what lets the physics_test harness swap between demos cleanly.

§ 17.11

Constraints#

A constraint ties bodies together with a rule the solver enforces every step — the basis of hinges, ropes, ragdolls, and vehicles. Taos Engine surfaces the most common one, a point (ball-and-socket) constraint, which pins a point on one body to a point on another, leaving rotation free. pointLink builds it in world space:

pointLink(body1: initJolt.Body, body2: initJolt.Body, worldPivot: Vec3): void {
  const J = this.world.jolt;
  const settings = new J.PointConstraintSettings();
  settings.mSpace = J.EConstraintSpace_WorldSpace;
  const p1 = new J.RVec3(worldPivot.x, worldPivot.y, worldPivot.z);
  const p2 = new J.RVec3(worldPivot.x, worldPivot.y, worldPivot.z);
  settings.mPoint1 = p1;
  settings.mPoint2 = p2;
  this.world.addConstraint(settings, body1, body2);
  J.destroy(p1); J.destroy(p2);
}

Chaining several spheres with point links between consecutive pivots gives a swinging pendulum chain — exactly what the physics_test pendulum demo builds. The lower-level PhysicsWorld.addConstraint(settings, a, b) accepts any Jolt TwoBodyConstraintSettings (hinge, slider, distance, …), so reaching past pointLink to richer joints is a matter of constructing the appropriate settings object and handing it over — the wrapper consumes and destroys it for you, and tracks the resulting constraint for clear().

Interactive · Point ConstraintsOpen ↗
Interactive demo · loads when scrolled into view
Change the link count; drag to orbit. A chain of beads joined by pointLink, each joint pivoting at the midpoint between neighbors, with the first bead pinned to a static anchor. Released horizontally, it falls and whips through a stack of crates, so the solver handles joints and contacts in the same step. The chain re-releases every few seconds.
§ 17.12

Sensors and Triggers#

A sensor is a body that detects overlaps but imparts no collision response — the foundation of a door that opens when you walk up to it, a kill volume under the level, a reverb zone, an enemy's awareness radius. Any factory body becomes one by passing a sensor callback pair (or isSensor: true) in its BodyOptions:

TypeScript
// a static trigger volume that fires as the player enters and leaves
world.addBox(new Vec3(2, 1.2, 0.5), pos, null, {
  dynamic: false, layer: CollisionLayer.Trigger, mask: CollisionLayer.All,
  sensor: {
    onEnter: (other) => openDoor(),
    onExit:  (other) => {},
  },
});

Under the hood the world lazily installs a single Jolt ContactListenerJS the first time a sensor is registered, and routes its OnContactAdded / OnContactRemoved events to the right callbacks. Bodies are tracked in a map keyed by BodyID, so the listener can hand each callback the other body that triggered it (or undefined when the overlapper isn't a tracked body — e.g. the character controller's internal capsule). Sensors are also flagged to collide with kinematic and static overlappers, so a scripted or kinematic body walking into a static trigger still registers.

The symmetric layer/mask rule from §17.5 applies here too, and it is the usual cause of a trigger that "doesn't fire": for a sensor to detect a body, that body's mask must include the sensor's layer (Trigger), not only the other way round. The simplest way to stay out of trouble is to give bodies that should be broadly detectable a mask of All — which is exactly why the character controller defaults to it.

§ 17.13

The Character Controller#

Bullets and crates want full rigid-body dynamics; a player almost never does. A capsule driven by forces slides down ramps, tips over, bounces off steps, and generally fights the player for control. What a humanoid wants instead is move-and-slide kinematics: "try to move here; slide along whatever you hit; step up small ledges; stick to the floor going downhill" — with no bouncing and no tipping. Jolt provides exactly this as CharacterVirtual, an object that is not a body in the world but sweeps its shape through it each frame, and Taos Engine wraps it as CharacterController.

You create one against a world, set a desired horizontal velocity each frame, and call move(dt):

typical character loopTypeScript
const character = new CharacterController(world, {
  radius: 0.35, halfHeight: 0.6, position: new Vec3(0, 2, 0),
  layer: CollisionLayer.Player,
});

engine.beforeFrame((frame) => {
  character.velocity.x = inputX * walkSpeed;
  character.velocity.z = inputZ * walkSpeed;
  if (jumpPressed) { character.jump(jumpSpeed); }  // only takes effect when grounded
  world.step(frame.dt);     // advance dynamics (props, bullets, sensor contacts)…
  character.move(frame.dt);  // …then sweep the character against the updated world
});

move does three things: it folds gravity into velocity.y (zeroing accumulated fall speed while grounded so the character doesn't build phantom velocity standing still), runs Jolt's ExtendedUpdate to sweep-slide-and-stair-walk the capsule, then writes the collision-resolved velocity and position back. After it returns, position, velocity, isGrounded, groundState ('ground' | 'steep' | 'air' | 'unsupported'), and groundNormal are all readable — enough to drive an animation state machine and a camera.

Two design points are worth calling out, because both were sharp edges in the building:

The crucial loop ordering is world.step then character.move: the controller sweeps against the world as it stands after this frame's dynamics have moved, not before. The character_controller_test sample wires all of this — walking, jumping, stair-climbing, a trigger zone, and a layer-filtered "bullet" that passes through the player but bounces off the world.

Interactive · Character ControllerOpen ↗
Interactive demo · loads when scrolled into view
Click the view, then walk with WASD or the arrow keys; Space jumps. Until you press a key, a CharacterController walks the course on autopilot: up 0.2 m stairs, across a platform, down a 20° ramp it stays stuck to, over a sensor pad that lights when the character's inner body enters it, and on to a 55° ramp. With the default 50° limit the character cannot get onto that ramp and stalls at its foot; raise Max slope past 55° and it walks up. The readout shows groundState and the slope under the feet.
§ 17.14

Summary#

The physics subsystem is a deliberately thin, leak-free skin over a best-in-class engine:

Where the heightmap terrain of Heightmap Terrain gave us large static worlds, and the next chapter gives us planet-scale ones, this chapter gives those worlds dynamics — and Geospatial Streaming's geo_physics sample combines the two, building Jolt mesh colliders from streamed terrain tiles so balls can bounce down a real mountainside.