Matter.js Consistent Physics Across Refresh Rates

Running a physics simulation directly tied to the browser’s render cycle leads to inconsistent gameplay, as monitors running at 60Hz, 120Hz, or 144Hz invoke animation frames at drastically different intervals. This article explains how to achieve deterministic and uniform physics behavior in Matter.js by decoupling your physics update loop from the rendering loop using a fixed timestep accumulator.

The Refresh Rate Problem

Browsers typically synchronize screen redraws with requestAnimationFrame, firing at the monitor's native refresh rate. If you call Engine.update(engine) inside this loop without a fixed time delta, the physics engine updates twice as often on a 120Hz monitor compared to a 60Hz monitor. Even if you pass the elapsed time (delta) directly into Engine.update(engine, delta), varying delta values can cause floating-point inaccuracies, tunneling, and non-deterministic behavior.

The Solution: Fixed Timestep with an Accumulator

The standard method to ensure identical physics across all devices is the fixed timestep accumulator pattern. Instead of updating physics based on whatever time elapsed between display frames, you accumulate the elapsed time and consume it in fixed, predictable slices (typically 16.66ms, equivalent to 60 updates per second).

Manual Implementation

To bypass refresh rate variations, handle your own loop instead of relying on default canvas runners:

const engine = Matter.Engine.create();
const fixedDelta = 1000 / 60; // 60 updates per second (approx. 16.66ms)

let lastTime = performance.now();
let accumulator = 0;

function gameLoop(currentTime) {
    requestAnimationFrame(gameLoop);

    let frameTime = currentTime - lastTime;
    lastTime = currentTime;

    // Prevent the "spiral of death" if the tab drops frames or stalls
    if (frameTime > 250) {
        frameTime = 250;
    }

    accumulator += frameTime;

    // Consume time in deterministic steps
    while (accumulator >= fixedDelta) {
        Matter.Engine.update(engine, fixedDelta);
        accumulator -= fixedDelta;
    }

    // Render step goes here (runs at monitor refresh rate)
    renderScene();
}

requestAnimationFrame(gameLoop);

Using Built-in Matter.Runner

If you use the Matter.Runner module rather than a custom game loop, configure it explicitly to run on a fixed step:

const runner = Matter.Runner.create({
    isFixed: true,
    delta: 1000 / 60
});

Matter.Runner.run(runner, engine);

Setting isFixed: true instructs the runner to compute the simulation strictly using the provided delta interval, preventing frame-rate fluctuations from altering velocity, gravity, or collision resolutions.

Avoiding the Spiral of Death

When applying a fixed timestep, a sudden lag spike or backgrounding the browser tab can cause frameTime to spike. If the accumulator becomes too large, the while loop will attempt to run dozens of physics steps in a single frame, causing further lag. Always clamp the maximum accumulated time (such as 250 milliseconds) to preserve responsiveness and prevent the browser from freezing.