The Game Loop & requestAnimationFrame
Every real-time game runs a loop: read input, update state, render a frame, repeat. To feel smooth at 60 fps the browser gives you a budget of just 16.67 ms per frame. Drive the loop with requestAnimationFrame so it syncs to the display's refresh rate instead of a wall-clock timer.
// Basic requestAnimationFrame loop
let lastTime = 0;
function gameLoop(timestamp: number) {
const deltaMs = timestamp - lastTime;
lastTime = timestamp;
update(deltaMs / 1000); // convert ms → seconds
render();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);setInterval is unaware of the display refresh, so it produces dropped or doubled frames and stutter. requestAnimationFrame fires right before the browser paints — perfectly aligned with the screen.Fixed vs Variable Timestep
A variable timestep advances physics by however long the last frame took — simple, but non-deterministic and unstable for collisions. A fixed timestep decouples update() from render(), stepping physics by a constant dt via an accumulator. This is the industry-standard loop.
// Fixed-timestep accumulator pattern
const FIXED_DT = 1 / 60; // 16.67 ms physics step
let accumulator = 0;
let lastTime = performance.now() / 1000;
function gameLoop() {
const now = performance.now() / 1000;
let frameTime = now - lastTime;
lastTime = now;
// Clamp to prevent "spiral of death" on slow machines
if (frameTime > 0.25) frameTime = 0.25;
accumulator += frameTime;
// Run physics in fixed steps
while (accumulator >= FIXED_DT) {
update(FIXED_DT); // deterministic, always same dt
accumulator -= FIXED_DT;
}
// Interpolate render between last and next physics step
const alpha = accumulator / FIXED_DT;
render(alpha);
requestAnimationFrame(gameLoop);
}| Variable timestep | Fixed timestep |
|---|---|
| dt changes every frame | dt is constant (e.g. 1/60) |
| Non-deterministic physics | Deterministic & reproducible |
| Tunneling at low fps | Stable collisions via sub-steps |
| Trivial to write | Needs an accumulator + interpolation |
frameTime (e.g. to 0.25 s) so a slow frame can never trigger an unbounded catch-up.Delta Time — Frame-Rate Independence
Movement must scale by elapsed time, not by frames. Multiply velocity by dt so an object travels the same distance per second on a 30 fps phone and a 144 fps monitor.
// Frame-rate-independent movement with delta time
function update(dt: number) {
// WRONG — tied to frame rate
// player.x += 5;
// CORRECT — moves at 300 px/sec regardless of fps
player.x += player.speedPxPerSec * dt;
// Rotation: 90 degrees per second
enemy.angle += (Math.PI / 2) * dt;
}Object Pooling — Taming the Garbage Collector
Allocating objects inside the hot loop (bullets, particles, vectors) creates garbage the GC must later collect — and a GC pause shows up as a dropped frame. Reuse objects from a pool instead of allocating and discarding them every frame.
// Simple typed object pool
class Pool<T> {
private free: T[] = [];
constructor(private factory: () => T, private reset: (obj: T) => void) {}
acquire(): T {
return this.free.length > 0
? (this.free.pop() as T) // reuse existing
: this.factory(); // allocate only when exhausted
}
release(obj: T) {
this.reset(obj);
this.free.push(obj); // return to pool — no GC pressure
}
}
// Usage: bullet pool
const bulletPool = new Pool(
() => ({ x: 0, y: 0, vx: 0, vy: 0, active: false }),
(b) => { b.active = false; }
);
function fireBullet(x: number, y: number, vx: number, vy: number) {
const b = bulletPool.acquire();
b.x = x; b.y = y; b.vx = vx; b.vy = vy; b.active = true;
activeBullets.push(b);
}
function destroyBullet(b: Bullet) {
bulletPool.release(b); // back to pool, not GC'd
}Spatial Partitioning — Beating O(n²)
Naive collision detection checks every entity against every other — O(n²) that explodes past a few hundred objects. Partition space into a grid or quadtree so each entity only tests its neighbours.
| Structure | Best for |
|---|---|
| Uniform grid | Evenly sized, evenly distributed entities |
| Quadtree | Clustered or widely varying density |
| Spatial hash | Huge or unbounded worlds |
Batching Draw Calls
Each draw call has fixed CPU→GPU overhead. Hundreds of individual draws starve the frame budget. Group sprites that share a texture and state so the renderer can submit them in one batch.
Profiling & Frame Budget
You can't optimise what you don't measure. Mark each phase with the User Timing API and read it in the DevTools Performance panel. Keep the sum of all phases under 16.67 ms.
// Mark frame budget in DevTools
function gameLoop(timestamp: number) {
performance.mark("frame-start");
performance.mark("update-start");
update(dt);
performance.mark("update-end");
performance.measure("update", "update-start", "update-end");
performance.mark("render-start");
render();
performance.mark("render-end");
performance.measure("render", "render-start", "render-end");
performance.mark("frame-end");
performance.measure("frame", "frame-start", "frame-end");
requestAnimationFrame(gameLoop);
}