> Cinder 0.1.0 documentation: Physics guide (cinder/physics)
> Canonical page: https://cinder.ash10.dev/docs/guides/physics/ · Source: `docs/guides/physics.md` in the engine repository (engine commit 47c9352, imported 2026-11-09)
> Index of every page for agents: https://cinder.ash10.dev/llms.txt

# Physics guide (`cinder/physics`)

`cinder/physics` is the safe Mojo layer over Jolt. Jolt is reached through joltc inside
`libcinder_native`; the parts of the shim that need C callbacks live in
`native/src/physics_shim.c` (query filters, closest-hit collectors, CharacterVirtual extended
update defaults) and `native/src/physics_events.c` (contact and activation listeners). Mojo
never runs inside a Jolt callback (ADR-0003): listeners write fixed-size events into the shim's
lock-free queue and `PhysicsWorld.poll_events()` drains it on the calling thread.

The package sits beside `render`, `anim` and `audio` in the module map (`docs/ARCHITECTURE.md`):
it imports `cinder/core` (math), `cinder/ecs` (for `PhysicsSync`), `cinder/ffi` and
`cinder/platform` (the native library), and is owned by `cinder/app`. The three modules are:

| Module | Contents |
|---|---|
| `physics/world.mojo` | `LayerConfig`, `MotionType`, `Shape`, `BodyDesc`, `PhysicsEvent(Kind)`, `RayHit`, `ShapeHit`, `PhysicsWorld`, `INVALID_BODY` |
| `physics/character.mojo` | `CharacterDesc`, `GroundState`, `CharacterController` |
| `physics/sync.mojo` | `RigidBody` component, `PhysicsSync`, free `pull_bodies` / `push_kinematics` / `write_transforms` |

The generated reference with every signature is `docs/api/physics.md`. The tests in
`tests/physics/test_physics_world.mojo` exercise everything described here and are the best
source of working snippets; the sample game (`sample/game/`) is the second.

## Units and conventions

Metres, seconds, kilograms, newtons; impulses in N·s. Right-handed, Y up, −Z forward, as
everywhere in the engine. Quaternions are `Quat(x, y, z, w)`; `Quat.from_axis_angle` takes
radians, `CharacterDesc.max_slope_degrees` takes degrees. Body ids are `UInt32`
(`INVALID_BODY = 0xFFFFFFFF`). Capsules and cylinders stand along Y. A dynamic body with
`mass == 0` gets its mass from the shape volume at Jolt's default density, 1000 kg/m³.

## Creating a world

`PhysicsWorld(lib, layers, max_bodies=4096, worker_threads=2, event_capacity=1 << 14)` creates
one Jolt `PhysicsSystem`, one `JobSystemThreadPool` with `worker_threads` workers, one event
queue of `event_capacity` entries, the two listeners and one reusable body filter. `lib` is a
`NativePtr` (`NativeLibrary.ptr()`). `JPH_Init` is idempotent in joltc, so every world calls it
and the process-wide Jolt state is never shut down while a world exists.

```mojo
from cinder.physics import BodyDesc, LayerConfig, MotionType, PhysicsWorld
from cinder.platform import NativeLibrary

comptime LAYER_STATIC = 0
comptime LAYER_MOVING = 1
comptime LAYER_GHOST = 2

def _layers() -> LayerConfig:
    """0 static, 1 moving (collides with everything but ghosts), 2 ghost (static only)."""
    var cfg = LayerConfig(3, 1)
    cfg.collide(LAYER_MOVING, LAYER_STATIC)
    cfg.collide(LAYER_MOVING, LAYER_MOVING)
    cfg.collide(LAYER_GHOST, LAYER_STATIC)
    return cfg^

var lib = NativeLibrary()
var physics = PhysicsWorld(lib.ptr(), _layers())
```

### Layers are bits

`LayerConfig(count, static_mask=1)` declares up to 32 object layers; layer `i` is bit `i` in
every query mask, so "static only" is `UInt32(1) << LAYER_STATIC` and the default mask
`0xFFFFFFFF` is everything. `static_mask` marks the layers that go to the static broad-phase
layer, so static geometry is never tested against itself; everything else shares the moving
broad-phase layer. `collide(a, b)` enables a pair (symmetric); pairs are checked against `count`
when the world is built and `create_body` rejects layers outside `[0, count)`.
`LayerConfig.default()` is two layers, 0 static and 1 moving, with moving colliding with both.

### Ownership and lifetimes

- **The world owns Jolt.** Its destructor destroys the filters, the system (which destroys any
  body still registered), the listeners, the job system and the queue, in that order. Keep the
  `NativeLibrary` alive past the world (`keep(lib)` at the end of a test; the `App` declares the
  library field last so it is destroyed last, see `docs/MOJO_NOTES.md`).
- **Shapes are handles.** `Shape` is `ImplicitlyCopyable`; copy it freely into as many
  `BodyDesc`s as you like and call `destroy_shape` exactly once. Jolt reference-counts the shape,
  so a body keeps its shape alive and you may destroy the handle right after `create_body` (the
  tests' `_floor` helper does this). `scaled(shape, ...)` is a new shape that also needs
  destroying. A destroyed `Shape` copy still converts to `True`; do not reuse it. The world
  remembers the shapes it created: `destroy_shape` on one that is already destroyed (through
  another copy) or that came from another world releases nothing, leaves `live_shapes` alone and
  adds an ERROR record to `diagnostics`. (A stale copy whose address Jolt has since reused for a
  new shape cannot be told apart, so still destroy each shape once.)
- **Bodies are ids.** `create_body` returns a `UInt32`; `destroy_body(id)` removes and destroys
  it. Jolt may reuse the index after destruction, with a new sequence number, so a stale id
  never names the new body. `destroy_body` checks the id with Jolt first: `INVALID_BODY`, an id
  never created or one already destroyed is ignored, `live_bodies` is left alone and an ERROR
  record is added to `diagnostics` (`error_count()` counts them).
- **Leak accounting, reported, no abort.** The world counts shapes, bodies and characters;
  `live_objects()` is their sum and must be zero before the world goes away. The destructor does
  not abort on leaks (ADR-0012 §6), so a failing test still reports its real assertion; it
  prints one ERROR line instead, `leak_report()`, of the form
  `[ERROR] physics: world destroyed with N live objects shapes=S bodies=B characters=C`. Call
  `leak_report()` (None when nothing leaked) or `live_objects()` before teardown to observe it;
  the tests and `App.live_objects()` assert zero explicitly.
- **Pointer stability.** `CharacterController` stores a `Pointer[PhysicsWorld, MutUntrackedOrigin]`
  and calls through it on every update and in its destructor. The world must therefore not move
  while a controller exists and must outlive it. `App` keeps its world in a `Boxed[PhysicsWorld]`
  (`cinder/core/boxed.mojo`) and hands out `app.physics.ptr()`; tests keep the world as a local
  that is never moved and build the pointer with a small `_ptr` helper, consuming the controller
  (`_ = hero^`) before the world is destroyed.

## Shapes and bodies

```mojo
var ball = physics.sphere(0.5)
var cube = physics.box(Vec3(0.5, 0.5, 0.5))          # half extents, convex radius 0.05
var pill = physics.capsule(0.6, 0.3)                  # half cylinder height, radius
var wide = physics.scaled(cube, Vec3(2.0, 1.0, 1.0))

var pts = List[Vec3]()
pts.append(Vec3(0.0, 0.0, 0.0))
pts.append(Vec3(1.0, 0.0, 0.0))
pts.append(Vec3(0.0, 1.0, 0.0))
pts.append(Vec3(0.0, 0.0, 1.0))
var hull = physics.convex_hull(pts)                   # at least 4 points

var verts = List[Vec3]()                              # a triangle plateau at y = 2
verts.append(Vec3(15.0, 2.0, -5.0))
verts.append(Vec3(25.0, 2.0, -5.0))
verts.append(Vec3(25.0, 2.0, 5.0))
verts.append(Vec3(15.0, 2.0, 5.0))
var indices = List[UInt32]()
for i in [0, 2, 1, 0, 3, 2]:
    indices.append(UInt32(i))
var mesh = physics.mesh(verts, indices)               # static or kinematic bodies only
```

`mesh` validates that the index count is a non-zero multiple of 3 and that every index is inside
the vertex list; Jolt mesh shapes have no mass properties and only work on static or kinematic
bodies. The sample's Sponza level builds one from the imported glTF triangles (ADR-0014).

A body is described by `BodyDesc(shape, position, motion, layer, rotation=Quat.identity())`
and the public fields you set afterwards: `friction` (0.5), `restitution` (0), `linear_damping`
and `angular_damping` (0.05), `gravity_factor` (1), `is_sensor` (contacts reported, no forces),
`continuous` (linear-cast motion quality for fast small bodies), `allow_sleeping`, `user_data`
(a `UInt64` returned with activation events), `linear_velocity` (non-static bodies) and `mass`
(dynamic bodies; a non-zero value overrides the mass and scales the shape's inertia to match).
`MotionType.static()`, `.kinematic()` and `.dynamic()` are the three motion types.

```mojo
var floor = physics.create_body(
    BodyDesc(cube, Vec3(0.0, -1.0, 0.0), MotionType.static(), LAYER_STATIC), False
)
var sensor_desc = BodyDesc(cube, Vec3(8.0, 1.5, 0.0), MotionType.static(), LAYER_STATIC)
sensor_desc.is_sensor = True
var sensor = physics.create_body(sensor_desc, False)

var crate_desc = BodyDesc(cube, Vec3(-3.0, 0.3, 0.0), MotionType.dynamic(), LAYER_MOVING)
crate_desc.mass = 10.0
crate_desc.friction = 1.0
var crate = physics.create_body(crate_desc)
physics.optimize_broad_phase()                        # after the static bodies, before step 1
```

The second argument of `create_body` is `activate` (default `True`); static geometry is created
inactive. `optimize_broad_phase()` is worth calling once after placing many static bodies and
before the first step. `body_count()` asks Jolt how many bodies exist.

Reading and driving bodies: `position(id)`, `rotation(id)`, `transform(id)` (a `Mat4` with unit
scale), `set_position_rotation(id, p, q, activate=True)`, `linear_velocity` /
`set_linear_velocity`, `angular_velocity` / `set_angular_velocity`, `add_force`, `add_impulse`,
`add_impulse_at(id, impulse, world_point)`, `is_active`, `activate`, `motion_type` /
`set_motion_type`, `user_data` / `set_user_data`. An impulse on a sleeping body wakes it.

Kinematic bodies are moved with `move_kinematic(id, target_position, target_rotation, dt)`,
which sets the velocity that reaches the target over the next step of `dt`, so riders (crates,
the character controller) inherit the platform's velocity instead of being teleported:

```mojo
var platform_x: Float32 = -10.0
for step in range(240):
    if step < 120:
        platform_x += 1.0 * DT
        physics.move_kinematic(platform, Vec3(platform_x, 0.1, 0.0), Quat.identity(), DT)
    physics.step(DT)
```

## Stepping and events

`step(dt, collision_steps=1)` runs `JPH_PhysicsSystem_Update` on the world's own thread pool and
raises if Jolt reports an error; `steps` counts successful calls. `collision_steps` is Jolt's
sub-step count for fast scenes. Call `poll_events()` once after each step; it returns every queued
`PhysicsEvent` and refreshes `dropped_events`, the number of events the fixed-capacity queue had
to drop (asserted zero in the tests; raise `event_capacity` if it is not).

```mojo
physics.step(DT)
for ev in physics.poll_events():
    if ev.kind == PhysicsEventKind.contact_added() and ev.pair(floor, moving):
        print("hit the floor, normal", ev.normal, "at", ev.point, ev.contact_count, "points")
    if ev.kind == PhysicsEventKind.contact_added() and ev.pair(sensor, through):
        print("sensor entered")
    if ev.kind == PhysicsEventKind.body_deactivated() and ev.body1 == moving:
        print("fell asleep, user data", ev.user_data)
```

`PhysicsEventKind` has five values: `contact_added`, `contact_persisted`, `contact_removed`,
`body_activated`, `body_deactivated`. Contact events carry `body1`, `body2`, the world `normal`
from body 1 to body 2, the first manifold `point` on body 1 and `contact_count`
(`contact_removed` carries only the ids). Activation events carry `body1` and that body's
`user_data`; their `body2` is `INVALID_BODY`. `involves(id)` and `pair(a, b)` test membership
without caring about order. Sensors produce contact events and no forces. Events describe the
step that produced them; nothing is kept across `poll_events` calls.

## Queries

```mojo
var hit = physics.raycast(Vec3(0.0, 5.0, 0.0), Vec3(0.0, -10.0, 0.0))
if hit:
    print(hit.value().body, hit.value().fraction, hit.value().point, hit.value().normal)
var floor_only = physics.raycast(
    Vec3(0.0, 5.0, 0.0), Vec3(0.0, -10.0, 0.0), UInt32(1) << LAYER_STATIC
)
var not_me = physics.raycast(Vec3(0.0, 5.0, 0.0), Vec3(0.0, -10.0, 0.0), 0xFFFFFFFF, box)

var probe = physics.sphere(0.25)
var sweep = physics.shape_cast(
    probe, Mat4.translation(Vec3(0.0, 5.0, 0.0)), Vec3(0.0, -10.0, 0.0), UInt32(1) << LAYER_STATIC
)
if sweep and sweep.value().penetration_depth <= 0.0 and sweep.value().normal.y() > 0.7:
    var contact = Vec3(0.0, 5.0, 0.0) + Vec3(0.0, -10.0, 0.0) * sweep.value().fraction
```

Both queries return the closest hit along `origin + t * direction` with `t` in `[0, 1]`: the
direction vector carries the range. `layer_mask` selects object layers by bit; `ignore_body`
skips one body (the caster's own). `RayHit` gives `body`, `fraction`, `point` and the hit body's
surface `normal` (read under a body lock in the shim). `ShapeHit` adds `penetration_depth`,
which is greater than zero when the swept shape already overlaps at `t = 0`; `point` is on the
hit body and `normal` is the hit body's surface normal. Shape casts use Jolt's default
`ShapeCastSettings`. The sample's level placement is built on exactly these two calls (ray casts
for floor height, capsule sweeps for standing spots, see `sample/game/level.mojo`).

Queries take `mut self` because the world caches one pair of broad-phase and object filters per
distinct mask (created on first use with C procs from `physics_shim.c`) and reuses one body filter
whose ignored id it rewrites before each cast.

## The character controller

`CharacterController` wraps Jolt's `CharacterVirtual`. It is not a body: it has no id, it never
appears in events or query results, and it moves itself by sweeping its capsule against the
layers that `desc.layer` collides with. Its position is at its feet (the capsule is offset through
`shapeOffset`); only the lower hemisphere supports it, so standing on an edge counts as
`not_supported` rather than ground.

```mojo
var hero = CharacterController(
    _ptr(physics),                              # app.physics.ptr() in a game
    CharacterDesc(0.3, 1.8, LAYER_MOVING),      # radius, total height, layer
    Vec3(0.0, 0.0, 0.0),
)
for _ in range(120):
    hero.update(DT, Vec3(2.0, 0.0, 0.0))        # walk +X at 2 m/s
    physics.step(DT)
if hero.ground_state() == GroundState.on_ground():
    hero.update(DT, Vec3.zero(), 5.0)           # jump at 5 m/s
    physics.step(DT)
print(hero.position(), hero.velocity(), hero.ground_normal(), hero.ground_body())
_ = hero^                                       # destroy before the world
```

`CharacterDesc(radius=0.3, height=1.8, layer=1, max_slope_degrees=50, step_height=0.4,
mass=70)` also has public `gravity` (`(0, -9.81, 0)`) and `max_strength` (100 N, the force used to
push dynamic bodies). `height` must exceed `2 * radius + 0.01`. The controller creates and owns
its capsule shape and increments `world.live_characters`; its destructor releases both.

`update(dt, desired_velocity, jump_speed=0)` must run before `PhysicsWorld.step` with the same
`dt`. It refreshes the ground velocity, then applies the engine's velocity rule: standing on the
ground (and not already moving away from it) the vertical velocity becomes the ground's, so a
kinematic platform carries the character, plus `jump_speed` upward when it is positive; otherwise
the current vertical velocity continues under `desc.gravity`. The horizontal part of
`desired_velocity` is added in both cases (its Y is ignored). Jolt's `ExtendedUpdate` then
performs the move, walking up steps as high as `step_height`, sticking to the floor when walking
down (step down 0.5 m, Jolt's default), sliding along slopes steeper than `max_slope_degrees` and
pushing dynamic bodies with `mass` and `max_strength`.

State queries: `position` / `set_position`, `rotation` / `set_rotation`, `velocity`,
`ground_state()` (`on_ground`, `on_steep_ground`, `not_supported`, `in_air`), `is_supported()`,
`ground_normal()`, `ground_velocity()` and `ground_body()` (the body id under the feet, which the
test checks equals the moving platform).

Implementation note: joltc's `JPH_CharacterVirtualSettings_Init` leaks the settings of the
placeholder shape it installs, so `character.mojo` fills `JPH_CharacterVirtualSettings` by hand
in 16-byte-aligned zeroed storage, writing Jolt's documented defaults explicitly. If joltc is
updated, compare those values with Jolt's `CharacterVirtualSettings` constructor. The extended
update settings come from `cn_physics_extended_update_settings_init` in the shim with
`walkStairsStepUp.y` replaced by `step_height`.

## ECS synchronisation and the fixed-step loop

`RigidBody` (component id 7) links an entity to a body and keeps the two most recent simulated
poses: `RigidBody(body, motion, position, rotation, scale=Vec3.one(), interpolate=True)`.
Jolt carries no scale, so the component's `scale` is reapplied whenever it writes a transform.

```mojo
var falling = world.spawn()
var falling_body = physics.create_body(
    BodyDesc(cube, Vec3(0.0, 5.0, 0.0), MotionType.dynamic(), LAYER_MOVING)
)
world.add[RigidBody](
    falling,
    RigidBody(falling_body, MotionType.dynamic(), Vec3(0.0, 5.0, 0.0), Quat.identity(), Vec3(2.0, 2.0, 2.0)),
)
var lift = world.spawn()
var lift_body = physics.create_body(
    BodyDesc(cube, Vec3(5.0, 0.5, 0.0), MotionType.kinematic(), LAYER_MOVING)
)
world.add[RigidBody](lift, RigidBody(lift_body, MotionType.kinematic(), Vec3(5.0, 0.5, 0.0), Quat.identity()))
world.add[LocalTransform](lift, LocalTransform(Mat4.translation(Vec3(5.0, 0.5, 0.0))))

var sync = PhysicsSync()
# Fixed step (App.run_frame runs this `steps` times per frame):
_ = world.advance_tick()
_ = sync.push_kinematics(world, physics, DT)   # moved LocalTransforms -> MoveKinematic
physics.step(DT)
_ = sync.pull_bodies(world, physics)           # dynamic bodies: previous <- current <- Jolt
# Once per rendered frame:
_ = sync.write_transforms(world, alpha)        # LocalTransform at alpha in [0, 1]
```

- `push_kinematics` runs before the step. For every kinematic `RigidBody` whose `LocalTransform`
  changed since `last_pushed_tick` (ECS change ticks, which is why the loop calls
  `world.advance_tick()` first) it calls `move_kinematic` towards the transform's translation
  and rotation over `dt`, records the pose and the tick, and returns the count. Unchanged
  kinematics cost nothing and are not pushed again.
- `pull_bodies` runs after the step. For every dynamic `RigidBody` it shifts `position` and
  `rotation` into `previous_*`, reads the new pose from Jolt and marks the component changed.
- `write_transforms(alpha)` runs once per frame with the accumulator's fraction
  (`App` passes `fixed.alpha()`). For dynamic bodies it writes `Mat4.trs(lerp(prev, cur, alpha),
  slerp(prev, cur, alpha), scale)` into `LocalTransform`, adding the component if the entity
  has none and skipping entities whose matrix is unchanged, so resting bodies do not dirty the
  hierarchy. With `interpolate = False` it writes the latest pose. Static bodies are never
  touched: a `RigidBody` with `MotionType.static()` is purely a record. All three functions skip
  a `RigidBody` whose `body` is `INVALID_BODY` (not linked to a Jolt body yet, or unlinked).

`PhysicsSync` keeps its `QuerySpec`s, chunk list and scratch lists between calls so steady-state
frames allocate nothing (ADR-0014). The free functions `pull_bodies`, `push_kinematics` and
`write_transforms` build a temporary instance and are fine for tests and tools.

The whole loop in `App.run_frame` (`cinder/app/app.mojo`) is: poll events; for each fixed step
(60 Hz accumulator) `advance_tick`, `push_kinematics`, `physics.step`, `pull_bodies`,
`Game.fixed_update`; then `write_transforms(alpha)`, `Game.update`, animation, transform
propagation, audio, render bridge, render. Character controllers are updated by the game inside
`fixed_update`, which runs after the step of the same iteration, so their move lands in the next
step with the same `dt`; events are polled by the game wherever it needs them
(`sample/game/game.mojo` polls them from `update` to trigger impact sounds).

## Threading

- `step` hands the islands to Jolt's own `JPH_JobSystemThreadPool` (`worker_threads`, 2 by
  default). The engine's job system is not involved: joltc's job system cannot be driven by an
  external scheduler without calling back into Mojo (ADR-0012 §1). Jolt's pool is idle outside the
  step so the two pools do not contend.
- Contact and activation callbacks run on Jolt's workers in C only; they push into the
  world's MPMC `CnEventQueue`. `poll_events` pops on the thread that calls it. Overflow is
  counted in `dropped_events`, never silent (`docs/THREADING.md`).
- Every `PhysicsWorld` and `CharacterController` method is called from one thread, the one
  running the fixed step (the main thread in `App`). Nothing in the Mojo layer is synchronised,
  and the body interface is used in its locking form only through Jolt's internals.

## Determinism

Jolt's deterministic mode is on by default, so a world fed the same sequence of calls with the
same inputs produces bit-identical results. `test_deterministic_replay_of_recorded_inputs` builds
two fresh worlds, creates 72 bodies in the same order, replays the same impulses at the same
steps and asserts every final position is exactly equal, then shows a different stream diverges.
For replay in a game this means: same `LayerConfig`, same creation order, same `dt` and
`collision_steps`, same inputs applied at the same step. Floating-point positions read back
between steps may be compared exactly, as the test does.

## Pitfalls

- Values passed to C by pointer must be followed by `keep(value)` after the last C call; every
  wrapper in this package does so (see `docs/MOJO_NOTES.md`, "Untracked pointers do not keep
  their pointee alive"). Follow the same pattern if you add a binding.
- Call `update` on a controller before the step, not after; call `poll_events` after the step.
- `move_kinematic` wants the next target, not a delta; call it every step while the platform
  moves. `set_position_rotation` teleports and does not carry riders.
- `desc.mass` is only applied to dynamic bodies; `desc.linear_velocity` only to non-static ones.
- A convex hull needs at least four points; a mesh needs indices in multiples of three.
- Destroy controllers before the world, bodies before shapes if you want `live_objects()` to
  read zero (the order does not matter to Jolt, only to the counter and to leak reports).
- `LayerConfig` is copied into the world; adding pairs afterwards has no effect.
- `write_transforms` after `pull_bodies` on a body that was just created shows the creation pose
  for both previous and current, so there is no first-frame jump.

## See also

- `docs/adr/0012-physics-animation-audio-input.md`, physics decisions 1 to 7.
- `docs/adr/0003-native-shim-pull-model.md`, why callbacks stay in C and events are pulled.
- `docs/adr/0014-sample-game-and-performance.md`, mesh colliders from glTF, query-based
  placement, the zero-allocation frame.
- `docs/THREADING.md`, `docs/ARCHITECTURE.md` (frame loop and conventions).
- `docs/api/physics.md`, the generated reference.
- `tests/physics/test_physics_world.mojo`: bodies, layers, sensors, kinematics and events;
  queries and mesh/hull/scaled colliders; the character controller; ECS sync with
  interpolation; deterministic replay.
- `docs/guides/anim.md` for driving a skinned character from the controller.
