Skip to content

Quick Start

This page builds a complete, keyboard-controlled character: a Jolt world, a floor, a controller, a capsule mesh, and a render loop that steps physics and draws the result. It assumes you already have a Three.js scene, camera, and renderer set up.

import { World, Shape } from "jolt-ts";
const world = await World.create({ gravity: [0, -9.81, 0] });
world.createBody({
type: "static",
shape: Shape.box({ halfExtents: [25, 0.5, 25] }),
position: [0, -0.5, 0],
layer: "static",
friction: 0.8,
});

Create the capsule body yourself so you can rotation-lock it (a walking character shouldn’t topple) and give it friction: 0 (the controller drives its own ground friction). Then scale the float spring to the body’s mass — the one step you must not skip.

import { Shape } from "jolt-ts";
import { CharacterController } from "jolt-ts-character-controller";
const body = world.createBody({
type: "dynamic",
shape: Shape.capsule({ halfHeight: 0.45, radius: 0.35 }),
position: [0, 1.1, 0],
layer: "moving",
friction: 0, // the controller manages its own ground friction
allowSleeping: false,
allowedDofs: ["translation-x", "translation-y", "translation-z"], // no tipping
motionQuality: "linearCast", // sweep against thin/fast geometry (CCD)
});
// springK/dampingC are NOT mass-normalized — scale them to the real body mass.
const massScale = body.mass() / 0.283; // 0.283 kg = the reference capsule mass
const controller = new CharacterController({
world,
body,
capsuleHalfHeight: 0.45,
capsuleRadius: 0.35,
useCustomForward: true, // treat the forward vector as a world axis
autoBalance: false, // rotation is DOF-locked; nothing to balance
springK: 80 * massScale,
dampingC: 6 * massScale,
fallingGravityScale: 1,
});

The controller exposes the resolved capsule dimensions, so the render mesh matches the physics shape exactly.

import { CapsuleGeometry, Mesh, MeshStandardMaterial } from "three";
const { capsuleRadius, capsuleHalfHeight } = controller.options;
const mesh = new Mesh(
new CapsuleGeometry(capsuleRadius, capsuleHalfHeight * 2),
new MeshStandardMaterial({ color: 0x87cefa }),
);
scene.add(mesh);

The controller never reads the keyboard — you keep a plain input object and hand it to setMovement. Fields you omit keep their previous value, so it’s fine to update the object in place.

import type { MovementInput } from "jolt-ts-character-controller";
const input: MovementInput = {
forward: false, backward: false,
leftward: false, rightward: false,
run: false, jump: false,
};
const keymap: Record<string, keyof MovementInput> = {
KeyW: "forward", KeyS: "backward",
KeyA: "leftward", KeyD: "rightward",
ShiftLeft: "run", Space: "jump",
};
addEventListener("keydown", (e) => {
const key = keymap[e.code];
if (key) input[key] = true;
});
addEventListener("keyup", (e) => {
const key = keymap[e.code];
if (key) input[key] = false;
});

Each frame: point the controller forward, feed it the input, step the controller, step the world, then copy the body’s pose onto the mesh.

const FIXED_DT = 1 / 60;
function frame() {
// Face +Z. With useCustomForward, this is the world-space forward axis.
controller.setForwardDirection({ x: 0, y: 0, z: 1 });
controller.setMovement(input);
controller.step(FIXED_DT); // apply movement/jump/float impulses
world.step(FIXED_DT); // integrate the world
// Sync the render mesh from the body after the world has advanced.
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);
renderer.render(scene, camera);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

That’s a fully playable character: WASD to move, Shift to run, Space to jump.

  • Overview — how a tick actually works and how to structure a fixed-timestep loop.
  • Movement & Input — camera-relative movement, joysticks, run toggling, and locked facing.
  • Configuration — every tuning option and its default.
  • Animation State Machine — turn controller state into locomotion animation.