Skip to content

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.

crate.scene.json
{
  "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.

Kotlin
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(...).

  1. Select an entity in the Hierarchy.
  2. In the Inspector, open Add component and choose Physics Body. Studio adds a 1 m box that is DYNAMIC.
  3. Set Motion to STATIC, KINEMATIC or DYNAMIC.

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.

Kotlin
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:

{ "component": "physics_body", "shape": { "type": "mesh", "mesh": "models/bridge.glb" } }

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:

Kotlin
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
Kotlin
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.

Kotlin
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.

Kotlin
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.

Kotlin
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.

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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)
Kotlin
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.

Kotlin
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