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.

ThirdPersonController and ThirdPersonCamera are the gameplay layer above a character: input becomes a request, the character controller answers it, and the answer is what the animator hears.

// Nothing to write. Both are components a scene declares.
player.add(ThirdPersonController, { moveSpeed: 320, jumpSpeed: 520 });
rig.add(ThirdPersonCamera, { target: player, distance: 420 });

The stick is read in the camera’s GROUND plane, so an eye angled steeply down still walks the character across the floor. Pitch never reaches the character.

Field What it does
moveSpeed Top speed on the ground, world units/second.
acceleration / deceleration How fast it reaches that speed, and sheds it.
rotationSpeed How fast it turns to face where it went, degrees/second.
jumpSpeed Upward speed a jump starts with. 0 cannot jump.
airControl How much of acceleration applies off the ground, 0..1.
camera Whose forward the stick is read against.

Three facts, and the whole of a combat system is keeping them apart:

an animation event ≠ a hit ≠ damage

The clip says only that the swing reached its effective moment. What was in that space is the physics world’s answer. What that does to anyone is gameplay’s. Collapse any two and you get a combat system that cannot be re-aimed, re-timed or re-balanced without touching the other two.

MeleeAttack sits beside an Animator whose attack clip declares three beats on a customEvent track — attack-start, hit, attack-end:

player.add(MeleeAttack, { radius: 70, damage: 25, anchor: swordSocket });
dummy.add(Health, { current: 100, max: 100 });
Field What it does
radius Radius of the sphere a hit beat tests, world units.
damage What a landed blow takes off.
layers Physics layers the swing can reach; 0 is every layer.
anchor The entity whose WORLD placement the query is centred on.
attackId Engine-written: the live swing, 0 when none is running.
hitCount Engine-written: how many targets it has landed on.

A swing is an INSTANCE with its own id and its own set of who it has already reached, so one swing lands on one target once and the next one may land again. A hit window is normally several hit beats, and leaning on “the event only fires once” instead breaks the first time a clip declares two — silently, as double damage, which reads as a balance problem rather than a bug.

Nothing in the swing writes health. A landed blow is sent on the Damage bus and one system answers it, so Health has a single author and every rule that will ever sit between a hit and a health bar has one seam to sit in:

import { defineSystem, EventReader, GetWorld, Res, Damage, Children, ParticleEmitter, Particle } from 'esengine';
const hitSparks = defineSystem(
[EventReader(Damage), GetWorld(), Res(Particle)],
(blows, world, particles) => {
for (const blow of blows) {
if (!world.has(blow.target, Children)) continue;
for (const child of world.get(blow.target, Children).entities) {
if (world.has(child, ParticleEmitter)) particles.play(child);
}
}
},
{ name: 'HitSparkSystem' },
);

A blow carries target, source, amount and the point it landed at — that point cannot be recovered later, because by the time a spark is drawn the swing has moved on. The bus is double buffered, so a blow sent this frame lands on the next.

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.