Implementing btKinematicCharacterController in Ammo.js

This article provides a step-by-step guide on how to successfully implement a kinematic character controller using btKinematicCharacterController in Ammo.js, the JavaScript port of the Bullet physics engine. You will learn how to configure the necessary physics world components, set up a pair-caching ghost object, initialize the character controller, and handle user input to achieve smooth, collision-aware character movement in your 3D applications.

Understanding the Kinematic Character Controller

Unlike dynamic rigid bodies that are fully simulated by gravity and collision forces, a kinematic character controller (KCC) is driven directly by your game logic code. It detects collisions and slides along walls but does not react to external physical forces unless you program it to do so. This makes it the ideal choice for player characters where precise, responsive movement control is required.

Step 1: Configure the Physics World for Ghost Objects

Before creating the character controller, your Bullet/Ammo.js physics world must be configured to handle ghost objects. Ghost objects are specialized collision objects that can detect overlaps without causing physical responses.

You must register a ghost pair callback with the physics world’s overlapping pair cache. Without this step, the character controller will not detect collisions.

// Initialize the standard physics world components
const collisionConfiguration = new Ammo.btDefaultCollisionConfiguration();
const dispatcher = new Ammo.btCollisionDispatcher(collisionConfiguration);
const broadphase = new Ammo.btDbvtBroadphase();
const solver = new Ammo.btSequentialImpulseConstraintSolver();

const physicsWorld = new Ammo.btDiscreteDynamicsWorld(
    dispatcher, 
    broadphase, 
    solver, 
    collisionConfiguration
);

// Crucial step: Enable ghost pair callbacks
const ghostPairCallback = new Ammo.btGhostPairCallback();
physicsWorld.getPairCache().setInternalGhostPairCallback(ghostPairCallback);

Step 2: Create the Ghost Object and Collision Shape

The character’s physical presence is represented by a btPairCachingGhostObject. A capsule shape is recommended for characters because its rounded bottom prevents getting stuck on uneven terrain or staircases.

const height = 1.8;
const radius = 0.5;

// Create the capsule shape
const shape = new Ammo.btCapsuleShape(radius, height);

// Create the ghost object
const ghostObject = new Ammo.btPairCachingGhostObject();
const transform = new Ammo.btTransform();
transform.setIdentity();
transform.setOrigin(new Ammo.btVector3(0, 5, 0)); // Starting position

ghostObject.setWorldTransform(transform);
ghostObject.setCollisionShape(shape);
ghostObject.setCollisionFlags(16); // CF_CHARACTER_OBJECT flag

Step 3: Initialize the Kinematic Character Controller

With the ghost object and shape prepared, you can now instantiate the btKinematicCharacterController. This object handles the mathematical calculations for movement, gravity, and step-climbing.

const stepHeight = 0.35; // Maximum height of an obstacle the character can step over
const upAxis = 1; // 1 represents the Y-axis as "up"

const characterController = new Ammo.btKinematicCharacterController(
    ghostObject, 
    shape, 
    stepHeight, 
    upAxis
);

// Configure gravity and jump settings
characterController.setGravity(new Ammo.btVector3(0, -9.8 * 3, 0));
characterController.setJumpSpeed(10);
characterController.setMaxJumpHeight(2.0);

Step 4: Add Objects to the Physics World

To make the character active, you must add both the ghost object and the character controller to your physics world. Use the collision filter flags btBroadphaseProxy::CharacterFilter and btBroadphaseProxy::StaticFilter to control what the character collides with.

const characterFilter = 32; // Default CharacterFilter value
const staticFilter = 1 | 2;  // Collide with static/default objects

physicsWorld.addCollisionObject(
    ghostObject, 
    characterFilter, 
    staticFilter
);

physicsWorld.addAction(characterController);

Step 5: Handling Movement and Input

To move the character, calculate a walk direction vector based on user input and pass it to the controller. The controller will resolve collisions and update the position of the ghost object during the physics simulation step.

const walkDirection = new Ammo.btVector3(0, 0, 0);
const speed = 0.1;

function updatePlayerMovement(keysPressed) {
    let moveX = 0;
    let moveZ = 0;

    if (keysPressed['W']) moveZ -= 1;
    if (keysPressed['S']) moveZ += 1;
    if (keysPressed['A']) moveX -= 1;
    if (keysPressed['D']) moveX += 1;

    // Set walking direction
    walkDirection.setValue(moveX * speed, 0, moveZ * speed);
    characterController.setWalkDirection(walkDirection);

    // Handle jumping
    if (keysPressed['Space'] && characterController.canJump()) {
        characterController.jump();
    }
}

Step 6: Syncing with your Render Engine

To display the movement on screen, you must copy the transform of the ghost object to your visual mesh (such as a Three.js Object3D) during your rendering loop.

const tempTransform = new Ammo.btTransform();

function tick(deltaTime) {
    // Step physics world
    physicsWorld.stepSimulation(deltaTime, 10);

    // Get the updated transform from the physics engine
    const ghostTransform = ghostObject.getWorldTransform();
    const origin = ghostTransform.getOrigin();
    const rotation = ghostTransform.getRotation();

    // Apply coordinates to your visual mesh (e.g., Three.js mesh)
    visualMesh.position.set(origin.x(), origin.y(), origin.z());
    visualMesh.quaternion.set(rotation.x(), rotation.y(), rotation.z(), rotation.w());
}