Ammo.js Kinematic Character Controller Ground Detection

This article explains how to accurately detect if a kinematic character controller in Ammo.js (the JavaScript port of the Bullet physics engine) is touching the ground. We will explore the built-in onGround() method, discuss its limitations, and demonstrate how to implement a more robust downward raycast solution to ensure reliable jump and gravity states in your 3D application.

Method 1: Using the Built-in onGround() Method

The simplest way to detect ground contact is by using the native method provided by the btKinematicCharacterController class.

const isGrounded = characterController.onGround();

How it Works

The onGround() method returns a boolean value (true or false). It internally checks if the character’s collision shape (usually a capsule) is resting on another collision body beneath it based on the controller’s gravity and step-handling calculations.

Limitations

While convenient, the built-in onGround() method can sometimes be unreliable. It may return false momentarily when the character is walking down slopes, sliding along walls, or moving quickly over uneven terrain.


To achieve highly reliable ground detection, developers often pair the kinematic character controller with a manual downward raycast. This approach checks for colliders directly beneath the character’s feet.

Step-by-Step Implementation

  1. Get the Character’s Current Position: Retrieve the origin point of the character’s ghost object or transform.
  2. Define the Ray Start and End Points:
    • Start: The center of the character (or slightly above the feet).
    • End: A point projecting straight down, slightly past the bottom of the character’s collision capsule.
  3. Perform the Raycast: Use the Ammo.js dynamics world to cast the ray and check for intersections.

Code Example

function checkGroundRaycast(dynamicsWorld, characterController, rayLength = 0.1) {
    const ghostObject = characterController.getGhostObject();
    const transform = ghostObject.getWorldTransform();
    const origin = transform.getOrigin();

    // Assuming a capsule height. Adjust this based on your character's half-height.
    const characterHalfHeight = 1.0; 
    
    // Define start and end vectors
    const rayStart = new Ammo.btVector3(origin.x(), origin.y() - characterHalfHeight + 0.1, origin.z());
    const rayEnd = new Ammo.btVector3(origin.x(), origin.y() - characterHalfHeight - rayLength, origin.z());

    // Create a raycast callback
    const rayCallback = new Ammo.ClosestRayResultCallback(rayStart, rayEnd);
    
    // Perform raycast
    dynamicsWorld.rayTest(rayStart, rayEnd, rayCallback);

    let isGrounded = false;

    if (rayCallback.hasHit()) {
        const hitBody = Ammo.castToVoidPointer(rayCallback.get_m_collisionObject());
        const ghostPointer = Ammo.castToVoidPointer(ghostObject);

        // Ensure the ray did not hit the character's own ghost object
        if (hitBody !== ghostPointer) {
            isGrounded = true;
        }
    }

    // Clean up Ammo memory
    Ammo.destroy(rayStart);
    Ammo.destroy(rayEnd);
    Ammo.destroy(rayCallback);

    return isGrounded;
}

Combining Both Methods for Best Results

For the most stable physics behavior, combine both techniques. You can consider the character grounded if either characterController.onGround() returns true OR your downward raycast registers a hit. This hybrid approach prevents “floating” jitter on slopes while maintaining precise landing detection.