← All posts

Fixing Timestep Game Loops for 120Hz Displays in JavaScript

Learn how to decouple physics from render frame rates in JavaScript to stop your browser game from breaking on modern 120Hz and 144Hz displays.

You have spent weeks polishing a retro web platformer. The jumping feels crisp, the hitboxes behave, and the wall jumps are absolute perfection on your trusty 60Hz office monitor. Then you open the game on a modern MacBook, an iPad Pro, or a high-refresh Android handset, and disaster strikes: your protagonist launches into the stratosphere at double speed, clips clean through a solid stone floor, and falls endlessly into the canvas void.

Welcome to the 120Hz screen trap.

With ProMotion and 120Hz-to-240Hz monitors now mainstream, relying on naive requestAnimationFrame hooks will break your browser game mechanics. Let us break down how to decouple your physics ticks from monitor refresh rates using a battle-tested fixed timestep accumulator loop in JavaScript.


+--------------------------------------------------------------+
|                    Browser rAF (Variable)                    |
|           (e.g., 60Hz = ~16.6ms, 120Hz = ~8.3ms)             |
+------------------------------+-------------------------------+
                               |
                               v
                     [ Accumulator Tank ]
        (Fills up with delta time between rendered frames)
                               |
                               v
               Is accumulator >= fixed timestep (16.6ms)?
                   /                       \
             [YES]                           [NO]
               |                               |
       Update Physics (60Hz)                   |
       Subtract 16.6ms from tank               |
       (Repeat until tank < 16.6ms)            |
               \                               /
                +--------------+---------------+
                               |
                               v
               Calculate alpha (remainder / timestep)
                               |
                               v
               Render Scene (Interpolate positions)

What Is a Fixed Timestep Loop?

A fixed timestep game loop is an architectural pattern where game logic and physics simulate forward in discrete, uniform slices of time (such as precisely 16.66 milliseconds for a 60Hz simulation), irrespective of how frequently the browser draws frames to the screen.

Instead of letting a wildly variable delta time govern your collision math, you buffer elapsed time in an accumulator and update physics at a constant rhythm, interpolating the render state right before the display refreshes.

Why Variable dt Fails Modern Web Games

In early browser game development tutorials, you often see this standard approach:


function tick(timestamp) {
  const dt = (timestamp - lastTime) / 1000;
  player.x += player.vx * dt; // Seems harmless!
  lastTime = timestamp;
  requestAnimationFrame(tick);
}

This variable delta time approach suffers from two severe flaws:

1. Non-Deterministic Physics: Floating-point rounding differences accumulate differently when dt is 0.0083s (120Hz) compared to 0.0166s (60Hz). Jump heights vary, sliding friction changes, and puzzle games become desynchronised.

2. Tunneling and Collision Breakdown: If a browser tab stutters, drops frames, or receives a chunky 100ms frame tick, moving objects jump massive distances in a single step, bypassing thin collision walls entirely.

Implementing the Fixed Timestep Accumulator Loop

The standard developer consensus across GitHub game engines and technical breakdowns points back to Glenn Fiedler’s foundational "Fix Your Timestep" approach. Here is how to implement an airtight version in vanilla ES6:


class GameEngine {
  constructor() {
    this.targetFps = 60;
    this.fixedStep = 1000 / this.targetFps; // ~16.666ms
    this.maxAccumulator = 250; // Prevent spiral of death
    
    this.accumulator = 0;
    this.lastTime = performance.now();
    
    // Physics state
    this.previousState = { x: 0, y: 0 };
    this.currentState = { x: 0, y: 0 };
  }

  start() {
    requestAnimationFrame(this.loop.bind(this));
  }

  loop(currentTime) {
    let frameTime = currentTime - this.lastTime;
    this.lastTime = currentTime;

    // Clamp huge lag spikes (e.g. background tab switching)
    if (frameTime > this.maxAccumulator) {
      frameTime = this.maxAccumulator;
    }

    this.accumulator += frameTime;

    // Consume accumulated time in fixed chunks
    while (this.accumulator >= this.fixedStep) {
      // Save current state for interpolation
      this.previousState = { ...this.currentState };
      
      this.updatePhysics(this.fixedStep / 1000);
      this.accumulator -= this.fixedStep;
    }

    // Alpha represents how far we are between physics steps
    const alpha = this.accumulator / this.fixedStep;
    this.render(alpha);

    requestAnimationFrame(this.loop.bind(this));
  }

  updatePhysics(dt) {
    // Discrete, reliable 60Hz physics step
    this.currentState.x += 120 * dt; 
  }

  render(alpha) {
    // Interpolate between previous and current state for buttery smoothness
    const renderX = this.currentState.x * alpha + this.previousState.x * (1 - alpha);
    
    // Draw using renderX
    this.draw(renderX);
  }

  draw(x) {
    // Canvas or WebGL drawing logic here
  }
}

Architectural Breakdown: Loop Strategies

StrategyDeterminism120Hz+ BehaviourImplementation Complexity
Naive rAF (no dt)Completely brokenRuns at 2x or 3x speed on fast displaysTrivial
Variable dtPoor (physics drifts)Correct speed, but jittery and prone to tunnelingLow
Fixed Timestep (No Lerp)HighSafe, but micro-stutter occurs on mismatched HzModerate
Fixed Timestep + Alpha LerpAbsolutePerfectly smooth on 60Hz, 120Hz, and 144Hz+Moderate

Key Pitfalls to Avoid

  • The Spiral of Death: If your physics update takes longer to calculate than the duration of fixedStep, your while loop runs more updates than the processor can handle. The accumulator climbs, frame rates collapse, and the browser freezes. Always clamp frameTime with a threshold (e.g., 250ms).
  • Skipping Alpha Interpolation: If you simply draw currentState without blending it with previousState, your game will suffer from visible temporal aliasing (micro-stutter) on 120Hz panels, because the renderer is running twice as fast as the physics ticks.
  • Over-Allocating Garbage: Notice how previousState is cloned above. In high-performance games, avoid instantiating new objects inside the loop every frame. Recycle your vector objects or use typed arrays to prevent garbage collection hiccups.

Decoupling physics from render ticks guarantees that whether your player is visiting your game via a low-end Chromebook or a 165Hz curved gaming monitor, the jumps, speeds, and collisions land exactly as intended.

Thanks for reading. Browse more from the Wobblox blog, or jump straight into all 100 free games.