Deterministic Input Replay in Matter.js

Debugging sporadic physics anomalies in Matter.js can be exceptionally difficult when relying on manual reproduction. By decoupling the physics update from the browser's variable render loop and recording user interactions relative to discrete physics ticks, you can capture the exact sequence of events leading to a glitch. This article explains how to configure a fixed timestep simulation, record user inputs per frame, and accurately replay those inputs to inspect and resolve physics bugs.

1. Enforce a Fixed Timestep

Matter.js instances powered by Matter.Runner dynamically adjust their delta time by default based on monitor refresh rates and performance dips. Because floating-point operations in physics engines depend on the exact integration step size, any variation in delta will alter collision outcomes.

To achieve determinism, discard Matter.Runner or configure manual updates using a fixed timestep:

const engine = Matter.Engine.create();
const FIXED_DELTA = 1000 / 60; // 60 Hz fixed timestep

function physicsTick() {
    Matter.Engine.update(engine, FIXED_DELTA);
}

Ensure this fixed update is called predictably. During debugging or replay, you can advance the simulation one frame at a time manually rather than letting requestAnimationFrame run continuously.

2. Capture Inputs Relative to Engine Ticks

Do not apply user inputs (such as forces, impulses, or position overrides) directly inside browser event listeners (keydown, mousemove, etc.). Instead, queue user actions and bind them to the current physics tick.

Maintain a monotonic tick counter and an input log:

let currentTick = 0;
const inputLog = [];
let pendingInputs = [];

// Event listener stores raw intent
window.addEventListener('keydown', (e) => {
    pendingInputs.push({ type: 'keydown', key: e.key });
});

// Run this right before updating the physics
function processInputsForTick() {
    if (pendingInputs.length > 0) {
        inputLog.push({
            tick: currentTick,
            actions: [...pendingInputs]
        });
        pendingInputs = [];
    }
}

3. Apply Actions Uniformly

Map the captured actions to physical bodies through a centralized dispatcher. This guarantees that both live gameplay and replays manipulate the simulation identically:

function applyAction(action) {
    if (action.type === 'keydown' && action.key === 'Space') {
        Matter.Body.applyForce(playerBody, playerBody.position, { x: 0, y: -0.05 });
    }
}

function stepSimulation() {
    processInputsForTick();
    
    // Apply any actions meant for this tick
    const entry = inputLog.find(log => log.tick === currentTick);
    if (entry) {
        entry.actions.forEach(applyAction);
    }

    Matter.Engine.update(engine, FIXED_DELTA);
    currentTick++;
}

4. Replay and Isolate Glitches

To replay a captured session, export inputLog alongside the initial world state (body dimensions, initial coordinates, and collision filters).

To execute the replay:

  1. Reset the Matter.Engine instance and reconstruct all bodies in their exact starting positions.
  2. Reset currentTick to zero.
  3. Advance the engine frame-by-frame, applying actions from the recorded inputLog when log.tick === currentTick.
function replayStep(recordedLog) {
    const entry = recordedLog.find(item => item.tick === currentTick);
    if (entry) {
        entry.actions.forEach(applyAction);
    }

    Matter.Engine.update(engine, FIXED_DELTA);
    currentTick++;
}

5. Inspecting the Root Cause

Once an input sequence consistently reproduces the glitch, take advantage of the deterministic environment: