Skip to content

Character controller

A player that walks, climbs steps, slides along walls and jumps, moved through the physics world instead of through it.

component: character_controller component: movement_control Jolt Desktop · Android · iOS · Web

The character lives in awake:scene:character. It needs two components on one entity: movement_control says where the player wants to go, and character_controller moves the body there without passing through physics colliders.

Add a character

A player standing on a floor. Both forms below build the same components; the floor is an ordinary static physics_body.

player.scene.json
{
  "version": 1,
  "nodes": [
    {
      "name": "floor",
      "transform": { "position": { "x": 0.0, "y": -0.1, "z": 0.0 } },
      "components": [
        {
          "component": "physics_body",
          "shape": { "type": "box", "halfExtents": { "x": 10.0, "y": 0.1, "z": 10.0 } }
        }
      ]
    },
    {
      "name": "player",
      "transform": { "position": { "x": 0.0, "y": 2.0, "z": 0.0 } },
      "components": [
        { "component": "movement_control", "speed": 4.0 },
        {
          "component": "character_controller",
          "radius": 0.4,
          "halfHeight": 0.5,
          "stepHeight": 0.3,
          "jumpSpeed": 5.0
        }
      ]
    }
  ]
}

Register the bindings before you load it: SceneComponentRegistry().registerControls().registerPhysics().registerCharacter(). The project runtime in awake:project:runtime registers all three for you.

Kotlin
world.scene {
    entity("player") {
        transform(y = 2f)
        with(MovementControl().apply { speed = 4f })
        with(
            CharacterController(
                config = CharacterConfig(
                    shape = CapsuleShape(halfHeight = 0.5f, radius = 0.4f),
                    stepHeight = 0.3f,
                    stepDownDistance = 0.3f,
                ),
                jumpSpeed = 5f,
                gravity = -9.81f,
            ),
        )
    }
}

A scene document sets stepDownDistance to stepHeight; set both in Kotlin to match it.

AwakeKt Studio has no Add component entry or inspector for character_controller yet. Write it in the scene document.

Run it

CharacterControllerSystem moves every character through the same PhysicsWorld that PhysicsSystem steps. Run physics first, so the character collides with this step's bodies:

Kotlin
val physicsWorld = createJoltPhysicsWorld()
val physics = PhysicsSystem(physicsWorld)
val characters = CharacterControllerSystem(physicsWorld)
fun step() {
    physics.update(world, 1f / 60f) // physics first, so the character sees this step's bodies
    characters.update(world, 1f / 60f)
}

In an app, register both as fixed-step systems in that order: fixedSystem("physics") { PhysicsSystem(physicsWorld) }, then fixedSystem("character") { CharacterControllerSystem(physicsWorld) }. playerInputSystem() fills MovementControl from the keyboard; you can also write it yourself:

Kotlin
val intent = requireNotNull(world.get<MovementControl>(player))
intent.moveZ = 1f // walk; the direction is relative to the active camera
intent.jump = true // jumps once, only while grounded and only when jumpSpeed > 0

Properties

character_controller:

Property Type Default What it does
radius number 0.5 Capsule radius.
halfHeight number 0.5 Half the capsule's cylinder. The capsule is 2 * (halfHeight + radius) tall.
stepHeight number 0.3 Tallest step it climbs, and how far it follows the ground down.
slopeLimit number, radians 0.785 (45°) Steepest slope it can walk up.
jumpSpeed number 0 Upward speed of a jump, in units per second. 0 means it cannot jump.
gravity number -9.81 Downward acceleration while airborne.

movement_control:

Property Type Default What it does
speed number or none none Walking speed in units per second. None uses the system's speed, 5 for CharacterControllerSystem.
driver Player or Agent Player What sets the intent: the keys and touch controls, or code.

At run time, MovementControl also carries moveX, moveZ and jump, and CharacterController.isGrounded says whether the character stood on walkable ground after its last move.

Characters the player doesn't drive

A monster, a pet or another player over the network is a character too. Give its movement_control the Agent driver, and the player's keys and touch controls leave it alone:

{ "component": "movement_control", "speed": 3.0, "driver": "Agent" }

Its code writes moveX and moveZ as a world-space direction (+X and +Z of the world, not of the camera), and the controller moves it with the same gravity, walls, steps and slopes as the player. Set them back to 0 to stop it.

How it works

On its first update the system builds a KinematicCharacterController at the entity's position. Each update it turns moveX and moveZ into a direction, relative to the active camera for a player and as given for an agent, adds gravity and any jump, and sweeps the capsule through the world with shape casts. A wall stops it and the rest of the move slides along the wall; a step or slope within its limits does not stop it. Standing on a moving body carries the character with it. The result is written back to the Transform.

CharacterConfig has settings a scene document does not carry: skinWidth, maxSlideIterations, groundProbeDistance, stepDownDistance, crouchHalfHeight and innerBody. Set them in Kotlin. For crouching, teleporting and contact callbacks, use KinematicCharacterController directly: it has move, crouch, standUp, teleport and onContact.

Triggers do not see the player by default

The controller moves by sweeping, so it has no body for a sensor to detect. Build its CharacterConfig with innerBody = true to give it a kinematic body that follows it.

Do not also run the plain movement system

MatrixRelativeMovementSystem moves entities with a MovementControl straight through walls. Leave it out of any scene whose players have a character_controller.

Keep the step height under the radius

A stepHeight taller than the capsule's radius lets the character climb surfaces it should treat as walls.

Pushing crates is up to you

The controller refuses to move into a dynamic body rather than shoving it. Apply an impulse yourself from KinematicCharacterController.onContact.

See also

  • Physics for colliders and the physics step.
  • Jolt backend for creating the PhysicsWorld.
  • Navigation for characters driven by pathfinding instead of input.