Physics
Rigid bodies, colliders, triggers and queries: give an entity a body and the simulation moves it, stops it against the level, and tells you what it touched.
component: physics_body
Jolt
Desktop · Android · iOS · Web
Two modules split the work. awake:physics:api is the backend-neutral PhysicsWorld contract, and
awake:scene:physics binds it to the ECS through the physics_body component and PhysicsSystem.
The simulation itself comes from a backend; today that is Jolt.
Add a physics body
Give an entity a physics_body component. A static body is level geometry that never moves; a
dynamic body falls, collides and comes to rest. Both forms below build the same components.
{
"version": 1,
"nodes": [
{
"name": "floor",
"transform": { "position": { "x": 0.0, "y": -0.1, "z": 0.0 } },
"components": [
{
"component": "physics_body",
"shape": { "type": "box", "halfExtents": { "x": 10.0, "y": 0.1, "z": 10.0 } }
}
]
},
{
"name": "crate",
"transform": { "position": { "x": 0.0, "y": 3.0, "z": 0.0 } },
"components": [
{
"component": "physics_body",
"shape": { "type": "box", "halfExtents": { "x": 0.5, "y": 0.5, "z": 0.5 } },
"motion": "DYNAMIC"
}
]
}
]
}
physics_body is not one of the default components. Register it before you load the document:
pass SceneComponentRegistry().registerPhysics() as the componentRegistry. The project runtime
in awake:project:runtime registers it for you.
world.scene {
entity("floor") {
transform(y = -0.1f)
with(PhysicsBody(BoxShape(Vec3f(10f, 0.1f, 10f)), MotionType.STATIC))
}
entity("crate") {
transform(y = 3f)
with(PhysicsBody(BoxShape(Vec3f(0.5f, 0.5f, 0.5f)), MotionType.DYNAMIC))
}
}
There is no dedicated DSL function for a body yet, so attach a PhysicsBody with with(...).
- Select an entity in the Hierarchy.
- In the Inspector, open Add component and choose Physics Body. Studio adds a 1 m
box that is
DYNAMIC. - Set Motion to
STATIC,KINEMATICorDYNAMIC.
Studio cannot change the shape, layer or sensor flag yet. Edit those in the scene document.
Step the simulation
PhysicsSystem creates each body the first time it sees it, steps the world once per update, and
writes the simulated pose back into the entity's Transform.
val physics = PhysicsSystem(createJoltPhysicsWorld())
repeat(120) { physics.update(world, 1f / 60f) }
In an app, register it as a fixed-step system so it steps at the same rate on every machine:
fixedSystem("physics") { PhysicsSystem(physicsWorld) }.
Properties
| Property | Type | Default | What it does |
|---|---|---|---|
shape |
box · sphere · capsule · mesh |
box |
The collider, centred on the entity. |
shape.halfExtents |
vector | (0.5, 0.5, 0.5) |
Half the box size on each axis (box). |
shape.radius |
number | 0.5 |
Sphere or capsule radius. |
shape.halfHeight |
number | 0.5 |
Half the capsule's cylinder, so the capsule is 2 * (halfHeight + radius) tall. |
shape.mesh |
string | required | Project path of the .glb or .gltf model whose triangles collide (mesh). |
shape.primitive |
integer or none | none | Which primitive of the model, counted in node order. None merges them all (mesh). |
motion |
STATIC · KINEMATIC · DYNAMIC |
STATIC |
Level geometry, driven by code, or simulated. |
layer |
integer or none | 0 for STATIC, 1 otherwise |
Collision layer index. See collision layers. |
sensor |
boolean | false |
Detects what passes through it instead of blocking it. |
degreesOfFreedom |
ALL · PLANE_2D |
ALL |
Allowed motion in world axes. PLANE_2D permits X/Y translation and Z rotation. |
In Kotlin, PhysicsBody(shape, motionType, layer, sensor) takes any shape, not only the
four a scene document can describe.
Physics in 2D
Set degreesOfFreedom to PLANE_2D on a body to simulate in the XY plane:
{
"component": "physics_body",
"shape": { "type": "box", "halfExtents": { "x": 0.5, "y": 0.5, "z": 0.25 } },
"motion": "DYNAMIC",
"degreesOfFreedom": "PLANE_2D"
}
The Kotlin API takes degreesOfFreedom = DegreesOfFreedom.PLANE_2D in createBody(...) or
PhysicsBody(...). The restriction is fixed at creation and applies to velocity writes, impulses,
gravity, contacts and kinematic movement. It uses Jolt's allowed degrees of freedom on Desktop,
Android, iOS and Web; the solver enforces the restriction during simulation.
The node's initial position and rotation are preserved. Translation on Z and rotation about X/Y are locked in world axes. Use a Z rotation for an upright 2D body. An origin shift can move the whole plane. Collision geometry remains three-dimensional: give boxes positive Z thickness and place bodies whose colliders should meet at overlapping Z positions. Sprite render ordering is independent of this setting.
Collide with a model
A mesh shape makes a static body of a model's own triangles, so a character walks over a bridge
and under an arch rather than into their bounding boxes:
The body is STATIC and not a sensor; a scene that asks for anything else fails validation. The
node's scale is baked into the triangles, any per-axis or mirrored scale included, and the body is
placed by the node's position and rotation like every other body. Saving writes the model path back,
not the triangles.
The shape loads as a MeshCollider and MeshColliderSystem builds the body from it, so register
that system before PhysicsSystem. It reads the triangles from a CollisionMeshSource;
loadCollisionMeshes(scene, files) in awake:project:runtime is the glTF one, reading each model
once and failing with the model path and node when one cannot be read. loadProject does
all of this for you. A host that calls sceneSystemsFor itself passes
content = loadSceneContent(scene, files) in its SceneHostServices, which reads the collision
meshes with everything else the scene's systems need.
For moving props or sensors, use a convex_hull shape instead:
{ "component": "physics_body", "shape": { "type": "convex_hull", "mesh": "models/crate.glb" }, "motion": "DYNAMIC" }
A convex_hull shrink-wraps the model's vertices into a convex shape with scale baked in. Because it
encloses volume, it can be DYNAMIC, KINEMATIC, or STATIC, and can act as a trigger sensor. It loads
through the same loadCollisionMeshes cache and MeshColliderSystem.
Shapes
| Shape | Motion | Use it for |
|---|---|---|
BoxShape(halfExtents) |
any | Crates, walls, floors. |
SphereShape(radius) |
any | Balls, projectiles, pickups. |
CapsuleShape(halfHeight, radius) |
any | Characters: it slides over steps where a box catches. |
ConvexHullShape(points) |
any | A prop that moves and is not a box. Concave detail is lost. |
MeshShape(vertices, indices) |
STATIC only |
Authored level geometry. |
HeightFieldShape(heights, sampleCount, scale) |
STATIC only |
Terrain. The grid is square, at least 4 samples a side. |
A heightfield for terrain, and a convex hull for a prop that rests on it:
val sampleCount = 16
val heights = FloatArray(sampleCount * sampleCount) // flat ground at y = 0
val terrain = HeightFieldShape(heights, sampleCount, scale = Vec3f(1f, 1f, 1f))
world.createBody(terrain, Vec3f(0f, 0f, 0f), Quat.IDENTITY, MotionType.STATIC) // static only
val wedge = ConvexHullShape(
floatArrayOf(
-0.5f, 0f, -0.5f, 0.5f, 0f, -0.5f, -0.5f, 0f, 0.5f, 0.5f, 0f, 0.5f, // base
-0.5f, 0.5f, -0.5f, 0.5f, 0.5f, -0.5f, // ridge
),
)
val prop = world.createBody(wedge, Vec3f(0f, 3f, 0f), Quat.IDENTITY, MotionType.DYNAMIC)
Winding decides which side of a mesh is solid
Jolt collides with the face a triangle's winding points at. Wind MeshShape triangles
counter-clockwise as seen from the solid side, or bodies fall straight through while rays
still hit it.
Query the world
Queries go to the PhysicsWorld. Casts ask what would stop you, so they never report sensors;
overlaps ask what you are inside, so they do.
Raycast. The first body along a ray, or null. RaycastHit carries handle, point and
distance; it has no surface normal.
val hit = world.raycast(origin = Vec3f(0f, 10f, 0f), direction = Vec3f(0f, -1f, 0f), maxDistance = 50f)
if (hit != null) {
println("hit ${hit.handle} at ${hit.point}, ${hit.distance} away")
}
Shape cast. Sweeps a convex shape and returns the first hit. ShapeCastHit carries handle,
point, normal and fraction. Pass ignore to skip one body, such as the caster's own.
val sweep = world.shapeCast(SphereShape(0.25f), from = Vec3f(0f, 5f, 0f), to = Vec3f(0f, -5f, 0f))
// sweep?.normal is the surface normal at the first contact; fraction is 0..1 along the sweep.
Overlap. Every body a convex shape overlaps right now: spawn checks, explosions, melee arcs.
val inside = mutableListOf<BodyHandle>()
world.overlapShape(SphereShape(1f), Vec3f(0f, 0f, 0f)) { handle -> inside += handle }
All three take an optional onlyLayer that limits them to one collision layer.
A MeshShape or HeightFieldShape cannot be swept or used for an overlap; passing one throws
PhysicsCapabilityException.
Triggers and contacts
A sensor body is a trigger volume: a pickup, a checkpoint, a damage zone.
world.scene {
entity("pickup") {
transform(y = 1.5f)
with(PhysicsBody(SphereShape(0.5f), MotionType.STATIC, sensor = true))
}
}
PhysicsSystem.contacts holds every contact from its last step, with the entities involved. Read it
from a system that runs after PhysicsSystem:
physics.update(world, 1f / 60f)
for (contact in physics.contacts) {
if (contact.phase == ContactPhase.BEGAN) {
contact.entityA?.let(touched::add)
contact.entityB?.let(touched::add)
}
}
Without the ECS, drain contacts from the world yourself, once per step, after stepping:
val trigger = world.createBody(SphereShape(1f), Vec3f(0f, 2f, 0f), Quat.IDENTITY, MotionType.STATIC, sensor = true)
val ball = world.createBody(SphereShape(0.25f), Vec3f(0f, 5f, 0f), Quat.IDENTITY, MotionType.DYNAMIC)
val entered = mutableListOf<BodyHandle>()
repeat(90) {
world.step(1f / 60f)
world.drainContacts { event ->
if (event.phase == ContactPhase.BEGAN) entered += if (event.a == trigger) event.b else event.a
}
}
Sensors report contacts on their own. A solid body reports only after
setContactReporting(handle, true), for hit sounds or impact damage.
What a sensor cannot see
A sensor only detects active bodies, so a body that fell asleep before entering it is never
reported. A static sensor does not detect static bodies; make a trigger that must notice level
geometry KINEMATIC. A MeshShape or HeightFieldShape cannot be a sensor.
One event per touching part
Contacts are reported per touching sub-shape pair, not per body pair. Two convex shapes touch once; a mesh or heightfield entering a sensor can report many. Deduplicate by pair if that matters.
Collision layers
Every body belongs to one collision layer. The default table has two: CollisionLayers.World
(index 0) for static level geometry and CollisionLayers.Moving (index 1) for everything else, where
moving bodies hit the world and each other. A body without a layer lands in the one that matches its
motion type.
Queries take onlyLayer to see a single layer, for example a camera ray that stops at walls but not
at crates:
val down = Vec3f(0f, -1f, 0f)
val anything = world.raycast(Vec3f(0f, 10f, 0f), down, maxDistance = 50f)
val levelOnly = world.raycast(Vec3f(0f, 10f, 0f), down, maxDistance = 50f, onlyLayer = CollisionLayers.World)
For more layers, build a CollisionLayers table and pass it when you create the world. It says how
many layers there are, which of them move, and which pairs collide:
val debris = CollisionLayer(2)
val layers = CollisionLayers(count = 3, movingLayers = setOf(CollisionLayers.Moving, debris)) { a, b ->
if (a == debris || b == debris) {
a == CollisionLayers.World || b == CollisionLayers.World // debris ignores other moving things
} else {
a == CollisionLayers.Moving || b == CollisionLayers.Moving
}
}
val world = createJoltPhysicsWorld(layers = layers)
val chip = world.createBody(SphereShape(0.1f), Vec3f(0f, 4f, 0f), Quat.IDENTITY, MotionType.DYNAMIC, layer = debris)
The table is fixed for the life of the world, must be symmetric, and holds at most 32 layers. In a
scene document, layer is the index.
Test a custom table before you rely on it
On desktop, a body in a third layer set to collide with World fell through a static World
floor in our tests, while the same body in Moving landed on it. Check that your layers
collide the way the table says before building on them.
Constraints
A constraint ties two bodies together. Anchors are world-space and read once, at creation.
val anchor = world.createBody(BoxShape(Vec3f(0.2f, 0.2f, 0.2f)), Vec3f(0f, 10f, 0f), Quat.IDENTITY, MotionType.STATIC)
val weight = world.createBody(SphereShape(0.3f), Vec3f(0f, 9f, 0f), Quat.IDENTITY, MotionType.DYNAMIC)
val rope = world.createConstraint(
DistanceConstraint(
bodyA = anchor,
bodyB = weight,
pointA = Vec3f(0f, 10f, 0f), // world-space anchors, read once
pointB = Vec3f(0f, 9f, 0f),
maxDistance = 2f,
),
)
| Constraint | Holds |
|---|---|
DistanceConstraint(bodyA, bodyB, pointA, pointB, minDistance, maxDistance) |
A rope or rod between two points. |
HingeConstraint(bodyA, bodyB, point, axis, limits) |
A door or wheel turning about one axis. |
BallSocketConstraint(bodyA, bodyB, point, twistAxis, swingLimit, twistLimit) |
A shoulder or hip: swing in a cone, twist about an axis. |
destroyBody removes the constraints that reference that body, so a constraint handle is dead once
either of its bodies is.
Move bodies from code
| Call | What it does |
|---|---|
setLinearVelocity(handle, velocity) / getLinearVelocity(handle) |
Replace or read the velocity. |
setAngularVelocity(handle, velocity) |
Replace the spin, in radians per second. |
addImpulse(handle, impulse) |
Push with mass taken into account. |
moveKinematic(handle, position, rotation, deltaTime) |
Drive a KINEMATIC body to a pose, pushing what it meets. |
setActive(handle, active) / isActive(handle) |
Put a body to sleep or wake it. A sleeping body still collides. |
setContinuousCollision(handle, true) |
Stop a fast body tunnelling through thin geometry. |
applyBuoyancy(handle, surfaceY, buoyancy, deltaTime) |
One step of push from water whose surface is at surfaceY. Call it every step. |
shiftOrigin(offset) |
Move every body; see Large worlds. |
PhysicsBody.handle is the body's handle once PhysicsSystem has created it.
How it works
PhysicsSystem is an ECS system over entities with both Transform and PhysicsBody. On its first
update it creates each body from the entity's transform. From then on the simulation owns the pose:
each update steps the world once and writes positions and rotations back into the transforms, with
interpolation between fixed steps. Changing PhysicsBody.motionType rebuilds the body from rest.
Destroy the body before the entity
Nothing destroys a body for you. Call PhysicsSystem.destroyBody(world, entity) before
World.destroy(entity); otherwise the body stays in the simulation, solid and unreachable.
Debugging
physicsDebugLines(world) returns world-space wireframes of every body's collider at its simulated
pose, coloured by motion type. Draw them with your other debug lines to see where a collider and its
mesh disagree.
See also
- Jolt backend for creating a
PhysicsWorldand platform notes. - Character controller for a player that walks on physics.
- Terrain for the heightmaps a heightfield is built from.
- Large worlds for streaming colliders and shifting the origin.