Skip to content

Character Controller

The character controller is a kinematic capsule that walks, climbs steps, and slides along walls - the physics behind api.moveAndSlide for players and NPCs.

Why a character controller, not a RigidBody

A player usually shouldn't be a dynamic rigid body. Dynamic bodies tumble, bounce, and get pushed around by every contact - great for a crate, terrible for a first-person player who expects to move exactly where the input says and stop cleanly at walls. Awaken solves this the way most engines do: with a kinematic character controller - a capsule moved by code that collides with the world but ignores forces.

You don't add a "character controller" component. Instead, when a script calls api.moveAndSlide for the first time, the runner promotes that entity's collider to a kinematic capsule character, replacing whatever fixed or dynamic body it had at Play start (a character is neither).

moveAndSlide / moveCharacter

From a script you drive the character with:

ts
// Move by a world-space delta this frame, sliding along collisions.
const { grounded } = api.moveAndSlide(dx, dy, dz, cfg?);
  • dx, dy, dz is the desired movement this frame, in world units. You compute it yourself - apply your own gravity to dy, scale by dt, etc.
  • The controller applies the collision-corrected movement straight to the Transform for you. The only value it returns is { grounded } - whether the mover ended standing on a surface. It does not hand the corrected movement back; read getPosition() afterwards if you need where you ended up.
  • cfg is an optional CharConfig.

Under the hood api.moveAndSlide calls PhysicsRunner.moveCharacter, which uses Rapier's KinematicCharacterController to compute how far the capsule can actually travel, then writes the result straight to the Transform. If no physics is running this session (never Play-started, or the WASM isn't ready), moveAndSlide is a safe no-op that reports grounded: false - so scripts stay safe to call unconditionally.

The capsule the controller drives is the entity's primary shape - the first enabled shape in its Collider. Give your player a capsule collider sized to fit.

CharConfig - tuning the feel

CharConfig lets a script (and, through script parameters, a maker in the Inspector) tune how the character negotiates the world. All fields are optional; omitted fields fall back to the Rapier backend defaults:

FieldMeaningDefault
stepHeightMax height (m) of an obstacle the capsule auto-steps over - stairs, curbs.0.3
slopeLimitSteepest walkable slope, in degrees.52
skinWidthGap (m) kept between the capsule and geometry; larger reduces wall clipping.0.08

The defaults are chosen for typical play: a slopeLimit a touch over 45° so ramps and stairs aren't rejected as walls, and a skinWidth large enough that the capsule doesn't sit flush against walls (which would clip the near-plane camera).

Autostep, slopes, and snap-to-ground

The controller applies several behaviours automatically:

  • Autostep - an obstacle up to stepHeight tall is climbed onto rather than blocking the mover. A narrow tread (down to 0.05 m wide) still counts as a valid step, so tight staircases work.
  • Slope handling - slopes up to slopeLimit are walkable; steeper faces are treated as walls. Below a ~30° minimum-slide angle the character won't slide.
  • Snap-to-ground - the controller keeps the capsule attached to the floor across small drops (within ~0.2 m), so walking down stairs or over a lip doesn't launch the character into the air.
  • Pushing dynamics - the character applies impulses to dynamic bodies it walks into, so it can nudge crates.
  • Triggers don't block - sensor (trigger) colliders are excluded from the movement solve, so a trigger volume reports an overlap without stopping the player.

The controller is a single shared instance per world. Its skinWidth is fixed at construction (changing it recreates the controller), while stepHeight and slopeLimit are re-applied whenever they change - so you can tune them live.

The grounded result

Use the returned grounded flag to gate jumping and to switch between ground and air movement. The simplest players integrate their own gravity and feed input straight in - no animation involved:

ts
class Player implements ScriptBehavior {
  speed = 6;      // move speed (m/s)
  jump = 6;       // jump strength
  private vy = 0; // vertical velocity we integrate ourselves

  onUpdate(api: ScriptApi, dt: number) {
    const i = api.input;
    let dx = 0, dz = 0;
    if (i.isDown("w")) dz -= 1;
    if (i.isDown("s")) dz += 1;
    if (i.isDown("a")) dx -= 1;
    if (i.isDown("d")) dx += 1;

    this.vy += -9.81 * dt; // gravity on our own vertical axis

    const { grounded } = api.moveAndSlide(
      dx * this.speed * dt,
      this.vy * dt,
      dz * this.speed * dt,
      { stepHeight: 0.4, slopeLimit: 50 },
    );

    if (grounded) {
      this.vy = 0;                            // reset fall speed on landing
      if (i.isDown(" ")) this.vy = this.jump; // jump when grounded (spacebar)
    }
  }
}

See the FPS Controller tutorial for a complete example, and the Input page for reading movement.

Root-motion-driven movement (animated characters)

The example above moves the capsule at a fixed speed, independent of what the mesh is doing. That's fine for a first-person body you never see. For a visible animated character it causes foot-slide: the feet plant on the ground in the walk clip, but the capsule glides at a speed the animation never agreed to.

The fix is to let the animation drive the ground movement. The animation system exposes how far the current clip moved the character this frame - its per-frame root motion - and you feed that into moveAndSlide instead of input × speed:

ts
// Character-local root-motion delta for this frame: dx = right, dz = forward.
// {0,0} for an in-place clip (a jump/fall that doesn't travel).
const rm = api.rootMotion();

Because rootMotion() is in the character's local space, rotate it by the body's facing before handing it to moveAndSlide. The pattern splits horizontal and vertical ownership:

  • Grounded - the animation owns horizontal (api.rootMotion(), rotated by facing), so walk/run travel at the clip's authored pace and the feet never slide.
  • Airborne - in-place jump/fall clips carry no root motion, so the script owns horizontal (input × speed) to keep forward momentum through the arc.
  • Vertical - a physics jump owns the up/down. Call api.setRootMotionVertical(false) while airborne so the clip's own baked pelvis rise (and foot IK) is dropped and doesn't stack on your jump - otherwise a jump clip's arc plus the physics jump sends the character flying too high. Turn it back true on the ground so crouch/land keep their pelvis motion and the feet re-plant.
ts
class Character implements ScriptBehavior {
  walkSpeed = 2.2;
  runSpeed = 5.5;
  gravity = 20;
  jumpHeight = 1.1;
  private bodyYaw = 0; // facing (radians), turned to face the move direction elsewhere
  private vy = 0;

  onUpdate(api: ScriptApi, dt: number) {
    // ... read WASD into a camera-relative world direction (wx, wz), turn bodyYaw
    // to face it, and set the Transform's Y rotation from bodyYaw ...

    const running = api.input.isDown("shift");
    const speed = running ? this.runSpeed : this.walkSpeed;

    this.vy -= this.gravity * dt;

    // First pass tells us if we're airborne; we set it from LAST frame's grounded.
    const airborne = !this.grounded;

    // HORIZONTAL: grounded → the animation drives it (no foot-slide); airborne →
    // input × speed so the jump carries forward momentum.
    let mx, mz;
    if (airborne) {
      mx = wx * speed * dt;
      mz = wz * speed * dt;
    } else {
      const rm = api.rootMotion();
      const c = Math.cos(this.bodyYaw), s = Math.sin(this.bodyYaw);
      mx = rm.dx * c + rm.dz * s;   // rotate the local root delta into world space
      mz = rm.dz * c - rm.dx * s;
    }

    const { grounded } = api.moveAndSlide(mx, this.vy * dt, mz,
      { stepHeight: 0.3, slopeLimit: 52, skinWidth: 0.08 });
    this.grounded = grounded;

    let jumped = false;
    if (grounded && this.vy < 0) this.vy = 0;
    if (grounded && api.input.isDown(" ")) {
      this.vy = Math.sqrt(2 * this.gravity * this.jumpHeight);
      jumped = true;
    }

    // VERTICAL ownership: while airborne (or on the jump frame) the physics jump owns
    // up/down - tell the animation to drop its own vertical so they don't double up.
    api.setRootMotionVertical(!(!grounded || jumped));

    // Drive the animation graph by intent (the graph owns which clip plays).
    api.setAnimParam("speed", (wx || wz) ? speed : 0);
    api.setAnimParam("grounded", grounded && !jumped);
    if (jumped) api.setAnimTrigger("jump");
  }

  private grounded = true;
}

Use the starter, don't hand-write it

This is exactly what the Third Person Controller starter does - a complete, tuned version with camera-relative WASD, turn-to-face, Shift-to-run, a spring-arm follow camera, fall/land states, and fatal-fall ragdoll + get-up. It ships as Third Person Controller.awakenscript alongside the Third Person Locomotion anim graph and a Third Person Player prefab. Drop the prefab in, assign a root camera, and you have a playable character. Read the starter to see the full pattern rather than reproducing it by hand.

The animation half - the state graph that turns speed / grounded / jump params into idle/walk/run/jump clips, and how root motion is baked into clips at import - lives on the Animation page.

📸 Screenshot - save as img/physics-character-controller.png

A player capsule mid-stair-climb in Play, with the F4 collider overlay on so the capsule and the step it's climbing are visible.

Limitations

  • The controller is a single shared instance per world: stepHeight and slopeLimit re-apply live, but skinWidth is fixed when the controller is created and changing it rebuilds the controller. In practice give every character the same skinWidth.
  • moveAndSlide never returns the corrected movement - only { grounded }. If you need where you actually ended up (e.g. after sliding along a wall), read getPosition() afterwards.
  • There is no built-in speed-matched locomotion or blend space: root motion travels at whatever the one bound clip authored, so a single walk clip won't smoothly cover a range of speeds. Crossfades between idle/walk/run are by graph transition, not a continuous blend on velocity.

See also

Awaken — browser-native WebGPU game engine.