Web Engine Docs
Preparing documentation
Use the search bar to quickly find any topic
Preparing documentation
Use the search bar to quickly find any topic
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.
Automatic ground detection with configurable offset. Detect when character is on ground, in air, or on a slope.
Smooth movement on slopes with configurable angle limits. Automatically slides down steep slopes.
Automatically climb stairs and small obstacles. Configurable step height for different environments.
Smooth collision sliding along walls. No getting stuck in corners or on edges.
| 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) |
Creating a basic first-person character controller:
Transform componentCharacterController componentCamera component as child// 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 movementconst forward = api.camera.forward;const right = api.camera.right;// Calculate movement directionconst moveDir = {x: forward.x * move.y + right.x * move.x,y: 0,z: forward.z * move.y + right.z * move.x,};// Normalize and apply speedconst 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 gravitylet verticalVel = api.characterController.verticalVelocity;if (api.characterController.grounded) {verticalVel = -2.0; // Small downward force to stay grounded// Jumpif (jumpDown) {verticalVel = jumpForce;}} else {verticalVel -= gravity * dt;}// Move charactermoveDir.y = verticalVel * dt;api.characterController.move(moveDir);api.characterController.verticalVelocity = verticalVel;};};
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 groundedif (api.characterController.grounded) {api.log("On ground");// Reset vertical velocity when landingapi.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.
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 normalconst hit = await api.raycast(api.position,{ x: 0, y: -1, z: 0 },2.0);if (hit) {// Calculate slope angle from normalconst angle = Math.acos(hit.normal.y) * (180 / Math.PI);if (angle > 45) {api.log("Steep slope - sliding!");// Apply slide force}}}};};
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.
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 inputlet speed = walkSpeed;if (sprint) speed = sprintSpeed;else if (run) speed = runSpeed;// Apply movementconst moveDir = {x: move.x * speed * dt,y: 0,z: move.y * speed * dt,};api.characterController.move(moveDir);};};
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 crouchingif (crouch) {api.characterController.height = crouchHeight;speed = crouchSpeed;} else {api.characterController.height = standHeight;speed = walkSpeed;}// Move as normalapi.characterController.move({x: move.x * speed * dt,y: 0,z: move.y * speed * dt,});};};
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 waterconst 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 waterapi.characterController.verticalVelocity = 0;}};};
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 airreturn (dt) => {const { move } = api.input;// Reduce movement speed in airconst speed = api.characterController.grounded? groundSpeed: airSpeed;api.characterController.move({x: move.x * speed * dt,y: 0,z: move.y * speed * dt,});};};
export default (api) => {let jumpsRemaining = 2;const jumpForce = 8.0;return (dt) => {const { jumpDown } = api.input;// Reset jumps when groundedif (api.characterController.grounded) {jumpsRemaining = 2;}// Jump if we have jumps remainingif (jumpDown && jumpsRemaining > 0) {api.characterController.verticalVelocity = jumpForce;jumpsRemaining--;api.log(`Jumps remaining: ${jumpsRemaining}`);}};};
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 wallsconst 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 airif (!api.characterController.grounded && (leftWall || rightWall)) {wallRunning = true;wallRunTime = 0;}if (wallRunning) {wallRunTime += dt;// Maintain height on wallapi.characterController.verticalVelocity = 0;// Move forward along wallapi.characterController.move({x: 0,y: 0,z: wallRunSpeed * dt,});// End wall run after durationif (wallRunTime > wallRunDuration) {wallRunning = false;}}};};
// 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)
// 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
export default (api) => {const wanderSpeed = 1.5;let targetPos = null;let timer = 0;return (dt) => {timer += dt;// Pick new random target every 5 secondsif (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 targetconst 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,});}};};
stepHeight to 0.3m for standard gameplaygrounded before allowing jumpradius for tighter spaces (0.3-0.4m)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.