Unit Testing Matter.js Physics with Jest

This guide demonstrates how to write deterministic unit tests for custom physics behaviors using Jest and headless Matter.js. By running Matter.js directly inside Node.js without a canvas renderer or the browser DOM, you can isolate game mechanics, advance simulation frames manually, and verify properties such as positions, velocities, and collision responses using Jest assertions.

Setting Up the Headless Environment

Matter.js operates independently of the DOM when you bypass its Render and Runner modules. To test custom behaviors, install the required packages:

npm install matter-js
npm install --save-dev jest

Ensure your jest.config.js is set to the Node test environment:

module.exports = {
  testEnvironment: 'node',
};

Implementing Deterministic Stepping

Automated tests must be deterministic. Avoid real-time runners (Matter.Runner) because execution speed variations cause test flakiness. Instead, advance the physics engine manually using Engine.update() with a fixed delta time (such as 16.666ms for 60 FPS).

const advanceFrames = (engine, frames = 1, delta = 1000 / 60) => {
  for (let i = 0; i < frames; i++) {
    Engine.update(engine, delta);
  }
};

Testing a Custom Behavior: Example

Consider a custom behavior function that applies a continuous upward buoyancy force to objects submerged in a designated zone.

// buoyancy.js
const Matter = require('matter-js');

function applyBuoyancy(body, fluidDensity = 0.002) {
  const forceMagnitude = body.mass * fluidDensity;
  Matter.Body.applyForce(body, body.position, { x: 0, y: -forceMagnitude });
}

module.exports = { applyBuoyancy };

To test this behavior, create a test suite that initializes the Engine, adds a dynamic body, applies the behavior across several frames, and asserts the resulting position or velocity:

// buoyancy.test.js
const Matter = require('matter-js');
const { applyBuoyancy } = require('./buoyancy');

const { Engine, World, Bodies } = Matter;

describe('Buoyancy Behavior', () => {
  let engine;
  let body;

  beforeEach(() => {
    // Create a new engine with zero gravity to isolate the custom force
    engine = Engine.create({
      gravity: { x: 0, y: 0, scale: 0 }
    });

    body = Bodies.rectangle(100, 100, 20, 20, { mass: 2 });
    World.add(engine.world, body);
  });

  afterEach(() => {
    World.clear(engine.world, false);
    Engine.clear(engine);
  });

  test('should push the body upward when buoyancy is applied', () => {
    const fixedDelta = 1000 / 60;

    // Simulate 30 frames of the custom force
    for (let i = 0; i < 30; i++) {
      applyBuoyancy(body, 0.05);
      Engine.update(engine, fixedDelta);
    }

    // Body should have moved upward (decreasing Y in Matter.js coordinates)
    expect(body.velocity.y).toBeLessThan(0);
    expect(body.position.y).toBeLessThan(100);
  });
});

Testing Collision Events Headless

Custom logic often relies on collision triggers. You can test collision listeners directly through Matter.js's Events module:

test('triggers custom damage logic on high-velocity collision', () => {
  let damageApplied = false;

  Matter.Events.on(engine, 'collisionStart', (event) => {
    event.pairs.forEach((pair) => {
      const { bodyA, bodyB } = pair;
      if (bodyA.label === 'player' || bodyB.label === 'player') {
        damageApplied = true;
      }
    });
  });

  const player = Bodies.rectangle(100, 100, 20, 20, { label: 'player' });
  const obstacle = Bodies.rectangle(100, 120, 20, 20, { isStatic: true });

  World.add(engine.world, [player, obstacle]);

  // Apply downward velocity directly to force collision
  Matter.Body.setVelocity(player, { x: 0, y: 10 });
  Engine.update(engine, 1000 / 60);

  expect(damageApplied).toBe(true);
});

Best Practices for Matter.js Physics Tests