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#

PropertyTypeDefaultDescription
speedfloat3.0Walk speed in m/s (3=walk, 5=run, 7=sprint)
jumpHeightfloat2.0Jump height in meters
heightfloat2.0Character capsule height
radiusfloat0.5Character capsule radius
stepHeightfloat0.3Max step/stair height to auto-climb
verticalVelocityfloat0.0Current vertical velocity (gravity)
groundedbooleanfalseIs character on ground (read-only)
movementvec3[0,0,0]Current movement input (read-only)

Quick Start#

Creating a basic first-person character controller:

  1. Create a new entity
  2. Add Transform component
  3. Add CharacterController component
  4. Set height to 2.0m, radius to 0.5m
  5. Add Camera component as child
  6. Add movement script (see below)
  7. Press Play!
// Basic first-person movement script
export 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 AngleBehaviorUse Case
0° - 30°Walk normallyGentle hills, ramps
30° - 45°Slower movementSteep hills
45° - 60°Slide downVery steep slopes
60°+Cannot climbCliffs, 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 HeightUse CaseExample
0.1m - 0.2mCurbs, small bumpsOutdoor environments
0.3m - 0.4mStandard stairsBuildings, dungeons
0.5m - 0.6mLarge stepsPlatforms, rocks
0.7m+ObstaclesRequires 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 character

NPC 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 stepHeight to 0.3m for standard gameplay
  • Always normalize movement vectors before applying speed
  • Apply gravity manually - controllers don't have automatic gravity
  • Check grounded before allowing jump
  • Use smaller radius for 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.

Physics | Web Engine Docs | Web Engine Docs