Character Controller
Build responsive player characters with kinematic character controllers. Features ground detection, slope handling, step climbing, and smooth collision response.
The CharacterController component provides kinematic character movement with automatic ground detection, slope handling, step climbing, and smooth collision response. It's purpose-built for player characters and NPCs, avoiding common pitfalls of using regular rigid bodies for character control.
Why Use Character Controller?
Character controllers solve problems that occur when using dynamic rigid bodies for characters: no unwanted rotation, no getting stuck on edges, automatic step climbing, and responsive movement that feels good to players.
Key Features#
Ground Detection
Automatic ground detection with configurable offset. Detect when character is on ground, in air, or on a slope.
Slope Handling
Smooth movement on slopes with configurable angle limits. Automatically slides down steep slopes.
Step Climbing
Automatically climb stairs and small obstacles. Configurable step height for different environments.
Collision Response
Smooth collision sliding along walls. No getting stuck in corners or on edges.
Component Properties#
| Property | Type | Default | Description |
|---|---|---|---|
| speed | float | 3.0 | Walk speed in m/s (3=walk, 5=run, 7=sprint) |
| jumpHeight | float | 2.0 | Jump height in meters |
| height | float | 2.0 | Character capsule height |
| radius | float | 0.5 | Character capsule radius |
| stepHeight | float | 0.3 | Max step/stair height to auto-climb |
| verticalVelocity | float | 0.0 | Current vertical velocity (gravity) |
| grounded | boolean | false | Is character on ground (read-only) |
| movement | vec3 | [0,0,0] | Current movement input (read-only) |
Quick Start#
Creating a basic first-person character controller:
- Create a new entity
- Add
Transformcomponent - Add
CharacterControllercomponent - Set height to 2.0m, radius to 0.5m
- Add
Cameracomponent as child - Add movement script (see below)
- Press Play!
// Basic first-person movement scriptexport default (api) => { const speed = 5.0; const jumpForce = 8.0; const gravity = 9.81; return (dt) => { const { move, jumpDown } = api.input; // Get camera forward/right for relative movement const forward = api.camera.forward; const right = api.camera.right; // Calculate movement direction const moveDir = { x: forward.x * move.y + right.x * move.x, y: 0, z: forward.z * move.y + right.z * move.x, }; // Normalize and apply speed const length = Math.sqrt( moveDir.x * moveDir.x + moveDir.z * moveDir.z ); if (length > 0) { moveDir.x = (moveDir.x / length) * speed * dt; moveDir.z = (moveDir.z / length) * speed * dt; } // Apply gravity let verticalVel = api.characterController.verticalVelocity; if (api.characterController.grounded) { verticalVel = -2.0; // Small downward force to stay grounded // Jump if (jumpDown) { verticalVel = jumpForce; } } else { verticalVel -= gravity * dt; } // Move character moveDir.y = verticalVel * dt; api.characterController.move(moveDir); api.characterController.verticalVelocity = verticalVel; };};Ground Detection#
The controller automatically detects when the character is on the ground by casting a ray downward. The grounded property is updated every frame.
export default (api) => { return (dt) => { // Check if grounded if (api.characterController.grounded) { api.log("On ground"); // Reset vertical velocity when landing api.characterController.verticalVelocity = 0; } else { api.log("In air"); // Apply different air control // (e.g., reduced movement speed) } };};Coyote Time
For better game feel, consider implementing "coyote time" - allowing jump input for a few frames after leaving the ground. This makes platforming feel more forgiving.
Slope Handling#
Characters automatically handle slopes up to a configurable angle. On steep slopes, the character will slide down. The controller maintains ground contact on slopes.
| Slope Angle | Behavior | Use Case |
|---|---|---|
| 0° - 30° | Walk normally | Gentle hills, ramps |
| 30° - 45° | Slower movement | Steep hills |
| 45° - 60° | Slide down | Very steep slopes |
| 60°+ | Cannot climb | Cliffs, walls |
// Detect slope angle (advanced)export default (api) => { return async (dt) => { if (api.characterController.grounded) { // Raycast down to get ground normal const hit = await api.raycast( api.position, { x: 0, y: -1, z: 0 }, 2.0 ); if (hit) { // Calculate slope angle from normal const angle = Math.acos(hit.normal.y) * (180 / Math.PI); if (angle > 45) { api.log("Steep slope - sliding!"); // Apply slide force } } } };};Step Climbing#
The stepHeight property controls the maximum step height the character can automatically climb. This prevents getting stuck on stairs and small obstacles.
| Step Height | Use Case | Example |
|---|---|---|
| 0.1m - 0.2m | Curbs, small bumps | Outdoor environments |
| 0.3m - 0.4m | Standard stairs | Buildings, dungeons |
| 0.5m - 0.6m | Large steps | Platforms, rocks |
| 0.7m+ | Obstacles | Requires jump |
// Configure step height based on environment{ stepHeight: 0.3, // Standard stairs (30cm)} // Indoor building with accessible design{ stepHeight: 0.15, // ADA compliant (15cm)} // Rocky outdoor terrain{ stepHeight: 0.5, // Larger rocks (50cm)}Step Height Limits
Don't set stepHeight too high or characters will "float" up large obstacles unrealistically. Values between 0.2m and 0.4m work well for most games.
Movement Modes#
Walking and Running#
export default (api) => { const walkSpeed = 3.0; // ~3 m/s (11 km/h) const runSpeed = 5.5; // ~5.5 m/s (20 km/h) const sprintSpeed = 7.0; // ~7 m/s (25 km/h) return (dt) => { const { move, run, sprint } = api.input; // Select speed based on input let speed = walkSpeed; if (sprint) speed = sprintSpeed; else if (run) speed = runSpeed; // Apply movement const moveDir = { x: move.x * speed * dt, y: 0, z: move.y * speed * dt, }; api.characterController.move(moveDir); };};Crouching#
export default (api) => { const standHeight = 2.0; const crouchHeight = 1.2; const crouchSpeed = 1.5; const walkSpeed = 3.0; return (dt) => { const { move, crouch } = api.input; // Adjust height and speed when crouching if (crouch) { api.characterController.height = crouchHeight; speed = crouchSpeed; } else { api.characterController.height = standHeight; speed = walkSpeed; } // Move as normal api.characterController.move({ x: move.x * speed * dt, y: 0, z: move.y * speed * dt, }); };};Swimming / Flying#
export default (api) => { let isSwimming = false; const swimSpeed = 2.0; return async (dt) => { // Check if in water (raycast or trigger volume) const inWater = await api.raycast( api.position, { x: 0, y: -1, z: 0 }, 1.0 ); if (inWater && inWater.material === 'water') { isSwimming = true; } if (isSwimming) { const { move, jumpDown } = api.input; // 3D movement in water const moveDir = { x: move.x * swimSpeed * dt, y: jumpDown ? swimSpeed * dt : -swimSpeed * dt * 0.5, z: move.y * swimSpeed * dt, }; api.characterController.move(moveDir); // No gravity in water api.characterController.verticalVelocity = 0; } };};Advanced Techniques#
Air Control#
Allow limited movement control while in the air for better platforming feel.
export default (api) => { const groundSpeed = 5.0; const airSpeed = 2.5; // 50% control in air return (dt) => { const { move } = api.input; // Reduce movement speed in air const speed = api.characterController.grounded ? groundSpeed : airSpeed; api.characterController.move({ x: move.x * speed * dt, y: 0, z: move.y * speed * dt, }); };};Double Jump#
export default (api) => { let jumpsRemaining = 2; const jumpForce = 8.0; return (dt) => { const { jumpDown } = api.input; // Reset jumps when grounded if (api.characterController.grounded) { jumpsRemaining = 2; } // Jump if we have jumps remaining if (jumpDown && jumpsRemaining > 0) { api.characterController.verticalVelocity = jumpForce; jumpsRemaining--; api.log(`Jumps remaining: ${jumpsRemaining}`); } };};Wall Running#
export default (api) => { let wallRunning = false; const wallRunSpeed = 4.0; const wallRunDuration = 2.0; let wallRunTime = 0; return async (dt) => { // Raycast to sides to detect walls const leftWall = await api.raycast( api.position, { x: -1, y: 0, z: 0 }, 1.0 ); const rightWall = await api.raycast( api.position, { x: 1, y: 0, z: 0 }, 1.0 ); // Start wall run if hitting wall while in air if (!api.characterController.grounded && (leftWall || rightWall)) { wallRunning = true; wallRunTime = 0; } if (wallRunning) { wallRunTime += dt; // Maintain height on wall api.characterController.verticalVelocity = 0; // Move forward along wall api.characterController.move({ x: 0, y: 0, z: wallRunSpeed * dt, }); // End wall run after duration if (wallRunTime > wallRunDuration) { wallRunning = false; } } };};Common Patterns#
First-Person Character#
// CharacterController component{ speed: 5.0, jumpHeight: 2.0, height: 1.8, radius: 0.4, stepHeight: 0.3,} // Add Camera as child entity// Position camera at eye level (1.6m up)Third-Person Character#
// CharacterController component{ speed: 4.0, jumpHeight: 2.5, height: 2.0, radius: 0.5, stepHeight: 0.35,} // Add Camera as separate entity// Use orbit controls to follow characterNPC Wandering#
export default (api) => { const wanderSpeed = 1.5; let targetPos = null; let timer = 0; return (dt) => { timer += dt; // Pick new random target every 5 seconds if (timer > 5.0 || !targetPos) { targetPos = { x: api.position.x + (Math.random() - 0.5) * 10, y: api.position.y, z: api.position.z + (Math.random() - 0.5) * 10, }; timer = 0; } // Move toward target const dx = targetPos.x - api.position.x; const dz = targetPos.z - api.position.z; const dist = Math.sqrt(dx * dx + dz * dz); if (dist > 0.5) { api.characterController.move({ x: (dx / dist) * wanderSpeed * dt, y: 0, z: (dz / dist) * wanderSpeed * dt, }); } };};Best Practices#
- Use CharacterController instead of dynamic RigidBody for player characters
- Set
stepHeightto 0.3m for standard gameplay - Always normalize movement vectors before applying speed
- Apply gravity manually - controllers don't have automatic gravity
- Check
groundedbefore allowing jump - Use smaller
radiusfor tighter spaces (0.3-0.4m) - Add slight downward force when grounded to maintain ground contact
- Consider adding coyote time and jump buffering for better feel
Physics Material
Character controllers don't use friction or restitution. All movement is scripted. This gives you full control over how the character moves and prevents unwanted physics interactions.