AppleyAppley Docs
Docs/Physics

Character Controllers

Build responsive character movement with custom controllers.

23 min read Intermediate Updated 2026-07-01 v2.0 ~79 min to complete

Introduction

Character controllers provide player movement that feels responsive. This guide covers kinematic controllers, ground detection, jumping, and slope handling.

Guide 12 of 25

This is part of the Intermediate Guides collection. Category: Physics. Estimated completion: 79 minutes. Difficulty: Intermediate.

Screenshot

Character Controllers
Character Controllers

Step-by-Step Guide

1

Use a kinematic controller

Instead of a rigidbody, use a kinematic character controller for full control without physics jitter.

2

Set up a capsule collider

Characters use capsules — they slide smoothly over terrain and steps. Radius 0.3-0.5m, height 1.8-2.0m.

3

Implement ground detection

Raycast downward to check if grounded. Set isGrounded based on raycast hit distance (< 0.1m).

4

Add movement smoothing

Lerp horizontal velocity towards target speed for smooth acceleration/deceleration.

5

Implement jumping

Apply upward velocity on jump input. Use variable jump height: hold = higher, release = shorter.

6

Handle slopes and stairs

Configure slope limit (45-60 degrees) and step offset (0.3-0.5m). Controller slides on steep slopes, climbs stairs.

FluxScript Example

Here is a complete FluxScript implementation for this guide:

Character Controllers — FluxScript
on start {
  player = scene.find("Player")
  player.setCharacterController({
    radius: 0.4, height: 1.8,
    slopeLimit: 55, stepOffset: 0.4
  })
  velocity = vec3(0, 0, 0)
  moveSpeed = 8
  jumpForce = 12
}

on update(dt) {
  player = scene.find("Player")
  hit = physics.raycast(player.position, vec3(0, -1, 0), 0.15)
  isGrounded = hit != null
  moveDir = vec3(0, 0, 0)
  if input("w") moveDir += forward
  if input("s") moveDir -= forward
  if input("a") moveDir -= right
  if input("d") moveDir += right
  if math.length(moveDir) > 0 moveDir = math.normalize(moveDir)
  targetVel = moveDir * moveSpeed
  velocity.x = math.lerp(velocity.x, targetVel.x, 10 * dt)
  velocity.z = math.lerp(velocity.z, targetVel.z, 10 * dt)
  velocity.y -= 25 * dt
  if input(" ") && isGrounded {
    velocity.y = jumpForce
    audio.play("jump")
  }
  if !input(" ") && velocity.y > 5 velocity.y *= 0.5
  player.move(velocity * dt)
  if math.length(moveDir) > 0.1 {
    targetAngle = math.atan2(moveDir.x, moveDir.z)
    player.setRotation(0, math.lerpAngle(player.rotation.y, targetAngle, 10 * dt), 0)
  }
}

Node Graph Example

Prefer visual programming? Here is the equivalent Node Graph:

Character Controllers — Node Graph
[On Start] --> [Set Character Controller: capsule, slope=55]

[On Update] --> [Raycast down] --> [Set: isGrounded]
   --> [Get Input W/A/S/D] --> [Normalize moveDir]
   --> [Lerp: velocity towards target] --> [Apply Gravity]
   --> [Branch: jump + grounded] --> [Set: velocity.y]
   --> [Player.Move] --> [Lerp Angle: face direction]

Optimization Tips

Keep your implementation fast and efficient with these optimization tips:

  • Use kinematic controllers instead of rigidbodies for characters. Rigidbodies cause jitter on stairs and slopes.
  • Cache the raycast result for ground detection. For 50+ NPCs, cache and update every 3-5 frames.
  • Use variable jump height. Holding the button longer = higher jump. Gives players precise control.
  • Apply acceleration via lerp (factor 8-12). Instant velocity changes feel robotic; lerped feels smooth.

Common Mistakes

Using a rigidbody for the player

Rigidbodies cause jitter on stairs, get stuck on edges, feel floaty. Use kinematic controllers.

No coyote time

Allow jumping 0.1-0.2s after walking off a ledge. Without it, jumps feel unresponsive at edges.