Building a Fixed-Timestep Game Loop in HTML Canvas

How the DN framework handles timing — the fixed-timestep update/render pattern, delta time, and why it matters for consistent game feel across devices.

canvasgame-loopjavascriptdn-framework
intermediate

Building a Fixed-Timestep Game Loop

Written alongside LoopShoots — the first Arcade game built on the DN framework.

Every game needs a heartbeat. In browser games, that’s the game loop — a function that runs on every frame and handles two jobs: update (move things, check collisions, run logic) and render (draw the current state to the canvas).

Getting this wrong causes stuttery movement, physics that run faster on high-refresh monitors, and input lag. The fixed-timestep pattern solves all of this.


The Problem with requestAnimationFrame Alone

requestAnimationFrame fires roughly 60 times per second, but not exactly. On a 144hz monitor it fires more often. Under CPU load it fires less often. If your update step just runs once per frame, your game literally runs faster on better hardware.


The Fixed-Timestep Solution

Instead of “update once per frame,” you track how much real time has passed and update in fixed-size chunks:

class GameLoop {
  constructor({ fps = 60, updateCallback, renderCallback } = {}) {
    this.dt      = 1 / fps;       // target seconds per update (1/60 ≈ 0.0167s)
    this.accTime = 0;             // accumulated unprocessed time
    this.lastTime = undefined;
    this.updateCB = updateCallback;
    this.renderCB = renderCallback;
  }

  processFrame = (time) => {
    if (this.lastTime === undefined) this.lastTime = time;

    // Accumulate elapsed time (convert ms to seconds)
    this.accTime += (time - this.lastTime) / 1000;
    this.lastTime = time;

    // Run update in fixed steps until we've caught up
    let didUpdate = false;
    while (this.accTime >= this.dt) {
      this.updateCB?.(this.dt);   // pass dt so physics is framerate-independent
      this.accTime -= this.dt;
      didUpdate = true;
    }

    // Only render if something changed
    if (didUpdate) this.renderCB?.();

    requestAnimationFrame(this.processFrame);
  }

  startLoop() {
    requestAnimationFrame(this.processFrame);
  }
}

Using It

const loop = new GameLoop({
  fps: 60,
  updateCallback: (dt) => {
    // dt is always 1/60 — use it for movement
    player.x += player.speed * dt;
  },
  renderCallback: () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    // draw everything
  },
});

loop.startLoop();

Why dt in the Update Callback?

player.speed * dt means “move at speed pixels per second.” With dt = 1/60, that’s speed / 60 pixels per frame — consistent regardless of actual frame rate.


What’s Next

This pattern is the foundation of every game in the DN framework. The next tutorial covers the SceneManager — how to switch between menu, game, and game-over states without spaghetti code.

This tutorial is in progress. Check back when LoopShoots ships.