Lesson 39 - Three.js Game Development

Three.js Delta Time, Fixed Steps, and a Game Loop You Can Trust

The renderer can draw at 144 FPS while gameplay still behaves incorrectly. We separate elapsed time, simulation time, and rendering so movement stays predictable across devices.

IntermediateThree.js65-90 minutesGame loopDelta time

1. Cap delta after a stalled tab

A tab can return after seconds of inactivity. Feeding that full gap into movement launches objects through walls. Clamp the frame delta before any gameplay update.

const clock = new THREE.Clock();

function frame() {
  const dt = Math.min(clock.getDelta(), 1 / 20);
  updatePlayer(dt);
  updateCamera(dt);
  renderer.render(scene, camera);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

How this code works

Execution flow

Clock measures elapsed seconds since the previous frame, Math.min limits an abnormal gap, and all simulation systems receive the capped value before rendering. The next animation request then continues from a clean clock sample.

Key Three.js decisions

THREE.Clock provides seconds in the format movement equations expect, while a 1/20-second cap limits one update to fifty milliseconds. The cap sacrifices impossible catch-up time to preserve collision and player safety after a suspended tab.

Adapt it

Use a tighter cap for fast platformers and a slightly larger one for slow strategy cameras. Keep real wall-clock timers separately if quests or cooldowns must continue while the tab is hidden; simulation time and calendar time solve different problems.

Watch out

Without a cap, returning to a backgrounded tab can move a player through walls or age every projectile instantly. A cap alone does not make unstable physics deterministic, so use fixed stepping when contact resolution needs a constant interval.

2. Run physics at a fixed cadence

Accumulate real time and consume it in fixed slices. Physics then sees a stable timestep even when rendering alternates between fast and slow frames.

const FIXED_STEP = 1 / 60;
let accumulator = 0;

function updateFrame(dt) {
  accumulator = Math.min(accumulator + dt, FIXED_STEP * 5);
  while (accumulator >= FIXED_STEP) {
    simulate(FIXED_STEP);
    accumulator -= FIXED_STEP;
  }
  const alpha = accumulator / FIXED_STEP;
  renderInterpolated(alpha);
}

How this code works

Execution flow

Real frame time enters an accumulator, which is capped to prevent an unlimited backlog. The while loop consumes exact 1/60-second simulation slices, and the remaining fraction becomes an interpolation alpha for smooth rendering.

Key Three.js decisions

A fixed timestep makes forces, collision, and moving bodies reproducible independently of display refresh. The accumulator bridges variable requestAnimationFrame timing, while interpolation can display a position between the previous and current simulation states.

Adapt it

Use this structure for Cannon-es worlds, deterministic moving platforms, or replay systems. A turn-based game may not need it, while a racing game can run vehicle physics at 60 Hz and render camera effects at the display rate.

Watch out

If simulation work takes longer than real time, the while loop can enter a spiral of death. Limit accumulated steps, lower world complexity, and measure update duration; never solve overload by allowing hundreds of catch-up steps in one frame.

3. Make moving platforms deterministic

Derive cyclic movement from simulation time rather than repeatedly adding frame-scaled offsets. The platform returns to exactly the same path after every cycle.

let simulationTime = 0;
const origin = new THREE.Vector3(4, 3, -8);

function updatePlatform(dt) {
  simulationTime += dt;
  platform.position.copy(origin);
  platform.position.x += Math.sin(simulationTime * 1.4) * 5;
}

How this code works

Execution flow

Simulation time increases by delta, the platform resets to its stored origin, and a sine function computes the complete horizontal offset for that instant. The position is derived from time rather than accumulated from the previous frame.

Key Three.js decisions

Vector3.copy restores an exact reference point before motion, and Math.sin supplies a bounded periodic value between minus one and one. This makes amplitude and speed explicit and prevents numerical drift across long sessions.

Adapt it

Change the axis for elevators, combine sine and cosine for circular platforms, or sample a Curve for authored routes. Store previous platform position as well when a standing player must inherit the platform's frame displacement.

Watch out

Adding velocity to the existing position every frame can drift and produce different endpoints at different frame rates. If riders slide, the platform visuals and collision body may be updating in different phases of the simulation loop.

4. Pause without a giant recovery step

Stop consuming simulation time while hidden and reset the clock when play resumes. This avoids both unfair deaths and a burst of catch-up work.

let paused = false;
document.addEventListener("visibilitychange", () => {
  paused = document.hidden;
  clock.getDelta();
});

function frame() {
  const dt = Math.min(clock.getDelta(), 0.05);
  if (!paused) updateFrame(dt);
  renderer.render(scene, camera);
  requestAnimationFrame(frame);
}

How this code works

Execution flow

The visibility listener records whether the document is hidden and immediately drains the clock's stale delta. Each frame still renders, but simulation updates run only while the page is active, so resuming starts with a fresh short interval.

Key Three.js decisions

The Page Visibility API describes browser suspension more accurately than window focus alone. Keeping rendering separate from update state also lets a pause overlay remain visible without advancing hazards, physics, or cooldowns.

Adapt it

Add manual pause, menu, and lost-focus reasons to one pause-state set rather than a single boolean. Multiplayer games should keep network time separate and resynchronize authoritative state instead of freezing the remote world.

Watch out

If the clock is not reset on resume, its first delta includes the entire hidden duration. If several listeners independently pause and resume the game, one event can accidentally unpause another reason, so centralize pause ownership.

5. Real Supagames example

Production source: biggames/valley-racer/game.js

Play the published example: Valley Racer

Valley Racer separates per-frame rendering from vehicle and world updates, while the shared environment engine coordinates timed subsystems. These examples extract the timing rules without the racing-specific systems.

The teaching snippets deliberately isolate one Three.js responsibility at a time. The production file also handles integration, lifecycle, quality settings, and game-specific state, so read it after the smaller examples make sense.

6. Common mistakes

  • Creating geometry, materials, vectors, or arrays inside the frame loop and causing avoidable garbage collection pauses.
  • Treating the Three.js scene graph as the only source of gameplay state instead of keeping explicit JavaScript state.
  • Testing only on a fast desktop and leaving pixel ratio, shadows, and object counts uncapped on mobile devices.

7. Build checklist

  • Serve ES modules through HTTP during development instead of opening the page through file://.
  • Test resize, pause, restart, and cleanup paths as carefully as the first successful frame.
  • Use browser performance tools and an on-screen FPS sample before guessing where a slowdown comes from.
  • Verify keyboard, pointer, and touch controls without letting game shortcuts break forms or page navigation.
Previous: Lighting and shadows Next: Keyboard, pointer, and mobile input