Three.js From Zero · Article s2-04
Character Controller
Character Controller
S2-03 gave you a ragdoll that flops. Today we build the opposite: a capsule that walks on purpose. Press WASD and it moves. Press space and it jumps. Run up a slope and it slides over. Walk off a ledge and it falls. Drop off a cliff and the 0.15s after you leave the ground still counts as "on the ground" for jumping — that's coyote time, the single most important game-feel trick for platformers.
Click the demo to focus it, then use W A S D to walk and Space to jump. Shift runs. Try the ramps and the stairs.
Kinematic vs dynamic character
The first fork in the road. Two very different ways to drive a body:
| Dynamic body + forces | Kinematic body + controller | |
|---|---|---|
| How you drive it | applyImpulse, addForce | You compute next position directly, engine snaps it there |
| Collision response | Solver handles it | You ask the engine "is this move legal?" |
| Feel | Realistic but floaty, hard to stop on a dime | Precise, responsive — what games actually do |
| Slopes / stairs | Catches on edges | Built-in snap-to-ground + step-up |
Real games use the kinematic approach for the player. Dynamic is for
everything else (physics props, enemies flung around). Rapier ships
KinematicCharacterController specifically for this.
The capsule
Why capsules? They're smooth cylinders with rounded ends — they slide over small obstacles instead of catching on corners, they handle slopes without getting stuck, and their inertia tensor is easy for the solver. Every FPS character in every commercial engine is a capsule (sometimes ellipsoid, same idea).
const CHAR_HEIGHT = 1.6;
const CHAR_RADIUS = 0.3;
const CHAR_HALF_H = (CHAR_HEIGHT - 2 * CHAR_RADIUS) / 2;
const body = world.createRigidBody(
RAPIER.RigidBodyDesc.kinematicPositionBased()
.setTranslation(0, CHAR_HEIGHT / 2, 0),
);
world.createCollider(
RAPIER.ColliderDesc.capsule(CHAR_HALF_H, CHAR_RADIUS),
body,
);
The capsule's total height is 2·(halfH + radius). Picking radius 0.3m and
total 1.6m matches a human-scale character — adjust to your world's scale.
The controller
const controller = world.createCharacterController(0.01);
controller.enableAutostep(0.35, 0.2, true); // max step height, max step width, skip dynamic
controller.enableSnapToGround(0.2); // snap down up to 0.2m to stick to slopes
controller.setMaxSlopeClimbAngle(45 * Math.PI / 180);
controller.setMinSlopeSlideAngle(30 * Math.PI / 180);
controller.setApplyImpulsesToDynamicBodies(true);
What each setting does:
0.01— "offset". Small skin gap that keeps the capsule from penetrating into geometry. 0.01m is the standard.enableAutostep— if you walk into a step ≤ 0.35m tall, the controller lifts you up and over automatically. No manual climbing code needed.enableSnapToGround— when walking downhill, the controller pulls you down up to 0.2m to stay touching the slope. Without it you'd bounce off every hill.setMaxSlopeClimbAngle— anything steeper than 45° counts as a wall — you can't walk up it.setMinSlopeSlideAngle— 30° and above, you start sliding down when standing still. Below, you're stable.setApplyImpulsesToDynamicBodies— you can push boxes by walking into them.
The move loop
Per frame, compute a desired translation (where you want to go this step), ask the controller to resolve it against the world, then apply the result:
// 1. Read input → desired horizontal direction
const input = readKeys(); // { x: -1..1, z: -1..1 }
const speed = keys.shift ? RUN_SPEED : WALK_SPEED;
const dir = new THREE.Vector3(input.x, 0, input.z).normalize();
const hVel = dir.multiplyScalar(speed);
// 2. Vertical velocity — gravity + jump
if (controller.computedGrounded() && keys.space) {
vVel = JUMP_SPEED;
} else {
vVel += GRAVITY * dt; // GRAVITY = -22, juicy jump
}
// 3. Combine into a translation for this step
const desired = {
x: hVel.x * dt,
y: vVel * dt,
z: hVel.z * dt,
};
// 4. Ask the controller to resolve the move
controller.computeColliderMovement(collider, desired);
const correctedMove = controller.computedMovement();
// 5. Apply the corrected move to the body
const p = body.translation();
body.setNextKinematicTranslation({
x: p.x + correctedMove.x,
y: p.y + correctedMove.y,
z: p.z + correctedMove.z,
});
The controller figures out: does this move cause a collision? If yes, how much does it
have to shorten the move? Can it step up over a small ledge? Can it snap down onto a slope?
Its output computedMovement() is the actual move vector that will
keep the capsule out of trouble.
Ground detection + gravity
After a successful computeColliderMovement, the controller knows whether
you ended up on the ground:
const grounded = controller.computedGrounded();
When grounded, reset vertical velocity (so gravity doesn't accumulate forever):
if (controller.computedGrounded()) vVel = 0;
Actually — don't set it to zero. Set it to a tiny negative (like -1) so the
controller keeps pressing into the ground. This prevents the "floating hairline above the
floor" glitch you see in badly-written controllers.
Coyote time — the forgive-me window
If you press jump within a few frames of leaving a ledge, it should still work. This is coyote time. Without it, running off edges feels unfair — players press jump a hair late and fall.
let coyoteTimer = 0;
if (grounded) coyoteTimer = COYOTE_TIME;
else coyoteTimer = Math.max(0, coyoteTimer - dt);
if (keys.space && coyoteTimer > 0) {
vVel = JUMP_SPEED;
coyoteTimer = 0; // used it up
}
COYOTE_TIME = 0.12 is the industry-standard number — 7 frames at 60 FPS.
Imperceptible as a forgiveness but hugely impactful on "feel".
Jump buffering — the other forgive-me window
Same trick, opposite direction. If you press jump slightly before landing, the game should still jump on touchdown. Buffer the press for a few frames and honor it the moment you become grounded:
let jumpBuffer = 0;
if (keys.spacePressed) jumpBuffer = JUMP_BUFFER; // 0.12s again
jumpBuffer = Math.max(0, jumpBuffer - dt);
if (jumpBuffer > 0 && (grounded || coyoteTimer > 0)) {
vVel = JUMP_SPEED;
jumpBuffer = 0;
coyoteTimer = 0;
}
Coyote + jump-buffer together account for enormous perceived responsiveness. Every polished platformer from Celeste to Mario has both.
Variable jump height
Hold the space bar for a higher jump. Release early for a shorter hop. The trick: cut vertical velocity the moment the player releases while still moving up:
if (!keys.space && vVel > 0) {
vVel *= 0.5; // cut upward momentum
}
Run at top of the move loop. Gives you Mario-style short/long jumps.
Slope handling — what the controller does for you
With enableSnapToGround(0.2) + setMaxSlopeClimbAngle(45°):
- Walking up a 30° slope — you walk up normally, snap-to-ground keeps you planted
- Walking up a 50° slope — the controller detects it's beyond climb angle, treats it as a wall, you slide
- Walking off a small drop (≤ 0.2m) — snap-to-ground pulls you onto the lower surface smoothly instead of launching you into the air
- Walking off a cliff (> 0.2m) — snap fails, you become airborne, gravity takes over
All of this is "free" from the controller. You just supply horizontal input + jump.
Third-person camera
The demo's camera follows the capsule using a classic lerp-spring pattern:
const desiredCam = body.translation().clone().add(
new THREE.Vector3(0, 3.5, 7).applyAxisAngle(
new THREE.Vector3(0, 1, 0),
cameraYaw,
),
);
camera.position.lerp(desiredCam, 0.1);
camera.lookAt(body.translation().x, body.translation().y + 1.0, body.translation().z);
Lerp factor 0.1 per frame gives a smooth chase without feeling laggy. Add mouse-look to
set cameraYaw and you've got a proper over-the-shoulder camera.
Keyboard input — the cleanest pattern
const keys = new Set();
const justPressed = new Set();
addEventListener('keydown', (e) => {
if (!keys.has(e.code)) justPressed.add(e.code);
keys.add(e.code);
});
addEventListener('keyup', (e) => keys.delete(e.code));
// per frame:
const w = keys.has('KeyW');
const jumpPressed = justPressed.has('Space');
justPressed.clear(); // reset edge-triggers
Two sets: "currently held" (for WASD) and "just pressed this frame" (for jump).
Clearing justPressed at the end of each frame turns it into per-frame
edge-triggered input. Standard game-loop pattern.
Moving platforms (bonus)
If you want to ride a platform, after moving the player, check if they're standing on a kinematic platform and add the platform's delta:
if (grounded) {
const groundBody = getGroundBody(); // via last contact or a shapecast
if (groundBody && groundBody.userData.isMovingPlatform) {
const delta = groundBody.userData.deltaThisFrame;
playerBody.setNextKinematicTranslation({
x: playerPos.x + delta.x,
y: playerPos.y + delta.y,
z: playerPos.z + delta.z,
});
}
}
Common first-time pitfalls
- Capsule sinks into the floor on spawn. Your initial Y is too low — it starts inside the ground. Add a small epsilon:
y = CHAR_HEIGHT / 2 + 0.01. - Bouncing down stairs.
enableSnapToGroundwasn't called. Snap distance needs to be ≥ your steepest step. - Can't walk up a gentle slope.
setMaxSlopeClimbAngledefault is too low for your geometry. Bump to 50° for natural walking. - Jump is too floaty / too snappy. JUMP_SPEED and GRAVITY need to be tuned together. Typical: JUMP_SPEED 8, GRAVITY -22. Jump Mario-feels = high gravity + high jump impulse.
- Character jitters when stopped. You're accumulating a tiny vertical velocity per frame. Reset vVel to -1 when grounded (not to 0).
- WASD does nothing. Canvas doesn't have focus. Add
tabindex="0"and click it to focus. - Jumping mid-air is possible. You're checking
keys.spaceinstead of a justPressed edge-trigger —Spacekeeps firing while held.
Exercises
- Double jump: allow a second jump in mid-air, reset the counter on landing. Most 2D platformers ship with this — a huge feel upgrade for a 5-line change.
- Dash: on shift-press, apply a large horizontal velocity in the facing direction for 0.15s. Add a cooldown so you can't spam it.
- Wall slide: when airborne and touching a wall, reduce gravity. Lets you hang on walls briefly — the starting point for wall-jumping.
What's next
Article S2-05 — WebXR Basics. We put on the headset. Enter-VR button, session lifecycle, reference spaces, stereo render loop. Your Quest 3 or Vision Pro browser becomes a first-class Three.js target.