Skip to content
DasPeTe Tutorials

A First-Person Controller in Babylon.js · Part 3 of 11 in the series

Part 3: Walking With a Character Controller

Give the camera a body: a capsule on Babylon.js's Havok character controller that walks where you look, speeds up and slows down smoothly, falls and lands.

Quest objectives

  • Know what a character controller does, and why the player is not a rigid body
  • Create Babylon.js's PhysicsCharacterController with a capsule
  • Turn keys into a velocity relative to the view
  • Speed up and slow down at a fixed rate, at any frame rate
  • Fall with gravity and land again
  • Show what the player does in a small HUD

The camera from part 2 turns, but it stands still. This part gives it a body that walks, bumps into walls, falls off edges and lands.

A character controller

The obvious idea is a capsule that is a physics body like any other: push it with forces and let the engine do the rest. It does not feel good. A rigid body tips over, slides on slopes, bounces when it lands and keeps sliding after you let go of the keys, and taming all that takes more work than doing it differently.

A character controller turns it around. Your code says how fast the player should move, and the controller moves the capsule as far as it can in that direction: it slides along the walls it hits and stops on the floor, but nothing pushes it around. It also tells you what is under the feet. Babylon.js has one for Havok, the PhysicsCharacterController. Every frame takes three steps:

  1. checkSupport() looks for ground under the capsule.

  2. Your code works out the velocity it wants, from the keys and the ground, and sets it.

  3. integrate() moves the capsule by that velocity, with collisions, and leaves the velocity that was actually possible.

Keys

The input class learns about keys. It names actions rather than keys, so the rest of the code asks for forward, not for W. Replace src/input.ts:

src/input.ts
/**
 * Which keys do what. `event.code` names the physical key, so WASD stays where it is on
 * any keyboard layout.
 */
const BINDINGS = {
  forward: ['KeyW', 'ArrowUp'],
  back: ['KeyS', 'ArrowDown'],
  left: ['KeyA', 'ArrowLeft'],
  right: ['KeyD', 'ArrowRight'],
};

export type Action = keyof typeof BINDINGS;

const BOUND = new Set(Object.values(BINDINGS).flat());

export class Input {
  /** True while the mouse is captured. Keys and mouse only count then. */
  locked = false;
  onLockChange: (locked: boolean) => void = () => {};

  private readonly canvas: HTMLCanvasElement;
  private readonly held = new Set<string>();
  private mouseX = 0;
  private mouseY = 0;

  constructor(canvas: HTMLCanvasElement) {
    this.canvas = canvas;

    window.addEventListener('keydown', (event) => {
      if (!this.locked || !BOUND.has(event.code)) return;
      event.preventDefault();
      this.held.add(event.code);
    });
    window.addEventListener('keyup', (event) => this.held.delete(event.code));
    // A key let go while another window has the focus never sends its keyup.
    window.addEventListener('blur', () => this.held.clear());

    document.addEventListener('pointerlockchange', () => {
      this.locked = document.pointerLockElement === canvas;
      if (!this.locked) this.held.clear();
      this.onLockChange(this.locked);
    });
    document.addEventListener('mousemove', (event) => {
      if (!this.locked) return;
      this.mouseX += event.movementX;
      this.mouseY += event.movementY;
    });
  }

  /** Captures the mouse. Browsers only allow it in answer to a click or a key press. */
  async lock(): Promise<void> {
    try {
      // Raw mouse movement, without the acceleration of the operating system.
      await this.canvas.requestPointerLock({ unadjustedMovement: true });
    } catch {
      // Not every platform has raw movement (Linux does not), so ask again without it.
      await this.canvas.requestPointerLock().catch(() => {});
    }
  }

  /** Whether one of the action's keys is held down. */
  down(action: Action): boolean {
    return BINDINGS[action].some((code) => this.held.has(code));
  }

  /** -1, 0 or 1, from a pair of opposite actions. */
  axis(negative: Action, positive: Action): number {
    return Number(this.down(positive)) - Number(this.down(negative));
  }

  /** The mouse movement since the last call, in pixels. */
  takeMouse(): { x: number; y: number } {
    const movement = { x: this.mouseX, y: this.mouseY };
    this.mouseX = 0;
    this.mouseY = 0;
    return movement;
  }
}
  • event.code names the physical key, whatever the keyboard layout. On a French keyboard the keys in the place of WASD are ZQSD, and KeyW is still the one top left.

  • Only the game's own keys are kept from the browser with preventDefault(), and only while the mouse is captured. The arrow keys would scroll the page otherwise.

  • A key let go while another window has the focus never sends its keyup. Without the blur handler, the player would walk on after an Alt+Tab.

  • axis() turns two opposite actions into -1, 0 or 1.

The look learns which way the player faces. Add these two methods to the Look class in src/look.ts, and import Vector3 from @babylonjs/core/pure at the top:

src/look.ts
/** The way the player faces, flat on the ground. */
  forward(result = new Vector3()): Vector3 {
    return result.set(Math.sin(this.yaw), 0, Math.cos(this.yaw));
  }

  /** Ninety degrees to the right of forward. */
  right(result = new Vector3()): Vector3 {
    return result.set(Math.cos(this.yaw), 0, -Math.sin(this.yaw));
  }

Both are flat on the ground: looking up while walking forward does not make the player fly.

The numbers

Add the player's numbers to src/settings.ts, above the camera's:

src/settings.ts
/** Every number that decides how the player feels. Meters, seconds and radians. */
export const PLAYER = {
  radius: 0.35,
  standHeight: 1.8,
  /** Eye height above the feet. */
  standEye: 1.65,

  walkSpeed: 4,
  /** How fast the velocity may change on the ground, in m/s². */
  groundAcceleration: 40,

  gravity: 20,
};

The capsule is 1.8 m tall and 70 cm wide, and the eyes sit 15 cm under the top of it. It walks at 4 m/s, a brisk walk in a game. Gravity is 20 m/s², about twice the real value: with real gravity a game feels floaty, so most games use more. Babylon.js's own character controller example uses 18.

The player

Create src/player.ts:

src/player.ts
import {
  CharacterSupportedState,
  PhysicsCharacterController,
  Vector3,
  type CharacterSurfaceInfo,
  type Scene,
} from '@babylonjs/core/pure';
import type { Input } from './input.ts';
import type { Level } from './level.ts';
import type { Look } from './look.ts';
import { PLAYER } from './settings.ts';

export type Mode = 'walk' | 'air';

const DOWN = Vector3.Down();
const GRAVITY = new Vector3(0, -PLAYER.gravity, 0);

export class Player {
  mode: Mode = 'air';

  private readonly controller: PhysicsCharacterController;
  private readonly level: Level;
  private readonly input: Input;
  private readonly look: Look;
  private readonly velocity = new Vector3();

  constructor(scene: Scene, level: Level, input: Input, look: Look) {
    this.level = level;
    this.input = input;
    this.look = look;
    this.controller = new PhysicsCharacterController(
      level.spawn.add(new Vector3(0, PLAYER.standHeight / 2, 0)),
      { capsuleHeight: PLAYER.standHeight, capsuleRadius: PLAYER.radius },
      scene,
    );
  }

  /** Back to the start, for when the player falls off the world. */
  respawn(): void {
    this.controller.setPosition(this.level.spawn.add(new Vector3(0, this.controller.footOffset, 0)));
    this.controller.setVelocity(Vector3.Zero());
    this.mode = 'air';
  }

  /** The point between the eyes, in world space. */
  eyePosition(result = new Vector3()): Vector3 {
    return result.copyFrom(this.controller.getPosition()).addInPlaceFromFloats(0, PLAYER.standEye - this.controller.footOffset, 0);
  }

  /** How fast the player moves across the ground, in m/s. */
  horizontalSpeed(): number {
    const velocity = this.controller.getVelocity();
    return Math.hypot(velocity.x, velocity.z);
  }

  /** Where the feet are. */
  feet(): Vector3 {
    return this.controller.getPosition().subtract(new Vector3(0, this.controller.footOffset, 0));
  }

  update(dt: number): void {
    if (this.controller.getPosition().y < -20) this.respawn();
    const support = this.controller.checkSupport(dt, DOWN);
    this.velocity.copyFrom(this.controller.getVelocity());

    switch (this.mode) {
      case 'walk':
        this.walk(dt, support);
        break;
      case 'air':
        this.air(dt, support);
        break;
    }

    this.controller.setVelocity(this.velocity);
    this.controller.integrate(dt, support, GRAVITY);
  }

  private walk(dt: number, support: CharacterSurfaceInfo): void {
    if (support.supportedState !== CharacterSupportedState.SUPPORTED) {
      this.mode = 'air';
      this.air(dt, support);
      return;
    }
    const target = this.wish(PLAYER.walkSpeed);
    approach(this.velocity, target, PLAYER.groundAcceleration * dt);
  }

  private air(dt: number, support: CharacterSurfaceInfo): void {
    if (support.supportedState === CharacterSupportedState.SUPPORTED) {
      this.mode = 'walk';
      this.walk(dt, support);
      return;
    }
    this.velocity.y -= PLAYER.gravity * dt;
  }

  /** The velocity the keys ask for, flat on the ground, at the given speed. */
  private wish(speed: number): Vector3 {
    const direction = this.look.forward().scaleInPlace(this.input.axis('back', 'forward'));
    direction.addInPlace(this.look.right().scaleInPlace(this.input.axis('left', 'right')));
    // Forward and sideways together would be faster than either alone.
    const length = direction.length();
    if (length > 1) direction.scaleInPlace(1 / length);
    return direction.scaleInPlace(speed);
  }
}

/** Moves `current` towards `target` by at most `maxDelta`. */
function approach(current: Vector3, target: Vector3, maxDelta: number): void {
  const distance = Vector3.Distance(current, target);
  if (distance <= maxDelta) current.copyFrom(target);
  else current.addInPlace(target.subtract(current).scaleInPlace(maxDelta / distance));
}

The position of the controller is the center of the capsule, so the start position is the spawn point plus half the height, and footOffset, half the height, leads from the center to the feet.

The player is always in one mode, for now walk or air, and the switch in update() runs the code for the one it is in. When a mode sees that it no longer applies, it switches and hands the frame on, so walking off an edge starts the fall in the same frame. Later parts add modes for ladders, ledges and walls.

update() starts from getVelocity(), the velocity the controller was left with after the last frame. If the player ran into a wall, that is the velocity along it, not into it, so speed does not pile up against walls.

Walking

wish() turns the keys into the velocity the player asks for: forward and right as the player faces, times the axes, times the speed. Forward and right at the same time make a vector about 1.41 long, so it is scaled back to 1. Otherwise, walking diagonally would be 41 percent faster, the strafe-running trick of early shooters.

The velocity does not jump to that value. approach() moves it towards the target by at most groundAcceleration × dt per frame: 40 m/s² takes it from standing to 4 m/s in a tenth of a second, and back again. Because the step is tied to dt, it takes the same time at 30, 60 or 144 frames per second.

Falling

checkSupport() reports one of three states: SUPPORTED on walkable ground, SLIDING on ground too steep to stand on, and UNSUPPORTED in the air. Without support the player falls: gravity adds to the downward speed every frame. When support comes back, the player lands and walks again.

A HUD

Movement code is hard to judge by eye. A few lines in the corner that show the mode, the speed and the position of the feet help a lot. Create src/hud.ts:

src/hud.ts
import type { Player } from './player.ts';

/** A few lines of text in the corner that say what the player is doing. */
export class Hud {
  private readonly element: HTMLElement;
  private text = '';

  constructor(element: HTMLElement) {
    this.element = element;
  }

  update(player: Player): void {
    const feet = player.feet();
    const text = [
      player.mode,
      `${player.horizontalSpeed().toFixed(1)} m/s`,
      `x ${feet.x.toFixed(1)}  y ${feet.y.toFixed(2)}  z ${feet.z.toFixed(1)}`,
    ].join('\n');
    // Only touch the page when something changed.
    if (text !== this.text) this.element.textContent = this.text = text;
  }
}

It changes the page only when the text changed: touching the page 60 times a second for nothing costs time in every frame.

Add the HUD to index.html, after the crosshair, and give the overlay its new hint:

index.html
<canvas id="game"></canvas>
    <div id="crosshair"></div>
    <div id="hud"></div>
    <div id="overlay">
      <strong>Click to play</strong>
      <span>WASD walk · mouse look · Esc pause</span>
    </div>
    <script type="module" src="/src/main.ts"></script>

Style it in src/style.css, before #overlay:

src/style.css
#hud {
  position: fixed;
  left: 12px;
  top: 12px;
  font: 13px/1.5 ui-monospace, monospace;
  white-space: pre;
  text-shadow: 0 1px 2px #000;
  pointer-events: none;
}

The camera follows

The camera now takes its position from the player's eyes. Replace src/camera.ts:

src/camera.ts
import { FreeCamera, Vector3, type Scene } from '@babylonjs/core/pure';
import type { Look } from './look.ts';
import type { Player } from './player.ts';
import { CAMERA } from './settings.ts';

/** The player's eyes: the camera, put where the player is and turned where they look. */
export class PlayerCamera {
  readonly camera: FreeCamera;

  constructor(scene: Scene) {
    this.camera = new FreeCamera('eyes', Vector3.Zero(), scene);
    // A near plane close to the eye lets the player hug a wall without seeing through it.
    this.camera.minZ = 0.05;
    this.camera.fov = CAMERA.fov;
  }

  update(player: Player, look: Look): void {
    player.eyePosition(this.camera.position);
    this.camera.rotation.set(look.pitch, look.yaw, 0);
  }
}

In src/main.ts, import Player and Hud, and replace everything from await enablePhysics(scene) to the render loop. The fixed camera position goes:

src/main.ts
await enablePhysics(scene);
const level = buildLevel(scene);

const input = new Input(canvas);
const look = new Look();
const player = new Player(scene, level, input, look);
const view = new PlayerCamera(scene);
const hud = new Hud(document.querySelector<HTMLElement>('#hud')!);

overlay.addEventListener('click', () => input.lock());
input.onLockChange = (locked) => overlay.classList.toggle('hidden', locked);

scene.onBeforeRenderObservable.add(() => {
  // After a stall, such as a tab in the background, one huge step would tunnel through walls.
  const dt = Math.min(engine.getDeltaTime() / 1000, 0.05);
  if (dt <= 0) return;
  look.update(input);
  player.update(dt);
  view.update(player, look);
  hud.update(player);
});

engine.runRenderLoop(() => scene.render());

The frame time dt is capped at 50 milliseconds. Browsers pause requestAnimationFrame in background tabs, and the first frame back would otherwise move the player by seconds' worth at once, straight through a wall. The same trick appeared in Pong.

The order in a frame matters: the look first, so the player walks where the mouse points now, then the player, then the camera at the player's new position.

Try it

Walk around with W, A, S and D or the arrow keys. The HUD shows 4.0 m/s, also diagonally, and when you let go, the player stops within 20 cm. Walls stop you, and you slide along them. Walk off the platform at the top of the stairs: the HUD switches to air, and you land 2 m further down.

Then try the stairs. You get up, but in little hops, and the HUD flickers between walk and air, on the way up and on the way down. The 70 degree ramp cannot be walked up at all. That is the topic of the next part.

The project at the end of part 3: walk with WASD, the HUD in the corner shows what the player does.
Open in a new tab

In the next part the player learns to walk on slopes and stairs without hopping.

Comments

The comments are GitHub Discussions: sign in with GitHub to write one.

The comments come from GitHub, through giscus. Loading them sends your IP address to giscus.app and GitHub.