Skip to content

Capstone: Third-Person Character Controller

Every other page in this Module taught one idea. This one puts them together into a thing that works: a third-person character that walks where the camera is facing, slides along walls, climbs ledges, jumps, and is followed by a camera.

Almost no new mathematics appears here. That is the point — the pieces were all built already, and what remains is assembly. Where something new was needed, it is called out.

PieceWhere it came from
Stick input to a world direction relative to the camera1.1 basis vectors, 5.2 camera yaw
Diagonals not running 41% fast1.2 normalize
Facing the way you are going, the short way round1.5 shortest rotation
Jump sized by height and rise time, not gravity7.1 the backwards solve
Jump as an impulse rather than a force7.1 impulses
Velocity before position7.2 semi-implicit Euler
One fixed tick, whatever the frame rate7.2 the accumulator
Capsule as the body shape6.2 bounding volumes
Pushing out by the shortest distance6.3 minimum translation vector
Sliding along walls instead of stopping6.3 the split
A millimeter of skin so contact does not flicker6.3
Floor against wall, decided by a threshold6.3 slope limit
Camera orbit as two angles and a radius5.2 spherical coordinates
Camera follow that lags and settles4.1 exponential decay
A scripted camera sweep through placed points4.4 Catmull-Rom
Drawing between physics ticks7.2 render interpolation
The whole controller, with each piece switchable off
The code that draws it src/lib/gamedev/demos/capstone.scene.ts
/** The whole controller running a scripted route, with each piece switchable off. */
import * as THREE from "three";
import {
  HEIGHT,
  RADIUS,
  capsuleFor,
  uprightCapsuleContact,
} from "../controller.ts";
import {
  ALL_ON,
  DURATION,
  LEVEL,
  TICK,
  drawnAt,
  inputAt,
  simulate,
  type Switches,
} from "./capstone-shared.ts";
import {
  makeCanvas,
  addSlider,
  addCheckbox,
  addReadout,
  addPolyline,
} from "./ui.ts";
import type { MountFn } from "./runner.ts";

const BODY = 0x39d3c3;
const BROKEN = 0xff7b72;
const TRAIL = 0xd2a8ff;
const SOLID = 0x484f58;
const FACING = 0xf0883e;

const mount: MountFn = (el) => {
  const { renderer, width, height, background } = makeCanvas(el, 330);

  const scene = new THREE.Scene();
  scene.background = background;
  const camera = new THREE.PerspectiveCamera(42, width / height, 0.1, 200);

  // The level, drawn once: it never moves.
  for (const box of LEVEL) {
    const size = {
      x: box.max.x - box.min.x,
      y: box.max.y - box.min.y,
      z: box.max.z - box.min.z,
    };
    const edges = new THREE.LineSegments(
      new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)),
      new THREE.LineBasicMaterial({ color: SOLID }),
    );
    edges.position.set(
      (box.min.x + box.max.x) / 2,
      (box.min.y + box.max.y) / 2,
      (box.min.z + box.max.z) / 2,
    );
    scene.add(edges);
  }

  const bodyMaterial = new THREE.MeshBasicMaterial({
    color: BODY,
    wireframe: true,
  });
  const body = new THREE.Mesh(
    new THREE.CapsuleGeometry(RADIUS, HEIGHT - 2 * RADIUS, 6, 14),
    bodyMaterial,
  );
  scene.add(body);

  const trail = addPolyline(scene, TRAIL);
  const facing = addPolyline(scene, FACING);

  const state = addReadout(el);
  const pieces = addReadout(el);
  const when = addSlider(
    el,
    "scrub through the run",
    0,
    DURATION,
    1.2,
    draw,
    " s",
    0.02,
  );
  const spin = addSlider(el, "walk around it", -180, 180, 35, draw);
  const slide = addCheckbox(
    el,
    "slide along walls instead of stopping dead",
    true,
    rerun,
  );
  const shortest = addCheckbox(el, "turn the short way round", true, rerun);
  const normalize = addCheckbox(
    el,
    "normalize the input direction",
    true,
    rerun,
  );
  const smooth = addCheckbox(el, "draw between ticks, not on them", true, draw);

  let switches: Switches = ALL_ON;
  let ticks = simulate(switches);

  function rerun() {
    switches = {
      slide: slide(),
      shortestTurn: shortest(),
      normalize: normalize(),
    };
    ticks = simulate(switches);
    draw();
  }

  function draw() {
    const t = when();
    const shown = drawnAt(ticks, t, smooth());
    const index = Math.min(Math.round(t / TICK), ticks.length - 1);
    const live = ticks[index];

    body.position.set(
      shown.position.x,
      shown.position.y + HEIGHT / 2,
      shown.position.z,
    );
    const allOn = slide() && shortest() && normalize();
    bodyMaterial.color.setHex(allOn ? BODY : BROKEN);

    // Where the body is pointing, which lags where it is going.
    const nose = {
      x: shown.position.x + Math.sin(shown.yaw) * 1.4,
      z: shown.position.z + Math.cos(shown.yaw) * 1.4,
    };
    facing([
      new THREE.Vector3(
        shown.position.x,
        shown.position.y + 0.9,
        shown.position.z,
      ),
      new THREE.Vector3(nose.x, shown.position.y + 0.9, nose.z),
    ]);

    trail(
      ticks
        .slice(0, index + 1)
        .map(
          (c) =>
            new THREE.Vector3(c.position.x, c.position.y + 0.05, c.position.z),
        ),
    );

    const a = (spin() * Math.PI) / 180;
    camera.position.set(Math.sin(a) * 19, 11, Math.cos(a) * 19);
    camera.lookAt(-1, 0, 0);

    const input = inputAt(index * TICK);
    const speed = Math.hypot(live.velocity.x, live.velocity.z);
    const touching = LEVEL.filter(
      (box) =>
        uprightCapsuleContact(
          { ...capsuleFor(live.position), radius: RADIUS + 0.02 },
          box,
        ) !== null,
    ).length;
    state(
      `${speed.toFixed(2)} m/s \u00B7 facing ${((shown.yaw * 180) / Math.PI).toFixed(0)}\u00B0 \u00B7 ` +
        `${live.grounded ? "on the ground" : "in the air"} \u00B7 touching ${touching} surface${touching === 1 ? "" : "s"} \u00B7 ` +
        `stick (${input.forward}, ${input.strafe})${input.jump ? " + jump" : ""}`,
    );

    const off: string[] = [];
    if (!slide()) off.push("no sliding: it stops dead at the wall");
    if (!shortest()) off.push("no shortest turn: it spins the long way round");
    if (!normalize()) off.push("no normalize: diagonals run 25% fast");
    if (!smooth())
      off.push("no interpolation: it only moves on tick boundaries");
    pieces(off.length === 0 ? "every piece switched on" : off.join(" \u00B7 "));

    renderer.render(scene, camera);
  }

  draw();

  return () => renderer.dispose();
};

export default mount;

Scrub through the run. The route is scripted rather than played, because a controller wants held keys and a reader has one pointer — Part 1’s movement demo shipped an on-screen arrow pad for that problem and it turned out to be unusable. Scrubbing has a side benefit: you can stop on any single tick and look at it.

What the route does, in order: walks diagonally into the wall and slides along it, turns hard left and climbs the ledge, jumps from the top of it, arcs back across the floor, and turns around to come back.

Now switch the pieces off one at a time.

  • No sliding. The character stops dead at the wall instead of being carried along it. This is the single most recognisable difference between a controller that feels good and one that does not.
  • No shortest turn. The turn from walking-into-the-wall to walking-left is 233° written down and 127° travelled. Without folding the difference, the body spins the wrong way round the long route.
  • No normalize. Diagonal input runs 25% fast for this stick position, and up to 41% for a full diagonal.
  • No interpolation between ticks. The body only moves on tick boundaries. The physics is unchanged and identical; only the drawing stutters.
source The whole controller src/lib/gamedev/controller.ts 363 lines
/**
 * A third-person character controller, assembled from the rest of the module.
 *
 * Almost nothing here is new. Input becomes a world direction with Section 1.1's basis and Section
 * 1.2's normalize. The jump is Section 7.1's impulse, sized by Section 7.1's backwards solve. The
 * velocity update is Section 7.2's semi-implicit ordering. Pushing out of geometry and sliding
 * along it is Section 6.3. Turning the short way is Section 1.5. The whole thing runs on Section
 * 7.2's fixed step.
 *
 * The one piece of genuinely new arithmetic is `uprightCapsuleContact`, and it is new only because
 * being upright is what makes it easy - see the comment on it.
 */
import type { Vec3 } from "./matrices.ts";
import type { Aabb, Capsule } from "./collision.ts";
import { closestOnBox } from "./geometry.ts";
import { basisFromYaw } from "./conventions.ts";
import { rotateToward, wrapRad } from "./angles.ts";
import { isWalkable, slideAlong, type Contact } from "./response.ts";
import { jumpFromHeightAndTime } from "./dynamics.ts";

/** A body 1.8 m tall and 0.7 m across: roughly a person. */
export const HEIGHT = 1.8;
export const RADIUS = 0.35;
export const SPEED = 5;
/** Tuned the Section 7.1 way: a height and a rise time, not a gravity. */
export const JUMP_HEIGHT = 1.2;
export const JUMP_RISE = 0.4;
export const { gravity: GRAVITY, launchSpeed: JUMP_SPEED } =
  jumpFromHeightAndTime(JUMP_HEIGHT, JUMP_RISE);
/** Radians per second. A constant rate, so `rate * dt` is frame-rate independent. */
export const TURN_RATE = 9;
export const MAX_SLOPE = 46;
/** A millimeter of clearance, so a resting character is not on the boundary. Section 6.3. */
export const SKIN = 0.001;
/** The tallest ledge the character will walk up rather than be stopped by. */
export const STEP_HEIGHT = 0.4;

/** What the player is asking for. Both axes in -1..1, as a stick or two keys would give. */
export type Input = { forward: number; strafe: number; jump: boolean };

/** Everything the controller carries between ticks. */
export type Character = {
  /** At the feet, because that is what a level designer places. */
  position: Vec3;
  velocity: Vec3;
  /** Which way the body is facing, which lags the direction it is moving. */
  yaw: number;
  grounded: boolean;
};

const add = (a: Vec3, b: Vec3): Vec3 => ({
  x: a.x + b.x,
  y: a.y + b.y,
  z: a.z + b.z,
});
const mul = (a: Vec3, k: number): Vec3 => ({
  x: a.x * k,
  y: a.y * k,
  z: a.z * k,
});
const size = (a: Vec3) => Math.hypot(a.x, a.y, a.z);
const clamp = (v: number, lo: number, hi: number) =>
  v < lo ? lo : v > hi ? hi : v;

/** The capsule occupied by a character standing at `position`. Always upright. */
export function capsuleFor(position: Vec3): Capsule {
  return {
    a: { x: position.x, y: position.y + RADIUS, z: position.z },
    b: { x: position.x, y: position.y + HEIGHT - RADIUS, z: position.z },
    radius: RADIUS,
  };
}

/**
 * Stick input to a world-space direction, relative to where the camera is looking.
 *
 * This is the thing that makes third-person controls feel right: "forward" means *away from the
 * camera*, not along some fixed world axis, so turning the camera turns what the stick means.
 *
 * Two details that are each a bug if missed. Section 1.1's basis is used to get the camera's
 * forward and right, rather than guessing at signs - forward is $-Z$, which is where the minus
 * comes from. And the result is **normalized**, which is Section 1.2's diagonal-speed fix: full
 * forward plus full strafe has length 1.41, so without it diagonal movement runs 41% fast.
 */
export function moveDirection(input: Input, cameraYaw: number): Vec3 {
  const basis = basisFromYaw(cameraYaw);
  const forward = { x: -basis.z[0], y: 0, z: -basis.z[2] };
  const right = { x: basis.x[0], y: 0, z: basis.x[2] };
  const wanted = add(mul(forward, input.forward), mul(right, input.strafe));
  const length = size(wanted);
  // No direction asked for is not the same as a direction of zero length, so say so.
  return length < 1e-6 ? { x: 0, y: 0, z: 0 } : mul(wanted, 1 / length);
}

/**
 * Contact between an **upright** capsule and an axis-aligned box, exactly.
 *
 * Section 6.2 was careful not to claim a capsule against a box is easy, because in general it is
 * not - a tilted capsule against a box has no tidy closed form, which is why engines reach for
 * GJK. Being upright is what changes that, and the reason is Section 6.1's: **both shapes are
 * axis-aligned, so the three axes stay independent.**
 *
 * The capsule's axis only varies in $y$, so the box's nearest $x$ and $z$ do not depend on which
 * point of the axis you pick. That leaves one interval-against-interval question in $y$, which is
 * a comparison. Three clamps again, no iteration and no approximation.
 *
 * A character controller is exactly this case, which is a nice piece of luck and worth knowing:
 * the shape that fits a person best is also the one that collides with level geometry cheaply.
 */
export function uprightCapsuleContact(
  capsule: Capsule,
  box: Aabb,
): Contact | null {
  // The point on the capsule's axis nearest the box, found in y alone.
  const low = Math.max(capsule.a.y, box.min.y);
  const high = Math.min(capsule.b.y, box.max.y);
  const y =
    low <= high
      ? low // the ranges overlap, so any y in the overlap is zero distance away
      : capsule.b.y < box.min.y
        ? capsule.b.y // wholly below the box
        : capsule.a.y; // wholly above it
  const onAxis: Vec3 = { x: capsule.a.x, y, z: capsule.a.z };
  const onBox = closestOnBox(box.min, box.max, onAxis);

  const away = {
    x: onAxis.x - onBox.x,
    y: onAxis.y - onBox.y,
    z: onAxis.z - onBox.z,
  };
  const gap = size(away);
  if (gap >= capsule.radius) return null;
  if (gap > 1e-9) {
    return { normal: mul(away, 1 / gap), depth: capsule.radius - gap };
  }

  /* The axis is inside the box, so there is no direction to push along - the same degenerate case
     Section 6.1 hit at the centre of a sphere. Fall back to Section 6.3's minimum translation
     vector: the shallowest face wins, because any other choice flings the character. */
  const centre = {
    x: (box.min.x + box.max.x) / 2,
    y: (box.min.y + box.max.y) / 2,
    z: (box.min.z + box.max.z) / 2,
  };
  let axis: "x" | "y" | "z" = "y";
  let shallowest = Infinity;
  for (const k of ["x", "y", "z"] as const) {
    const half = (box.max[k] - box.min[k]) / 2;
    const overlap = half - Math.abs(onAxis[k] - centre[k]);
    if (overlap < shallowest) {
      shallowest = overlap;
      axis = k;
    }
  }
  const normal: Vec3 = { x: 0, y: 0, z: 0 };
  normal[axis] = onAxis[axis] >= centre[axis] ? 1 : -1;
  return { normal, depth: shallowest + capsule.radius };
}

/**
 * Push out of everything, repeatedly, and remove the blocked part of the velocity each time.
 *
 * Repeatedly, because resolving one contact can create another - sliding along a wall pushes you
 * into the floor. Section 6.3 does both halves each pass: the position fix uses the depth, the
 * velocity fix uses the normal, and doing only one of them either buzzes or sinks.
 *
 * **Eight passes, and the number was measured rather than guessed.** Four is plenty for anything
 * that arises from moving - a character stepping into a wall or a corner settles in one or two.
 * Four is not enough for a body that *starts* buried: sweeping 1,134 overlapping placements around
 * a box, 75 of them failed to clear in four passes, all of them with the capsule's axis fully
 * inside the box and up to 1.1 m deep. Every one of those settles by eight, because each pass can
 * only move by the shallowest face and the shallowest face changes as it goes.
 *
 * The cap itself stays, because a character genuinely wedged between two surfaces will never
 * settle, and a slightly wrong position is better than a frozen frame.
 */
export function resolve(
  position: Vec3,
  velocity: Vec3,
  level: readonly Aabb[],
  passes = 8,
): { position: Vec3; velocity: Vec3; grounded: boolean } {
  let where = position;
  let moving = velocity;
  let grounded = false;

  for (let pass = 0; pass < passes; pass += 1) {
    const capsule = capsuleFor(where);
    let deepest: Contact | null = null;
    for (const box of level) {
      const contact = uprightCapsuleContact(capsule, box);
      if (contact && (deepest === null || contact.depth > deepest.depth)) {
        deepest = contact;
      }
    }
    if (deepest === null) break;
    if (isWalkable(deepest.normal, MAX_SLOPE)) grounded = true;
    where = add(where, mul(deepest.normal, deepest.depth + SKIN));
    // Only interfere with a velocity heading into the surface, or the character sticks to walls.
    if (
      moving.x * deepest.normal.x +
        moving.y * deepest.normal.y +
        moving.z * deepest.normal.z <
      0
    ) {
      moving = slideAlong(moving, deepest.normal);
    }
  }

  return { position: where, velocity: moving, grounded };
}

/** Is any contact here too steep to stand on? That is what "blocked by a wall" means. */
function blockedByWall(position: Vec3, level: readonly Aabb[]): boolean {
  const capsule = capsuleFor(position);
  return level.some((box) => {
    const contact = uprightCapsuleContact(capsule, box);
    return contact !== null && !isWalkable(contact.normal, MAX_SLOPE);
  });
}

/**
 * The highest surface under a position that the character could stand on, at most `ceiling` high.
 *
 * Exact for boxes: the capsule's cross-section is a circle, so it overlaps a box's footprint when
 * the horizontal distance to that footprint is under the radius - Section 6.1's clamp again, in two
 * dimensions instead of three.
 */
export function supportUnder(
  position: Vec3,
  level: readonly Aabb[],
  ceiling: number,
): number | null {
  let best = -Infinity;
  for (const box of level) {
    const dx = Math.max(box.min.x - position.x, position.x - box.max.x, 0);
    const dz = Math.max(box.min.z - position.z, position.z - box.max.z, 0);
    if (Math.hypot(dx, dz) >= RADIUS) continue;
    if (box.max.y <= ceiling + 1e-9 && box.max.y > best) best = box.max.y;
  }
  return best === -Infinity ? null : best;
}

/**
 * Walk up a ledge instead of being stopped by it.
 *
 * **This is not optional, and the reason is worth knowing.** A capsule's lower hemisphere meets the
 * top edge of a ledge at a steep angle - for a 0.35 m radius against a 0.2 m ledge the contact
 * normal is 64 degrees from vertical, well past any sane slope limit - so push-out treats it as a
 * wall. What actually happens then is worse than being stopped: each pass shoves the capsule out
 * along a normal that has *some* upward component, so over several ticks it **ratchets** up and pops
 * onto the ledge anyway. Whether it manages that depends on the walking speed and the tick rate,
 * which is the kind of accident that works on your machine and not on someone else's.
 *
 * So do it deliberately. Lift by the step height, try the move there, and settle onto whatever
 * supports it. Unity spells this `stepOffset` and Godot puts it in `move_and_slide`; both exist for
 * exactly this reason.
 */
export function tryStepUp(
  from: Vec3,
  moved: Vec3,
  level: readonly Aabb[],
): Vec3 | null {
  const lifted: Vec3 = { x: moved.x, y: from.y + STEP_HEIGHT, z: moved.z };
  if (blockedByWall(lifted, level)) return null;
  const support = supportUnder(lifted, level, lifted.y);
  if (support === null || support <= from.y + SKIN) return null;
  if (support - from.y > STEP_HEIGHT) return null;
  const stepped: Vec3 = { x: moved.x, y: support + SKIN, z: moved.z };
  return blockedByWall(stepped, level) ? null : stepped;
}

/**
 * One fixed tick of the whole controller.
 *
 * The ordering is not arbitrary. Velocity is updated before position, which is Section 7.2's
 * semi-implicit Euler and the reason a spring in this system would stay bounded. The jump is
 * applied as an instant change rather than a force, which is Section 7.1's impulse and the reason
 * it feels sharp. Collision runs last, on a position that has already moved.
 */
export function step(
  character: Character,
  input: Input,
  cameraYaw: number,
  level: readonly Aabb[],
  dt: number,
): Character {
  const wanted = moveDirection(input, cameraYaw);

  // Horizontal speed is set outright rather than accelerated into, which is what makes an
  // action game feel responsive. Swap in Section 4.1's damp for something with weight.
  let velocity: Vec3 = {
    x: wanted.x * SPEED,
    y: character.velocity.y,
    z: wanted.z * SPEED,
  };

  if (input.jump && character.grounded) velocity.y = JUMP_SPEED;
  velocity = { x: velocity.x, y: velocity.y - GRAVITY * dt, z: velocity.z };

  const moved = add(character.position, mul(velocity, dt));

  /* Try the ledge first, but only when already standing on something: a character in mid-air must
     not be able to teleport onto a ledge it happened to brush past. */
  const stepped =
    character.grounded && blockedByWall(moved, level)
      ? tryStepUp(character.position, moved, level)
      : null;
  const settled = resolve(stepped ?? moved, velocity, level);

  /* Face the way you are going, arriving there over a few frames rather than instantly. Section
     1.5's shortest rotation is what stops a 179 degree turn going the long way round. */
  /* Wrapped, so the stored yaw stays within half a turn of zero instead of winding up past a full
     turn after a few laps. `rotateToward` already folds the *difference*, so this only keeps the
     state canonical - which matters, because an unwrapped yaw makes two identical facings compare
     as different numbers. */
  const yaw =
    size(wanted) < 1e-6
      ? character.yaw
      : wrapRad(
          rotateToward(
            character.yaw,
            Math.atan2(wanted.x, wanted.z),
            TURN_RATE * dt,
          ),
        );

  return {
    position: settled.position,
    velocity: settled.velocity,
    yaw,
    grounded: settled.grounded,
  };
}

// ---- The camera ---------------------------------------------------------------------------

/**
 * Where the camera should sit, given an orbit and a target.
 *
 * Section 5.2's spherical coordinates, so each drag axis drives exactly one number and the
 * elevation can be clamped short of the pole where the azimuth stops meaning anything.
 */
export function orbitPosition(
  target: Vec3,
  azimuth: number,
  elevation: number,
  distance: number,
): Vec3 {
  const el = clamp(elevation, -80, 80);
  const a = (azimuth * Math.PI) / 180;
  const e = (el * Math.PI) / 180;
  return {
    x: target.x + distance * Math.cos(e) * Math.sin(a),
    y: target.y + distance * Math.sin(e),
    z: target.z + distance * Math.cos(e) * Math.cos(a),
  };
}

/** The point the camera looks at: the character's chest, not their feet. */
export function lookTarget(position: Vec3): Vec3 {
  return { x: position.x, y: position.y + HEIGHT * 0.65, z: position.z };
}

Assembling a module’s worth of parts turned up exactly two problems that none of the individual Sections had to face.

A capsule against a box, which is easy only because it is upright

Section titled “A capsule against a box, which is easy only because it is upright”

Section 6.2 built sphere-box, box-box, capsule-capsule and sphere-capsule, and stopped there. A capsule against a box is genuinely awkward in general — a tilted capsule against a box has no tidy closed form, which is why engines reach for GJK.

Being upright changes it completely, and the reason is Section 6.1’s: both shapes are axis-aligned, so the three axes stay independent. The capsule’s axis only varies in yy, so the box’s nearest xx and zz do not depend on which point of the axis you pick. That leaves one interval-against-interval question in yy, which is a comparison. Three clamps, no iteration, no approximation.

That is a nice piece of luck worth noticing: the shape that fits a person best is also the one that collides with level geometry cheaply.

The build check verifies it by brute force rather than against another formula — 8,125 placements around a box, walking the capsule’s own axis at each one and requiring the same verdict and the same depth. Zero disagreements, depth exact.

Climbing a ledge, which a capsule will not do properly by accident

Section titled “Climbing a ledge, which a capsule will not do properly by accident”

Push-out alone cannot climb a step, and the way it fails is worse than failing.

A capsule’s lower hemisphere meets the top edge of a ledge at a steep angle. For a 0.35 m radius against a 0.2 m ledge the contact normal is 64° from vertical, well past any sane slope limit, so it is treated as a wall. But then each resolution pass shoves the capsule out along a normal that has some upward component — so over several ticks it ratchets up and pops onto the ledge anyway.

Whether it manages that depends on the walking speed and the tick rate. That is the kind of accident that works on your machine and not on someone else’s.

So do it deliberately: lift by a step height, try the move up there, and settle onto whatever supports it. Unity spells this stepOffset and Godot folds it into move_and_slide; both exist for exactly this reason. The check confirms a 0.3 m ledge is climbed, a ledge taller than the step height is refused, and the character never ends up inside either one.

Three ways to point a camera at the same character
The code that draws it src/lib/gamedev/demos/capstonecam.scene.ts
/** Three ways to point a camera at the same character: orbit, damped follow, and a scripted shot. */
import * as THREE from "three";
import { HEIGHT, RADIUS, lookTarget, orbitPosition } from "../controller.ts";
import { damp, rateFromHalfLife } from "../interpolation.ts";
import { LEVEL, SHOT, TICK, shotAt, simulate } from "./capstone-shared.ts";
import {
  makeCanvas,
  addSlider,
  addReadout,
  addButtonRow,
  addPolyline,
} from "./ui.ts";
import type { MountFn } from "./runner.ts";

const BODY = 0x39d3c3;
const RIG = 0xf0883e;
const PATH = 0xd2a8ff;
const SOLID = 0x484f58;
const MODES = ["orbit", "damped follow", "scripted shot"] as const;
type Mode = (typeof MODES)[number];

const mount: MountFn = (el) => {
  const { renderer, width, height, background } = makeCanvas(el, 320);

  const scene = new THREE.Scene();
  scene.background = background;
  const camera = new THREE.PerspectiveCamera(44, width / height, 0.1, 200);

  for (const box of LEVEL) {
    const edges = new THREE.LineSegments(
      new THREE.EdgesGeometry(
        new THREE.BoxGeometry(
          box.max.x - box.min.x,
          box.max.y - box.min.y,
          box.max.z - box.min.z,
        ),
      ),
      new THREE.LineBasicMaterial({ color: SOLID }),
    );
    edges.position.set(
      (box.min.x + box.max.x) / 2,
      (box.min.y + box.max.y) / 2,
      (box.min.z + box.max.z) / 2,
    );
    scene.add(edges);
  }

  const body = new THREE.Mesh(
    new THREE.CapsuleGeometry(RADIUS, HEIGHT - 2 * RADIUS, 6, 14),
    new THREE.MeshBasicMaterial({ color: BODY, wireframe: true }),
  );
  scene.add(body);

  const rig = new THREE.Mesh(
    new THREE.SphereGeometry(0.3, 14, 10),
    new THREE.MeshBasicMaterial({ color: RIG }),
  );
  scene.add(rig);

  const sightLine = addPolyline(scene, RIG, {
    dashed: true,
    dashSize: 0.4,
    gapSize: 0.3,
  });
  const shotPath = addPolyline(scene, PATH);
  const waypoints = SHOT.map(() => {
    const m = new THREE.Mesh(
      new THREE.SphereGeometry(0.14, 10, 8),
      new THREE.MeshBasicMaterial({ color: PATH }),
    );
    scene.add(m);
    return m;
  });
  SHOT.forEach((p, i) => waypoints[i].position.set(p.x, p.y, p.z));

  const ticks = simulate();
  let mode: Mode = "orbit";
  /* The damped camera has to remember where it was, because Section 4.1's damp is a step from the
     current position rather than a formula for it. Scrubbing backwards therefore replays from the
     start, which is the honest way to show a stateful camera on a scrub slider. */

  const show = addReadout(el);
  const note = addReadout(el);
  const mark = addButtonRow(
    el,
    MODES.map((m) => ({
      label: m,
      apply: () => {
        mode = m;
        draw();
      },
    })),
  );
  const when = addSlider(
    el,
    "scrub through the run",
    0,
    5.5,
    2.4,
    draw,
    " s",
    0.02,
  );
  const around = addSlider(el, "camera angle around", -180, 180, 35, draw);
  const halfLife = addSlider(
    el,
    "follow half-life",
    0.02,
    0.6,
    0.12,
    draw,
    " s",
    0.02,
  );

  function draw() {
    const t = when();
    const index = Math.min(Math.round(t / TICK), ticks.length - 1);
    const here = ticks[index].position;
    body.position.set(here.x, here.y + HEIGHT / 2, here.z);
    const target = lookTarget(here);

    let eye: THREE.Vector3;
    if (mode === "orbit") {
      const p = orbitPosition(target, around(), 22, 8);
      eye = new THREE.Vector3(p.x, p.y, p.z);
      note(
        "azimuth, elevation and distance, so each drag axis drives exactly one number \u00B7 Section 5.2",
      );
    } else if (mode === "damped follow") {
      // Replayed from the start, because a damped follow depends on where it has been.
      let held = orbitPosition(lookTarget(ticks[0].position), around(), 22, 8);
      /* `damp` wants a rate, not a half-life. Section 4.1 exposed the half-life to designers and
         kept the conversion in one place precisely so this substitution cannot be made by hand. */
      const rate = rateFromHalfLife(halfLife());
      for (let i = 1; i <= index; i += 1) {
        const wanted = orbitPosition(
          lookTarget(ticks[i].position),
          around(),
          22,
          8,
        );
        held = {
          x: damp(held.x, wanted.x, rate, TICK),
          y: damp(held.y, wanted.y, rate, TICK),
          z: damp(held.z, wanted.z, rate, TICK),
        };
      }
      eye = new THREE.Vector3(held.x, held.y, held.z);
      note(
        `the orbit position chased with a ${halfLife().toFixed(2)} s half-life, so the camera lags and settles \u00B7 Section 4.1`,
      );
    } else {
      const u = t / 5.5;
      const p = shotAt(u);
      eye = new THREE.Vector3(p.x, p.y, p.z);
      note(
        "a Catmull-Rom spline through six placed waypoints, ignoring the character entirely \u00B7 Section 4.4",
      );
    }

    rig.position.copy(eye);
    sightLine([eye, new THREE.Vector3(target.x, target.y, target.z)]);
    shotPath(
      mode === "scripted shot"
        ? Array.from({ length: 121 }, (_, i) => {
            const p = shotAt(i / 120);
            return new THREE.Vector3(p.x, p.y, p.z);
          })
        : [],
    );
    waypoints.forEach((w) => (w.visible = mode === "scripted shot"));
    mark(MODES.indexOf(mode));

    // The scene is watched from outside, so the camera being demonstrated stays visible.
    const a = (around() * Math.PI) / 180 + Math.PI * 0.65;
    camera.position.set(Math.sin(a) * 26, 15, Math.cos(a) * 26);
    camera.lookAt(-1, 1, 0);

    show(
      `the orange dot is the camera being placed, ${eye.distanceTo(new THREE.Vector3(target.x, target.y, target.z)).toFixed(1)} m from the character it is aimed at`,
    );
    renderer.render(scene, camera);
  }

  draw();

  return () => renderer.dispose();
};

export default mount;

The orange dot is the camera being positioned, and the dashed line is what it is aimed at — the character’s chest, not their feet, which is a one-line detail that makes an enormous difference to how a third-person game reads.

Three modes, three Sections:

  • Orbit is Section 5.2’s spherical coordinates. Azimuth, elevation and radius, so each drag axis drives exactly one number and the elevation clamps short of the pole where the azimuth stops meaning anything.
  • Damped follow is that same orbit position chased with Section 4.1’s exponential decay. Wind the half-life up and the camera drags behind; wind it down and it snaps. Because a damped camera depends on where it has been, scrubbing backwards replays from the start — a stateful camera cannot be evaluated at a single moment, which is worth knowing before you try to make one rewindable.
  • Scripted shot is Section 4.4’s Catmull-Rom through six placed waypoints, ignoring the character entirely.

Here is the finding that only appears once everything runs together, and it corrects something widely half-remembered.

What a fixed timestep promises, and what it does not
The code src/lib/gamedev/demos/determinism.ts
/** What a fixed timestep does and does not promise, measured on the run above. */
import { SPEED } from "../controller.ts";
import { ALL_ON, simulate } from "./capstone-shared.ts";
import type { Demo } from "./runner.ts";

const demo: Demo = (log) => {
  const ends = [30, 60, 144].map((fps) => ({
    fps,
    at: simulate(ALL_ON, 1 / fps).at(-1)!.position,
  }));

  for (const { fps, at } of ends) {
    log(
      `the same run stepped at ${fps} Hz ends at`,
      `x ${at.x.toFixed(3)}, z ${at.z.toFixed(3)}`,
      fps === 30
        ? "identical input, identical code, different tick rate"
        : undefined,
    );
  }

  const xs = ends.map((e) => e.at.x);
  const zs = ends.map((e) => e.at.z);
  const spread = Math.hypot(
    Math.max(...xs) - Math.min(...xs),
    Math.max(...zs) - Math.min(...zs),
  );
  log("so they disagree by", `${spread.toFixed(3)} m`, "which is not nothing");
  log(
    "one tick of walking at 30 Hz is",
    `${(SPEED / 30).toFixed(3)} m`,
    `so the disagreement is ${(spread / (SPEED / 30)).toFixed(2)} ticks of travel`,
  );

  // The promise that does hold: the same rate, twice.
  const a = simulate(ALL_ON, 1 / 60);
  const b = simulate(ALL_ON, 1 / 60);
  const identical = a.every(
    (c, i) =>
      c.position.x === b[i].position.x &&
      c.position.y === b[i].position.y &&
      c.position.z === b[i].position.z &&
      c.yaw === b[i].yaw,
  );
  log(
    "re-running at one rate matches",
    identical ? "bit for bit, every tick" : "NOT identical",
    "which is the promise a replay actually needs",
  );
};

export default demo;
the same run stepped at 30 Hz ends at x -4.062, z -1.175 // identical input, identical code, different tick rate
the same run stepped at 60 Hz ends at x -4.036, z -1.104
the same run stepped at 144 Hz ends at x -4.012, z -1.006
so they disagree by 0.176 m // which is not nothing
one tick of walking at 30 Hz is 0.167 m // so the disagreement is 1.06 ticks of travel
re-running at one rate matches bit for bit, every tick // which is the promise a replay actually needs

The same run, the same input, the same code, stepped at 30, 60 and 144 Hz — and the three end up 18 cm apart.

That is not a bug, and it is not the accumulator failing. Every discrete decision in a controller happens on a tick boundary: which tick first notices the wall, which tick the landing lands on, which tick the ledge climb fires. Different rates put those moments at different instants, and the paths separate by about one tick of travel — 1.08 ticks, measured.

a fixed timestep makes one tick rate reproducible, not two tick rates equal\text{a fixed timestep makes one tick rate } \textbf{reproducible} \text{, not two tick rates } \textbf{equal}

The promise that does hold is the one replays and networked play actually need: re-run at the same rate and you get the same result bit for bit, every tick. Which is why every engine that supports deterministic replay pins the tick rate as part of the format, and why changing physics_ticks_per_second invalidates old replays.

The check asserts both halves — bit-for-bit equality at a fixed rate, and a spread of about one tick of travel across rates. It also asserts the character never ends up inside the level at any of five tick rates, which is the property that actually matters.

Being honest about the edges, because a capstone that pretends to be finished teaches the wrong lesson:

  • No ledge detection, so it will walk off an edge without hesitating.
  • No crouching, no ceilings, so nothing stops it standing up inside geometry.
  • No moving platforms, which need the platform’s motion added to the character’s frame.
  • No coyote time or jump buffering — the forgiveness windows that make a jump feel fair rather than accurate. Neither is physics; both are input timing.
  • No acceleration on the ground. Horizontal speed is set outright, which is what makes an action game feel sharp and a heavier game feel wrong. Section 4.1’s damp is the swap.
  • No animation, which in a real game drives some of the movement rather than following it.
  • No network prediction, which needs the determinism above plus a way to rewind and replay.
  • Boxes only. Real levels are triangle meshes, which needs a broad phase over triangles and capsule-against-triangle rather than capsule-against-box.

Each of those is a known problem with a known shape. None of them changes the maths on this page.