Skip to content

Tune the follow camera

Goal

A camera that sits where you want it: a longer or shorter boom, a different shoulder height, a pitch range that suits your level, and an over-the-shoulder offset instead of a centred one.

Files you will edit

  • src/game.ts
  • src/systems/locomotion.ts

Steps

  1. Know who owns what. The guest states an intent; the host owns the arm. ctx.camera.follow(hero, { yaw, pitch, distance, height }) writes one camera-state record — the orbit pivot, the direction the player is looking, the boom length — and the engine builds a spring arm from it, probes from the pivot towards where the camera wants to sit, and pulls the boom in when a wall is in the way. You never write the collision.

  2. Change the numbers. Everything is a rule in src/game.ts, and the declarative player block has to agree with it, because the engine re-states the rig after every user system:

    ts
    player: {
      prefab: HeroPrefab,
      spawn: HERO_SPAWN,
      camera: 'thirdPerson',
      distance: 3,      // a tighter, more claustrophobic boom
      height: 1.35,     // roughly shoulder height on this capsule
      sensitivity: 0.0018,
    },
    rules: {
      cameraDistance: 3,
      cameraHeight: 1.35,
      cameraPitch: -0.3,      // resting: a little above, looking down
      cameraMinPitch: -1.0,   // how far the camera may rise
      cameraMaxPitch: 0.45,   // how far it may drop and look up
    },

    Keep distance/height and cameraDistance/cameraHeight the same number. They are the same boom stated twice, and tests/game.test.ts asserts the result.

  3. Offset the shoulder. The centred boom is the aimCamera helper in src/systems/locomotion.ts. Swinging the pivot sideways is one extra term on the yaw:

    ts
    function aimCamera(ctx: GameContext, hero: number): void {
      const shoulder = Number(ctx.rules.cameraShoulder ?? 0.5);
      ctx.camera.follow(hero, {
        // A small yaw offset reads as an over-the-shoulder camera, and costs the
        // rig nothing: it is the same one arm, aimed slightly to one side.
        yaw: locomotionState.yaw + shoulder * 0.12,
        pitch: locomotionState.pitch,
        distance: Number(ctx.rules.cameraDistance ?? 4.5),
        height: Number(ctx.rules.cameraHeight ?? 1.5),
      });
    }
  4. Keep the accumulator honest. orbit clamps ctx.camera.look.pitch in place before reading it. That is not tidiness: the declarative camera: 'thirdPerson' block rebuilds the camera out of that same accumulator after your systems run, so a clamp that is not written back is a clamp the engine ignores.

Verify

sh
npm test -w templates/third-person
npm run dev -w templates/third-person

Walk the hero into a corner and hold the mouse so the camera swings into the wall: the boom shortens instead of the wall filling the screen. The headless test rides a spring arm behind the hero asserts armLength and offset.y straight out of output.camera, so a mismatched pair fails before you look at it.

See also