Skip to content

How to Build: Camera System

The camera is one of those systems that you barely notice when it works well and immediately notice when it does not. For a mobile auto-battle RPG where the player walks along a spiraling ribbon of terrain, we need a camera that orbits smoothly, follows the player without jarring snaps, and handles touch input naturally.

The approach is a classic orbit camera built on spherical coordinates, with a lerp-based smoothing layer that makes everything feel polished. This is a direct port from beta where it has been running reliably on both desktop and mobile.

The spherical coordinate model

Instead of storing the camera's position in Cartesian (x, y, z) space, we store it as three spherical values relative to a target point:

  • theta -- horizontal rotation angle (orbiting left/right around the target)
  • phi -- vertical angle (looking down from above vs. looking at the horizon)
  • radius -- distance from the target (zoom level)

Plus a pan offset (panX, panZ) that shifts the orbit center away from the target.

This model is elegant because user input maps directly to these values: dragging horizontally changes theta, dragging vertically changes phi, pinching changes radius. No matrix math needed for the input handling.

The CameraOrbit trait

This is the core state container. Notice how every value has both a current and a target:

typescript
const CameraOrbit = trait({
  theta: 0,                  // current horizontal rotation
  phi: Math.PI / 4,          // current vertical angle
  radius: 120,               // current distance
  targetTheta: 0,            // where theta is heading
  targetPhi: Math.PI / 4,    // where phi is heading
  targetRadius: 120,         // where radius is heading
  panX: 0,                   // current pan offset X
  panZ: 0,                   // current pan offset Z
  targetPanX: 0,             // where panX is heading
  targetPanZ: 0,             // where panZ is heading
})

The current/target split is the key to smooth movement. Input systems write to the target values. The update system lerps current values toward targets each frame. The result is buttery camera movement regardless of input jitter.

Supporting traits

typescript
const IsCamera = trait()          // Tag: marks the camera entity
const IsSnapBack = trait()        // Tag: when present, pan lerps back to (0,0) when not actively panning

const CameraRef = trait(() => ({
  camera: null as THREE.Camera | null,
  canvas: null as HTMLCanvasElement | null,
}))

IsSnapBack is a nice touch for mobile -- if the player drags the camera off-center and then lifts their finger, the camera gently drifts back to center on the player.

Input traits

The camera reads from two world-level input traits:

  • PointerInput -- tracks drag state, delta movement, scroll delta, pinch distance
  • Keyboard -- set of currently held keys (for desktop arrow key rotation)

These are populated by a separate input setup system that registers global event listeners.

The three systems

pollCameraInput

typescript
function pollCameraInput(world: World): void

Runs each frame. Reads PointerInput and Keyboard state, then translates gestures into CameraOrbit target values:

InputMaps to
Single finger drag / mouse dragtargetTheta, targetPhi
Scroll wheel / pinchtargetRadius
Two-finger dragtargetPanX, targetPanZ
Arrow keystargetTheta, targetPhi

The mapping uses sensitivity multipliers to convert pixel deltas into angle changes. These are worth tuning carefully -- too sensitive and the camera feels twitchy on mobile, too sluggish and it feels unresponsive.

updateCamera

typescript
function updateCamera(world: World): void

Runs each frame after input polling. Does three things:

  1. Lerp current values toward targets: theta += (targetTheta - theta) * lerpFactor
  2. Snap back pan offset if IsSnapBack is present and the player is not actively panning
  3. Position the Three.js camera by converting spherical coordinates to Cartesian:
x = centerX + panX + radius * sin(phi) * sin(theta)
y = centerY + radius * cos(phi)
z = centerZ + panZ + radius * sin(phi) * cos(theta)

Then call camera.lookAt(centerX + panX, centerY, centerZ + panZ).

The lerp factor controls how quickly the camera catches up. A value around 0.08-0.12 feels smooth without being sluggish. You can use deltaTime to make it frame-rate independent.

setupPointerInput

typescript
function setupPointerInput(world: World): void

Registers global event listeners for pointer move, pointer down/up, wheel, and touch events. Writes raw input data to the world-level PointerInput trait. This runs once during initialization, not every frame.

The camera renderer component

typescript
// renderers/camera.tsx
function CameraRenderer(): JSX.Element

This React component lives inside the R3F <Canvas> and:

  1. Spawns a camera entity on mount with IsCamera, CameraOrbit, IsSnapBack
  2. Stores the Three.js camera and canvas references in CameraRef
  3. Cleans up the entity on unmount

The actual camera positioning happens in the updateCamera system, not in React. The component just bridges the React Three Fiber world with the Entity Component System (ECS).

Following the player

The orbit center defaults to the player's Transform position. Each frame, updateCamera queries for the player entity and uses its world position as the orbit center:

typescript
const player = world.queryFirst(IsPlayer, Transform)
if (player) {
  const pos = player.get(Transform).position
  // Use pos as the orbit center
}

The pan offset is relative to this center, so when the player moves, the camera follows automatically. If no player exists (during terrain-only testing), the camera can orbit around the origin.

Files to port from beta

All of these are stable and battle-tested:

FilePurpose
systems/poll-camera-input.tsInput to CameraOrbit target mapping
systems/update-camera.tsLerping and Three.js camera positioning
systems/setup-pointer-input.tsGlobal event listener registration
renderers/camera.tsxReact component that spawns the camera entity
traits/input.tsCameraOrbit, PointerInput, CameraRef, IsCamera, IsSnapBack

What comes next

We now have terrain rendering with biome blending and a camera to look at it. But we do not have any concept of what a "level" is -- when it starts, when it ends, what happens when the player dies. The level structure system defines that lifecycle.