Offload Matter.js Physics Calculations to a Web Worker

Offloading Matter.js physics calculations to a Web Worker prevents heavy simulation logic from blocking the main thread, ensuring smooth UI interactions and consistent rendering frame rates. This guide explains how to decouple the Matter.js engine from the DOM, run the physics simulation inside a background worker, and synchronize body coordinates back to the main thread for rendering on an HTML5 Canvas.

The Architectural Separation

By default, Matter.js includes a built-in renderer (Matter.Render) and runner (Matter.Runner) designed to operate on the main browser thread alongside the DOM. Because Web Workers lack DOM access, you must split the workflow:

  1. The Web Worker (Physics Thread): Instantiates Matter.Engine, creates rigid bodies in Matter.Composite, and runs the update cycle via Matter.Engine.update(). It extracts the essential transform data (positions and angles) and posts it back to the main thread.
  2. The Main Thread (Render Thread): Captures user inputs, forwards interaction events to the worker, receives updated body coordinates, and renders the entities using requestAnimationFrame on a <canvas> element or custom rendering library.

Setting Up the Physics Worker

Inside your worker file (e.g., physics.worker.js), import Matter.js. Since you cannot use Matter.Render here, you only need the physics modules: Engine, Bodies, Composite, and Body.

// physics.worker.js
importScripts('https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js');

const { Engine, Bodies, Composite } = Matter;

const engine = Engine.create();
const world = engine.world;

// Add bodies to the world
const ground = Bodies.rectangle(400, 600, 810, 60, { isStatic: true, id: 1 });
const box = Bodies.rectangle(400, 200, 80, 80, { id: 2 });
Composite.add(world, [ground, box]);

const fixedDelta = 1000 / 60;

function tick() {
  Engine.update(engine, fixedDelta);

  // Extract minimal state for rendering
  const bodies = Composite.allBodies(world).map(body => ({
    id: body.id,
    x: body.position.x,
    y: body.position.y,
    angle: body.angle
  }));

  // Send state to the main thread
  self.postMessage({ type: 'UPDATE', bodies });
}

// Run the physics loop at 60Hz
setInterval(tick, fixedDelta);

// Handle messages from the main thread (inputs, additions)
self.onmessage = (event) => {
  const { type, payload } = event.data;
  if (type === 'APPLY_FORCE') {
    const body = Composite.get(world, payload.id, 'body');
    if (body) {
      Matter.Body.applyForce(body, body.position, payload.force);
    }
  }
};

Implementing the Main Thread Renderer

The main thread spawns the worker, caches the incoming physics transformations, and handles painting using requestAnimationFrame.

// main.js
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

const worker = new Worker('physics.worker.js');
let renderBodies = [];

// Receive updated positions from the worker
worker.onmessage = (event) => {
  if (event.data.type === 'UPDATE') {
    renderBodies = event.data.bodies;
  }
};

// Render loop decoupled from the physics update rate
function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  for (const body of renderBodies) {
    ctx.save();
    ctx.translate(body.x, body.y);
    ctx.rotate(body.angle);

    // Draw entity based on ID or configuration
    if (body.id === 1) {
      ctx.fillRect(-405, -30, 810, 60); // Ground
    } else if (body.id === 2) {
      ctx.fillRect(-40, -40, 80, 80);   // Box
    }

    ctx.restore();
  }

  requestAnimationFrame(render);
}

requestAnimationFrame(render);

// Forward user interaction to worker
canvas.addEventListener('click', (e) => {
  worker.postMessage({
    type: 'APPLY_FORCE',
    payload: { id: 2, force: { x: 0, y: -0.05 } }
  });
});

Performance and Serialization Considerations

To maximize throughput and avoid garbage collection pauses when running physics in a worker: