Matter.js Mouse Interactions in a Web Worker

Running Matter.js inside a Web Worker prevents heavy physics calculations from degrading UI rendering performance, but it isolates the physics engine from the browser's DOM. Because Web Workers lack direct access to DOM events, standard modules like Matter.Mouse and Matter.MouseConstraint cannot listen for pointer inputs directly. Achieving smooth, responsive mouse interactions requires creating a lightweight input-proxy system on the main thread, streaming serialized input states to the worker thread, and updating virtual constraints within the physics loop without introducing latency or frame drops.

The Decoupled Architecture

By default, Matter.js binds DOM listeners directly to an HTMLCanvasElement to update mouse coordinates and resolve dragging constraints. In a multi-threaded architecture, you must split this responsibility into two parts:

  1. The Main Thread (Input Capture): Listens to native pointer events, calculates canvas-relative coordinates, and sends minimal payloads to the worker.
  2. The Worker Thread (Physics Simulation): Maintains a virtual mouse representation and applies forces or updates a MouseConstraint based on the received data.

Capturing and Throttling Main Thread Events

Do not spam worker.postMessage on every raw mousemove event, as excessive messaging overhead can saturate the thread channel and cause noticeable input lag. Instead, capture the latest pointer coordinates and synchronize them using requestAnimationFrame or immediate messaging only when the pointer state changes.

// main.js
const canvas = document.getElementById('physics-canvas');
const worker = new Worker('physics.worker.js');

let mouseState = { x: 0, y: 0, isDown: 0, dirty: false };

function getCanvasCoordinates(e) {
  const rect = canvas.getBoundingClientRect();
  return {
    x: (e.clientX - rect.left) * (canvas.width / rect.width),
    y: (e.clientY - rect.top) * (canvas.height / rect.height)
  };
}

canvas.addEventListener('mousedown', (e) => {
  const coords = getCanvasCoordinates(e);
  mouseState.x = coords.x;
  mouseState.y = coords.y;
  mouseState.isDown = e.buttons;
  worker.postMessage({ type: 'pointerdown', ...coords, buttons: e.buttons });
});

canvas.addEventListener('mousemove', (e) => {
  const coords = getCanvasCoordinates(e);
  mouseState.x = coords.x;
  mouseState.y = coords.y;
  mouseState.dirty = true;
});

window.addEventListener('mouseup', (e) => {
  mouseState.isDown = 0;
  worker.postMessage({ type: 'pointerup' });
});

// Flush movement updates aligned with frame rendering
function syncInput() {
  if (mouseState.dirty) {
    worker.postMessage({
      type: 'pointermove',
      x: mouseState.x,
      y: mouseState.y
    });
    mouseState.dirty = false;
  }
  requestAnimationFrame(syncInput);
}
requestAnimationFrame(syncInput);

Emulating Matter.Mouse in the Worker

Inside the worker, instantiate Matter.Mouse with a dummy object instead of a canvas element. This prevents Matter.js from throwing errors when attempting to run element.addEventListener. You then update the internal coordinates manually whenever an event arrives from the main thread.

// physics.worker.js
importScripts('matter.min.js');

const { Engine, World, Bodies, Mouse, MouseConstraint } = Matter;

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

// Create a mock element to satisfy Matter.Mouse requirements
const dummyElement = {
  addEventListener: () => {},
  removeEventListener: () => {},
  getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 })
};

const mouse = Mouse.create(dummyElement);
const mouseConstraint = MouseConstraint.create(engine, {
  mouse: mouse,
  constraint: {
    stiffness: 0.2,
    render: { visible: false }
  }
});

World.add(world, mouseConstraint);

self.onmessage = (event) => {
  const { type, x, y, buttons } = event.data;

  switch (type) {
    case 'pointerdown':
      mouse.position.x = x;
      mouse.position.y = y;
      mouse.button = buttons === 1 ? 0 : 1; // Map left click to 0
      mouse.buttonIndices[mouse.button] = true;
      break;

    case 'pointermove':
      mouse.position.x = x;
      mouse.position.y = y;
      break;

    case 'pointerup':
      mouse.button = -1;
      mouse.buttonIndices = {};
      break;
  }
};

Optimizing for Zero Input Jitter

  1. Avoid Stale Dragging: Always bind mouseup to the window rather than the canvas. If a user drags outside the canvas boundary and releases the mouse, the physics engine must immediately release the constrained body to prevent physics artifacts.
  2. Coordinate Interpolation: When displaying the dragged body on the main thread, interpolate body positions between physics ticks. If the worker runs at 60Hz and the display runs at 120Hz, linear interpolation (lerp) between the two latest state snapshots ensures the object tracks smoothly beneath the cursor.
  3. Use SharedArrayBuffer for High-Frequency Sync: If postMessage serialization causes micro-stutters under heavy simulation loads, use a SharedArrayBuffer containing a fixed-size Float32Array for the mouse position and button state. The worker reads directly from shared memory at the start of every physics tick, eliminating message deserialization latency entirely.