Skip to content

3D Physics

A 3D model can be imported, animated, skinned and lit. This is what makes it stand somewhere: a rigid-body world powered by Jolt Physics, running in its own WebAssembly module.

It sits beside the 2D physics, not on top of it. A 2D scene keeps the solver, the units and the feel it already has, and a project that never asks for a 3D world never loads it.

An entity is in the world when it carries a RigidBody3D and a shape. A body with no shape has no extent to collide with, so it is left out entirely rather than falling forever as an invisible point.

cmds.spawn()
.insert(Transform, { position: { x: 0, y: 400, z: 0 } })
.insert(RigidBody3D, { bodyType: BodyType.Dynamic })
.insert(CapsuleCollider3D, { radius: 30, halfHeight: 50 });
Field What it does
bodyType Static never moves, Kinematic is moved by hand, Dynamic is solved.
gravityScale This body’s share of world gravity. 0 is weightlessness, not slow falling.
linearDamping / angularDamping How quickly motion bleeds off.
fixedRotation Freezes the orientation the body was given — what keeps a character from toppling. It does not right a body that starts tilted.
continuousCollision Checks the whole path a step covers rather than where the body ended up. What a bullet needs; off by default, since it costs more.
Component Extent
BoxCollider3D halfExtents on each axis.
SphereCollider3D radius.
CapsuleCollider3D An upright capsule: halfHeight is the cylinder’s half, with a cap of radius on each end, so the total height is 2 * (halfHeight + radius).

A MeshCollider3D collides against a mesh’s own triangles — a terrain, a staircase, the inside of a room. It is what a box and a capsule cannot say.

cmds.spawn()
.insert(Transform, {})
.insert(RigidBody3D, { bodyType: BodyType.Static })
.insert(MeshCollider3D, { mesh: 'assets/models/level.esmesh' });

For imported geometry that has to MOVE, use a ConvexCollider3D: it collides against the tightest convex volume around the same vertices, which a solver handles as readily as a box. A rock, a chamfered crate, a barrel that rolls — anything whose concave detail does not need to be felt.

cmds.spawn()
.insert(Transform, { position: { x: 0, y: 400, z: 0 } })
.insert(RigidBody3D, { bodyType: BodyType.Dynamic })
.insert(ConvexCollider3D, { mesh: 'assets/models/rock.esmesh' });

The viewport draws every 3D collider as a wireframe, turned the way its entity is — nothing else on screen says how big the box around a model is. It is on the same switch as the 2D collider gizmos.

An entity gets ONE body shape, chosen in the order BoxCollider3D, SphereCollider3D, MeshCollider3D, ConvexCollider3D, CapsuleCollider3D. Whatever loses — a disabled collider, one shadowed by that order, or any collider on an entity with no enabled RigidBody3D — is drawn dimmed and dashed: still there to author, visibly not what the world will collide with. A CharacterController3D capsule is drawn beside it, since a character is swept rather than solved.

The editor’s gizmos are hidden in play, so the running game draws its own. Turn the Physics3DDebugDraw resource on when you need to see why something got stuck where it did:

app.getResource(Physics3DDebugDraw).enabled = true;
Field What it does
enabled Off by default — an overlay costs a line per edge per frame.
showColliders The shape of every body the solver built.
showContacts A cross at each contact point reported this step.

A CharacterController3D replaces a RigidBody3D rather than accompanying one: a body the solver pushes and a character that sweeps are two answers to where an entity is. Set velocity from gameplay each step and read the result back.

const c = world.get(player, CharacterController3D);
c.velocity.x = input.axis('move') * 200;
if (input.justPressed('jump') && c.isOnFloor) c.velocity.y = 500;
Field What it does
velocity Desired velocity in world units/second. A positive y is a jump.
stepHeight Tallest step it climbs instead of stopping at. 0 climbs nothing.
snapDown How far it reaches down to stay on the floor over a crest. 0 is off.
maxSlope Steepest ground it can stand on; beyond this it slides.
pushForce How hard it shoves dynamic bodies it walks into. 0 moves nothing.
isOnFloor / floorNormal / realVelocity Outputs, read after the step.

A door, a rope bridge, a lift, a ragdoll: none of them are one body and a shape. They are two bodies and a rule about how they may move relative to each other. A joint lives on the entity that declares it and names the other one.

cmds.spawn()
.insert(Transform, { position: { x: 100, y: 0, z: 0 } })
.insert(RigidBody3D, {})
.insert(BoxCollider3D, { halfExtents: { x: 50, y: 100, z: 5 } })
.insert(HingeJoint3D, {
connectedEntity: frame,
anchor: { x: -50, y: 0, z: 0 }, // the door's own edge
axis: { x: 0, y: 1, z: 0 }, // upright
enableLimit: true, lowerAngle: 0, upperAngle: Math.PI / 2,
});
Component Rule
PointJoint3D A shared point, free to turn on all three axes.
HingeJoint3D One axis of rotation, with optional limits and a motor.
SliderJoint3D One axis of travel, with optional limits and a motor.
DistanceJoint3D A distance kept: a rope with maxLength, a rod when the lengths meet, a spring with frequency.
FixedJoint3D No freedom at all — the two move as one.

anchor and axis are written in the declaring entity’s own local space, in world units, and are resolved against its transform when the joint is made. That pose is also the zero of every limit: a door placed closed has angle 0 and opens to upperAngle.

Set motorSpeed from gameplay at any time — the world is re-driven when it changes. In the other direction, HingeJoint3D.angle and SliderJoint3D.translation are written back each step, so a game can ask how far the door swung without integrating the swing a second time.

The Physics3D resource answers spatial questions in world units.

const q = app.getResource(Physics3D);
// Is anything on this line?
const hit = q.raycast(muzzle, { x: 0, y: 0, z: -2000 }, ENEMIES);
// Can this thing GET there? A ray is infinitely thin and slips through gaps a
// moving body would not fit — that is what a swept shape answers instead.
const blocked = q.sphereCast(from, 30, travel, WORLD);
// What is already here, before something is spawned into it?
const occupants = q.overlapSphere(spawnPoint, 50);
Query Answers
raycast The nearest body on a line.
sphereCast The first body a moving sphere would meet.
overlapSphere / overlapBox Every body already inside a volume.

Every body is in one of sixteen layers, and a project says which layers hear from which. Both sides must agree — one of them refusing is enough to keep two bodies apart, so a rule only has to be written once to hold.

app.addPlugin(physics3dPlugin(url, {
layerMasks: [
0xFFFF, // 0: everything
0xFFFF, // 1: the world
~(1 << 3), // 2: bullets — not their own team
0xFFFF, // 3: that team
],
}));

Each step publishes what touched what into the Physics3DEvents resource. It is drained per fixed step, so a system reading it must run inside one — a read from Update sees whatever the last step happened to leave.

const events = app.getResource(Physics3DEvents);
for (const hit of events.contactEnters) {
// both entities, and where they met
spawnSparks(hit.pointX, hit.pointY, hit.pointZ);
}
for (const { sensorEntity, visitorEntity } of events.sensorEnters) {
if (sensorEntity === goalZone) score(visitorEntity);
}
Channel What it carries
contactEnters Both entities, the contact normal and the point.
contactExits Only the pair — see below.
sensorEnters / sensorExits The sensor first, then the visitor.

A collider with isSensor reports overlaps and stops nothing: the visitor passes straight through and shows up in sensorEnters instead of contactEnters.

Nothing stops an entity carrying both sets, but nothing merges them either: the two worlds do not see each other, and a 2D collider will never stop a 3D body. Pick one per scene.