Deterministic Physics Simulation with Matter.js

This guide explains how to achieve deterministic 2D physics simulations across different platforms and devices using Matter.js. By addressing the core causes of divergence—variable timesteps, engine settings, execution order, and floating-point variations—you will learn the exact steps and code patterns required to guarantee that identical inputs produce identical physics states every time.

1. Decouple Physics from the Render Loop

The default Matter.Runner synchronizes simulation updates with the browser’s requestAnimationFrame. Because frame rates fluctuate between devices and screens, this introduces variable delta times (\(\Delta t\)), making determinism impossible.

To fix this, update the physics engine manually with a fixed timestep using an accumulator loop:

const engine = Matter.Engine.create();
const fixedDelta = 1000 / 60; // 60 updates per second (16.666ms)
let accumulatedTime = 0;
let lastTime = performance.now();

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

  // Prevent spiral of death if a frame takes too long
  accumulatedTime += Math.min(frameTime, 250);

  while (accumulatedTime >= fixedDelta) {
    Matter.Engine.update(engine, fixedDelta);
    accumulatedTime -= fixedDelta;
  }

  // Render logic goes here (interpolate positions if needed)
  requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

By passing a hardcoded fixedDelta directly to Matter.Engine.update, every platform steps the simulation forward by the exact same mathematical duration per tick.

2. Lock Engine Solver Iterations

Matter.js uses iterative solvers for collisions, constraints, and positions. If iteration counts vary or use default heuristics across different environments, precision will diverge. Lock these explicitly during engine initialization:

const engine = Matter.Engine.create({
  positionIterations: 6,
  velocityIterations: 4,
  constraintIterations: 2,
  enableSleeping: false // Sleeping introduces non-deterministic wake-up states
});

Disabling sleeping (enableSleeping: false) is critical for cross-platform lockstep, as resting bodies may wake up on different ticks due to microscopic rounding differences.

3. Enforce Deterministic Object Creation and Array Ordering

Matter.js iterates through internal arrays (such as bodies, pairs, and constraints) sequentially. If objects are added in a different order on two clients, the solver resolves contact constraints in a different sequence, causing divergence.

// Add entities in a predictable, sorted order
const sortedEntities = getEntities().sort((a, b) => a.id - b.id);
sortedEntities.forEach(entity => Matter.Composite.add(engine.world, entity.body));

4. Handle Platform Floating-Point Discrepancies

JavaScript specifies IEEE 754 double-precision floating-point math, but execution can vary slightly across architectures (such as x86 vs. ARM) due to differences in compiler optimizations (like FMA instructions in V8 vs. JavaScriptCore).

For simulations running independently across clients (e.g., lockstep multiplayer):

function quantizeBody(body, precision = 10000) {
  body.position.x = Math.round(body.position.x * precision) / precision;
  body.position.y = Math.round(body.position.y * precision) / precision;
  body.velocity.x = Math.round(body.velocity.x * precision) / precision;
  body.velocity.y = Math.round(body.velocity.y * precision) / precision;
  body.angle = Math.round(body.angle * precision) / precision;
  body.angularVelocity = Math.round(body.angularVelocity * precision) / precision;
}

5. Synchronize Inputs by Tick Number

Never apply forces, impulses, or user inputs inside UI event listeners (mousemove, keydown, touchstart). These events fire at arbitrary, un-synced intervals.

  1. Buffer user inputs alongside the current engine tick index.
  2. Apply the inputs immediately before Matter.Engine.update runs for that specific tick.
  3. Keep the input format standardized across all clients.
let currentTick = 0;
const inputQueue = [];

function stepSimulation() {
  // Process only inputs registered for this exact tick
  const inputsForTick = inputQueue.filter(input => input.tick === currentTick);
  for (const input of inputsForTick) {
    Matter.Body.applyForce(playerBody, playerBody.position, input.force);
  }

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

6. Validate with State Hashing

To ensure determinism remains intact across different devices during development, generate a state hash of your world at fixed intervals:

function getWorldStateHash(world) {
  const bodies = Matter.Composite.allBodies(world);
  let hashString = "";

  for (const b of bodies) {
    hashString += `${b.id}:${b.position.x.toFixed(4)},${b.position.y.toFixed(4)},${b.angle.toFixed(4)};`;
  }

  // Simple string hash algorithm (e.g., djb2)
  let hash = 5381;
  for (let i = 0; i < hashString.length; i++) {
    hash = (hash * 33) ^ hashString.charCodeAt(i);
  }
  return hash >>> 0;
}

Log or compare this hash across platforms at specified tick numbers (e.g., every 600 ticks) to detect and isolate simulation desynchronization.