Appearance
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): voidRuns each frame. Reads PointerInput and Keyboard state, then translates gestures into CameraOrbit target values:
| Input | Maps to |
|---|---|
| Single finger drag / mouse drag | targetTheta, targetPhi |
| Scroll wheel / pinch | targetRadius |
| Two-finger drag | targetPanX, targetPanZ |
| Arrow keys | targetTheta, 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): voidRuns each frame after input polling. Does three things:
- Lerp current values toward targets:
theta += (targetTheta - theta) * lerpFactor - Snap back pan offset if
IsSnapBackis present and the player is not actively panning - 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): voidRegisters 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.ElementThis React component lives inside the R3F <Canvas> and:
- Spawns a camera entity on mount with
IsCamera,CameraOrbit,IsSnapBack - Stores the Three.js camera and canvas references in
CameraRef - 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:
| File | Purpose |
|---|---|
systems/poll-camera-input.ts | Input to CameraOrbit target mapping |
systems/update-camera.ts | Lerping and Three.js camera positioning |
systems/setup-pointer-input.ts | Global event listener registration |
renderers/camera.tsx | React component that spawns the camera entity |
traits/input.ts | CameraOrbit, 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.