Prevent Spiral of Death in Matter.js Physics

When a physics engine falls behind real time, it attempts to catch up by running multiple simulation steps per frame, which takes longer, causing even more lag in an escalating cycle known as the "spiral of death." This article explains how the spiral of death occurs in Matter.js and provides direct, actionable solutions to stop it, including clamping delta times, capping sub-steps in custom game loops, configuring the built-in runner, and tuning engine iteration settings.

Understanding the Spiral of Death

In physics simulations, updates are calculated using a time delta (\(\Delta t\)). When a frame rate drops—due to garbage collection, background tasks, or heavy rendering—the time since the last frame increases.

If the physics loop tries to simulate all of that elapsed time using fixed timesteps, it must execute multiple simulation ticks inside a single render frame. If calculating those ticks takes longer than the available frame budget, the subsequent frame receives an even larger \(\Delta t\). The engine attempts even more ticks, slowing the frame rate further until the tab completely freezes or crashes.

Method 1: Clamping the Maximum Frame Delta

The most effective way to eliminate the spiral of death is to set a hard limit on the maximum delta time allowed in a single frame. Any elapsed time exceeding this cap is simply discarded. The physics will visually slow down during intense lag spikes instead of freezing the application.

If you are using a custom requestAnimationFrame loop, clamp the delta before passing it to Matter.Engine.update:

let lastTime = performance.now();
const MAX_FRAME_TIME = 100; // Cap at 100ms (equivalent to 10 FPS)

function gameLoop(currentTime) {
    let delta = currentTime - lastTime;
    lastTime = currentTime;

    // Prevent spiral of death by capping the delta
    if (delta > MAX_FRAME_TIME) {
        delta = MAX_FRAME_TIME;
    }

    Matter.Engine.update(engine, delta);
    requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

Method 2: Capping Sub-Steps in a Fixed Timestep Loop

For deterministic simulations, a fixed timestep accumulator pattern is standard practice. To prevent the spiral of death in this setup, restrict the maximum number of physics steps permitted per render frame.

let accumulator = 0;
let lastTime = performance.now();
const FIXED_DELTA = 1000 / 60; // 16.66ms per step
const MAX_SUB_STEPS = 5;       // Maximum 5 steps per render frame

function gameLoop(currentTime) {
    let frameTime = currentTime - lastTime;
    lastTime = currentTime;

    // Prevent massive spikes if the tab was in the background
    if (frameTime > 250) {
        frameTime = 250;
    }

    accumulator += frameTime;

    let steps = 0;
    while (accumulator >= FIXED_DELTA && steps < MAX_SUB_STEPS) {
        Matter.Engine.update(engine, FIXED_DELTA);
        accumulator -= FIXED_DELTA;
        steps++;
    }

    // Discard remaining accumulator time if limit was reached
    if (steps >= MAX_SUB_STEPS) {
        accumulator = 0;
    }

    requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

Method 3: Configuring the Matter.Runner

If you are using Matter.Runner rather than a custom loop, configure its parameters to prevent excessive catch-up steps. The built-in runner handles delta correction internally, but you can enforce fixed pacing and constrain execution:

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

// Start the runner with your engine
Matter.Runner.run(runner, engine);

Ensure you do not run multiple runners simultaneously on the same engine, as this will double execution time and immediately trigger loop degradation.

Method 4: Reducing Computational Overhead

If your simulation routinely pushes hardware limits, reducing the computational cost of each tick prevents lags from compounding:

  1. Enable Sleeping: Set enableSleeping: true on the engine to stop calculating stationary bodies.
  2. Lower Iteration Counts: Decrease engine.positionIterations and engine.velocityIterations from their default values (default is 6 and 4, respectively) to reduce solver work per step.
  3. Broadphase Optimization: Remove off-screen or dead dynamic bodies promptly using Matter.World.remove(engine.world, body).