Overview
CharacterController wraps a single dynamic Jolt capsule body and turns
per-tick input into physics impulses. It does not move the body directly — it
applies floating, friction, movement, turning, and jump impulses, and lets Jolt
integrate them when you step the world.
Creating a controller
Section titled “Creating a controller”import { CharacterController } from "jolt-ts-character-controller";
const controller = new CharacterController({ world, // required: your jolt-ts World position: [0, 1, 0], motionQuality: "linearCast",});With only world supplied, the controller creates its own dynamic capsule via
createCharacterBody. To reuse an existing
body — a custom shape, a pooled body, or one you created yourself — pass it as
body and the controller will drive that instead:
const controller = new CharacterController({ world, body: myCapsuleBody });The controller exposes three public fields:
controller.world— the world you passed in.controller.body— the Jolt body it drives (created or supplied).controller.options— the resolved options (defaults merged with yours). These are live and mutable; changing them between ticks retunes the controller.
Anatomy of a tick
Section titled “Anatomy of a tick”Each simulation tick you do four things, in order:
controller.setForwardDirection(cameraForward, cameraUp); // 1. aimcontroller.setMovement(input); // 2. intentcontroller.step(dt); // 3. simulate the controllerworld.step(dt); // 4. integrate the world- Aim — tell the controller which way “forward” is (see Movement & Input).
- Intent — feed it the pressed buttons / joystick.
step(dt)— the controller reads the body’s current pose and velocity, casts for the ground, and applies this tick’s impulses. It allocates nothing.world.step(dt)— Jolt integrates those impulses (and everything else) forward bydt.
Internally, a single step(dt) resolves run/jump edge triggers, refreshes
gravity and the up-axis, projects your input into a movement direction, casts
for the ground, applies the floating spring, detects slopes / falling / moving
platforms, applies friction (when there’s no move input), applies dynamic
gravity scaling, fires the jump impulse, and finally turns and moves the body.
step vs update
Section titled “step vs update”Two methods run the exact same simulation. They differ only in the return value:
| Method | Returns | Allocates | Use when |
|---|---|---|---|
step(dt) |
void |
Nothing | Hot paths, servers, when you read state via getters. |
update(dt) |
An CharacterControllerSnapshot |
A fresh snapshot object | You want an immutable, plain-data view of the tick. |
// Equivalent to step(dt), plus a snapshot:const snapshot = controller.update(dt);// snapshot.isOnGround, snapshot.moveSpeed, snapshot.position, ...Prefer step(dt) in the render/simulation loop and read live values through the
zero-allocation getters (controller.isOnGround, controller.moveSpeed, …).
Reach for update(dt) when you specifically want a detached copy.
Rendering the result
Section titled “Rendering the result”After world.step(dt), read the body’s pose and copy it onto your mesh:
const p = controller.body.translation();const r = controller.body.rotation();mesh.position.set(p.x, p.y, p.z);mesh.quaternion.set(r.x, r.y, r.z, r.w);The controller never touches your scene graph — this copy is the only link between physics and rendering.
Fixed timestep
Section titled “Fixed timestep”Physics is most stable and most reproducible at a fixed dt. A simple and
robust pattern is an accumulator that steps the controller and world in fixed
slices, draining whatever real time has elapsed:
const FIXED_DT = 1 / 60;const MAX_FRAME = 1 / 6; // never simulate more than this per framelet accumulator = 0;let last = performance.now();
function frame(now: number) { accumulator += Math.min((now - last) / 1000, MAX_FRAME); last = now;
while (accumulator >= FIXED_DT) { controller.setForwardDirection(cameraForward, camera.up); controller.setMovement(input); controller.step(FIXED_DT); world.step(FIXED_DT); accumulator -= FIXED_DT; }
syncMeshFromBody(controller.body, mesh); renderer.render(scene, camera); requestAnimationFrame(frame);}requestAnimationFrame(frame);Enabling and disabling
Section titled “Enabling and disabling”step(dt) is a no-op when options.enable is false or when the body has been
invalidated (body.valid === false). Toggle a controller off without destroying
it:
controller.options.enable = false; // step() now does nothing