Appearance
Camera System: Orbit Navigation
Without a camera system, the terrain exists in memory and in GPU buffers, but the player has no way to see it. The camera is the window into the world -- it determines what the player sees, how they navigate, and how the terrain feels as they explore it. A well-tuned camera is invisible. A poorly-tuned one makes the whole game feel wrong.
The approach is a spherical coordinate orbit camera with lerp-based smoothing. Input maps directly to spherical parameters: horizontal drag changes the azimuth, vertical drag changes the elevation, scroll or pinch changes the zoom distance. The camera follows the player entity and supports a snap-back pan offset for mobile comfort. This system was battle-tested in beta on both desktop and mobile and ports over cleanly.
Goal
Implement an orbit camera that follows the player, responds to touch and mouse input, and provides smooth navigation around the generated terrain. By the end, the player can rotate, zoom, and pan to explore the Wave Function Collapse (WFC) generated levels from any angle.
Prerequisites
- 06 Chunk Integration -- terrain renders on screen
- 07 Biome Blending -- terrain has visual detail worth looking at
- Familiarity with Koota Entity Component System (ECS) traits and systems
Key Concepts
Spherical coordinate model
Instead of storing the camera position as Cartesian (x, y, z), the orbit camera uses three spherical parameters relative to a focus point:
Y (up)
|
| / camera position
| /
| / radius
|/_______________ phi (elevation from vertical)
+ \
/| \
/ | .
/ |
/ |
Z |
/ |
/ theta (azimuth around Y)
/
X- theta -- azimuth angle: horizontal rotation around the Y axis. Dragging left/right changes this.
- phi -- elevation angle: measured from the vertical (Y) axis.
phi = 0looks straight down,phi = PI/2looks at the horizon. Dragging up/down changes this. - radius -- distance from the focus point. Scroll wheel or pinch gesture changes this.
- pan offset -- (panOffsetX, panOffsetZ) shifts the focus point away from the player position. Two-finger drag on mobile changes this.
This model is elegant because user input maps directly to the parameters. No rotation matrices, no quaternion math for the input handling. The spherical-to-Cartesian conversion only happens once per frame when positioning the Three.js camera.
The CameraOrbit trait
This is the core state container. Every parameter has both a current and a target value:
typescript
const CameraOrbit = trait({
theta: Math.PI / 4, // current azimuth (radians)
phi: Math.PI / 4, // current elevation (radians)
radius: 20, // current zoom distance
phiMin: 0.1, // near straight down (just off vertical)
phiMax: Math.PI / 2, // at the horizon
targetTheta: Math.PI / 4, // where theta is heading
targetPhi: Math.PI / 4, // where phi is heading
targetRadius: 20, // where radius is heading
panOffsetX: 0, // current pan offset X (world space)
panOffsetZ: 0, // current pan offset Z (world space)
targetPanOffsetX: 0, // where panOffsetX is heading
targetPanOffsetZ: 0, // where panOffsetZ is heading
});The current/target split is the key to smooth movement. Input systems write to the target values only. A separate update system lerps the current values toward the targets each frame. The result is buttery camera animation regardless of input jitter or frame rate variation.
The min/max phi clamps prevent two problematic angles: looking exactly at the horizon (phi = PI/2) where the camera clips into terrain, and looking from below (phi > PI/2) which inverts the view. The range [0.1, PI/2] keeps the camera safely above the terrain surface.
Supporting traits
typescript
const IsCamera = trait(); // Tag: marks the camera entity
const IsSnapBack = trait(); // Tag: pan lerps back to (0,0) when not panning
const CameraRef = trait(() => ({
camera: null as Camera | null,
canvas: null as HTMLCanvasElement | null,
}));IsSnapBack provides the "rubber band" feel on mobile. When the player drags the camera off-center and lifts their finger, the pan offset gently drifts back to zero, re-centering on the player. Without this tag (editor mode), the pan offset stays wherever you leave it.
CameraRef bridges ECS and Three.js -- it stores references to the actual Three.js PerspectiveCamera and the canvas element so that pure ECS systems can position the camera and perform raycasts without depending on React.
Input source: the PointerInput world trait
The camera reads raw input from a world-level trait:
typescript
const PointerInput = trait(() => ({
isDragging: false,
deltaX: 0, // pointer movement since last frame
deltaY: 0,
scrollDelta: 0, // scroll wheel delta
pointers: new Map(), // active touch points: id -> {x, y}
pinchDistance: 0, // distance between two fingers
prevPinchDistance: 0,
hasTap: false, // single tap detected this frame
tapX: 0,
tapY: 0,
}));A separate initialization system registers global event listeners for pointer move, pointer down/up, wheel, and touch events. These listeners write raw input data into PointerInput each frame. The camera systems consume this data -- they never touch DOM events directly.
The three camera systems
The camera is driven by three ECS systems that run in sequence each frame:
pollCameraInput --> updateCamera --> (render)
(reads input, (lerps values,
writes targets) positions camera)1. pollCameraInput
typescript
function pollCameraInput(world: World): voidRuns every frame. Reads PointerInput and Keyboard state, then translates gestures into CameraOrbit target values:
| Input | Gesture | Maps to |
|---|---|---|
| Single finger horizontal drag | Orbit left/right | targetTheta += deltaX * sensitivity |
| Single finger vertical drag | Orbit up/down | targetPhi += deltaY * sensitivity |
| Scroll wheel | Zoom in/out | targetRadius += scrollDelta * zoomSpeed |
| Pinch (two fingers) | Zoom in/out | targetRadius based on pinch distance change |
| Two-finger drag | Pan | targetPanOffsetX, targetPanOffsetZ |
| Arrow keys (desktop) | Orbit | targetTheta, targetPhi |
The sensitivity multipliers convert pixel deltas into angle changes. These values are worth tuning carefully -- too sensitive and the camera feels twitchy on mobile touch screens, too sluggish and it feels unresponsive on desktop.
typescript
const ORBIT_SENSITIVITY = 0.005; // radians per pixel of drag
const ZOOM_SPEED = 0.1; // radius change per scroll unit
const PAN_SENSITIVITY = 0.05; // world units per pixel of two-finger dragThe system also clamps targetRadius to prevent zooming inside the terrain or out to infinity:
typescript
orbit.targetRadius = Math.max(5, Math.min(200, orbit.targetRadius));2. updateCamera
typescript
function updateCamera(world: World): voidRuns every frame after input polling. Does three things:
Lerp current values toward targets. The lerp factor controls smoothness:
typescript
const LERP_FACTOR = 0.1; // 0.08-0.12 feels smooth without being sluggish
orbit.theta += (orbit.targetTheta - orbit.theta) * LERP_FACTOR;
orbit.phi += (orbit.targetPhi - orbit.phi) * LERP_FACTOR;
orbit.radius += (orbit.targetRadius - orbit.radius) * LERP_FACTOR;For frame-rate independence, multiply the lerp factor by deltaTime / targetFrameTime. But in practice, at 60 frames per second (FPS) the difference is negligible and the simpler constant factor works fine.
Snap back pan offset. If the camera entity has the IsSnapBack tag and the player is not actively dragging with two fingers, the pan offset lerps toward zero:
typescript
if (entity.has(IsSnapBack) && !input.isDragging) {
orbit.panOffsetX += (0 - orbit.panOffsetX) * SNAP_LERP;
orbit.panOffsetZ += (0 - orbit.panOffsetZ) * SNAP_LERP;
}Convert spherical to Cartesian and position the Three.js camera:
typescript
// Player position as the orbit center
const player = world.queryFirst(IsPlayer, Transform);
const center = player ? player.get(Transform).position : new Vector3();
// Spherical to Cartesian conversion
const x = center.x + orbit.panOffsetX + orbit.radius * Math.sin(orbit.phi) * Math.sin(orbit.theta);
const y = center.y + orbit.radius * Math.cos(orbit.phi);
const z = center.z + orbit.panOffsetZ + orbit.radius * Math.sin(orbit.phi) * Math.cos(orbit.theta);
camera.position.set(x, y, z);
camera.lookAt(
center.x + orbit.panOffsetX,
center.y,
center.z + orbit.panOffsetZ,
);The orbit center defaults to the player's Transform position. The pan offset shifts this center, so the camera can look at terrain beside the player. When the player moves, the orbit center follows automatically.
3. setupPointerInput (initialization only)
typescript
function setupPointerInput(world: World): voidRuns once during initialization. Registers global event listeners for pointermove, pointerdown, pointerup, wheel, and touch events. Writes raw data into the PointerInput world trait. This is not a per-frame system -- it sets up the event pipeline that pollCameraInput reads from.
The coordinate conversion in detail
The spherical-to-Cartesian math deserves a closer look. Given spherical coordinates (theta, phi, radius) and a center point:
y
| * camera
| /|
| / |
| / | radius * cos(phi)
| / |
|/ phi|
────────────────+─────+──────── xz plane
| |
center |
radius * sin(phi)
In the xz plane:
z
| * camera projected onto xz
| /
| / radius * sin(phi)
| /
| / theta
+──────── x
centerThe full conversion:
x = centerX + panX + radius * sin(phi) * sin(theta)
y = centerY + radius * cos(phi)
z = centerZ + panZ + radius * sin(phi) * cos(theta)Note that phi is measured from the Y axis (vertical), not from the XZ plane. When phi = 0, the camera is directly above (y = radius, x = z = 0). When phi = PI/2, the camera is at the horizon (y = 0).
Camera entity setup
The camera entity is spawned by a React component inside the React Three Fiber (R3F) <Canvas>:
typescript
// renderers/camera.tsx
function CameraRenderer(): JSX.Element {
// 1. Spawn camera entity on mount
useEffect(() => {
const entity = world.spawn(
IsCamera,
CameraOrbit,
IsSnapBack, // game mode: pan snaps back to center
);
// 2. Store Three.js camera reference
entity.set(CameraRef, { camera: threeCamera, canvas });
return () => entity.destroy();
}, []);
return null; // no visual output -- camera positioning is in ECS
}The component bridges R3F and ECS. It spawns the entity, stores the Three.js references, and cleans up on unmount. All actual camera logic lives in the ECS systems, not in React.
Implementation Steps
Step 1: Define the camera traits
Add CameraOrbit, IsCamera, IsSnapBack, and CameraRef to src/traits/index.ts if they do not already exist. The trait definitions are listed above in the Key Concepts section. These are already present in the current codebase.
Step 2: Implement the pollCameraInput system
Create src/systems/poll-camera-input.ts. This system queries for entities with IsCamera and CameraOrbit, reads from PointerInput and Keyboard, and updates the target values:
typescript
export function pollCameraInput(world: World): void {
const input = world.get(PointerInput);
const keys = world.get(Keyboard);
world.query(IsCamera, CameraOrbit).updateEach(([orbit]) => {
// Single-finger drag -> orbit
if (input.isDragging && input.pointers.size === 1) {
orbit.targetTheta -= input.deltaX * ORBIT_SENSITIVITY;
orbit.targetPhi -= input.deltaY * ORBIT_SENSITIVITY;
orbit.targetPhi = Math.max(orbit.phiMin, Math.min(orbit.phiMax, orbit.targetPhi));
}
// Scroll -> zoom
if (input.scrollDelta !== 0) {
orbit.targetRadius += input.scrollDelta * ZOOM_SPEED;
orbit.targetRadius = Math.max(MIN_RADIUS, Math.min(MAX_RADIUS, orbit.targetRadius));
}
// Two-finger drag -> pan
if (input.isDragging && input.pointers.size === 2) {
orbit.targetPanOffsetX += input.deltaX * PAN_SENSITIVITY;
orbit.targetPanOffsetZ += input.deltaY * PAN_SENSITIVITY;
}
// Arrow keys (desktop)
if (keys.has('ArrowLeft')) orbit.targetTheta -= ARROW_SPEED;
if (keys.has('ArrowRight')) orbit.targetTheta += ARROW_SPEED;
if (keys.has('ArrowUp')) orbit.targetPhi -= ARROW_SPEED;
if (keys.has('ArrowDown')) orbit.targetPhi += ARROW_SPEED;
});
}Step 3: Implement the updateCamera system
Create src/systems/update-camera.ts. This system lerps current values, handles snap-back, and positions the Three.js camera:
typescript
export function updateCamera(world: World): void {
const input = world.get(PointerInput);
world.query(IsCamera, CameraOrbit, CameraRef).updateEach(([orbit, ref], entity) => {
if (!ref.camera) return;
// Lerp current toward target
orbit.theta += (orbit.targetTheta - orbit.theta) * LERP_FACTOR;
orbit.phi += (orbit.targetPhi - orbit.phi) * LERP_FACTOR;
orbit.radius += (orbit.targetRadius - orbit.radius) * LERP_FACTOR;
orbit.panOffsetX += (orbit.targetPanOffsetX - orbit.panOffsetX) * LERP_FACTOR;
orbit.panOffsetZ += (orbit.targetPanOffsetZ - orbit.panOffsetZ) * LERP_FACTOR;
// Snap-back: pan drifts to zero when not actively panning
if (entity.has(IsSnapBack) && !input.isDragging) {
orbit.targetPanOffsetX *= (1 - SNAP_LERP);
orbit.targetPanOffsetZ *= (1 - SNAP_LERP);
}
// Find orbit center (player position)
const player = world.queryFirst(IsPlayer, Transform);
const cx = player ? player.get(Transform).position.x : 0;
const cy = player ? player.get(Transform).position.y : 0;
const cz = player ? player.get(Transform).position.z : 0;
// Spherical to Cartesian
const x = cx + orbit.panOffsetX + orbit.radius * Math.sin(orbit.phi) * Math.sin(orbit.theta);
const y = cy + orbit.radius * Math.cos(orbit.phi);
const z = cz + orbit.panOffsetZ + orbit.radius * Math.sin(orbit.phi) * Math.cos(orbit.theta);
ref.camera.position.set(x, y, z);
ref.camera.lookAt(cx + orbit.panOffsetX, cy, cz + orbit.panOffsetZ);
});
}Step 4: Register systems in the frameloop
Add the camera systems to src/frameloop.ts in the correct execution order. pollCameraInput must run before updateCamera, and both should run after updateTime but before terrain systems:
typescript
// frameloop.ts execution order
updateTime(world);
pollCameraInput(world); // reads input, writes targets
// ... other systems ...
updateCamera(world); // lerps and positions cameraStep 5: Create the CameraRenderer component
Create src/renderers/camera.tsx (or verify it exists). This React component mounts inside the R3F <Canvas> and spawns the camera entity. It stores the Three.js camera reference so ECS systems can access it.
Step 6: Handle no-player fallback
During terrain-only testing (before the player entity exists), the camera should orbit around the grid center instead of a player position. The updateCamera system already handles this -- when queryFirst(IsPlayer, Transform) returns null, the fallback center is (0, 0, 0). For better testing, you can compute the grid center from the MapManifest chunk keys and use that as the fallback.
Step 7: Tune sensitivity values
The hardest part of the camera system is tuning the sensitivity and lerp values. Start with these and adjust:
| Parameter | Starting value | Notes |
|---|---|---|
ORBIT_SENSITIVITY | 0.005 | Radians per pixel. Lower for mobile. |
ZOOM_SPEED | 0.1 | Radius change per scroll unit. |
PAN_SENSITIVITY | 0.05 | World units per pixel. |
LERP_FACTOR | 0.1 | Higher = snappier, lower = smoother. |
SNAP_LERP | 0.05 | How fast pan returns to center. |
MIN_RADIUS | 5 | Closest zoom. |
MAX_RADIUS | 200 | Farthest zoom. |
ARROW_SPEED | 0.02 | Radians per frame for keyboard input. |
Test on both desktop (mouse + keyboard) and mobile (touch). Mobile typically needs lower orbit sensitivity because finger drags cover more pixels than mouse drags for the same intended rotation.
Visual Checkpoint
With the camera system active and terrain loaded:
- Drag to orbit -- single finger or mouse drag rotates the camera around the terrain. Horizontal drag spins the view, vertical drag tilts the elevation.
- Scroll to zoom -- mouse wheel or pinch gesture moves the camera closer or farther. The terrain gets bigger/smaller smoothly.
- Two-finger drag to pan -- the view shifts off-center from the player. On release, the view gently drifts back to center (snap-back).
- Smooth animation -- all transitions are lerped. No jerky jumps when starting or stopping a drag.
- The terrain stays in view -- phi clamping prevents the camera from going below the terrain or flipping upside down.
- Arrow keys work -- on desktop, arrow keys rotate and tilt the camera at a steady rate.
- No player required -- if the player entity does not exist yet, the camera orbits around the origin and the terrain is still visible and navigable.
If the camera rotates in the wrong direction, check the sign of the deltaX multiplication in pollCameraInput. If zoom feels inverted, check the sign of the scrollDelta multiplication.
What's Next
We can see the terrain and navigate around it, but there is no concept of "levels" -- the terrain just exists statically. Level Lifecycle defines when levels start, what happens when the player dies, and how terrain clears and regenerates for the next attempt.