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.
{
"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.
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:
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:
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:
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.