Testing Custom Physics Behaviors in Matter.js

This guide explains how to write reliable automated tests for custom physics behaviors in Matter.js using standard JavaScript testing frameworks like Jest or Vitest. It covers decoupling the physics engine from rendering, executing deterministic manual timesteps via Engine.update, testing custom forces and constraints, and handling floating-point assertions to verify physical interactions.

Decoupling the Engine for Headless Testing

Matter.js is modular, meaning the simulation engine (Engine) does not depend on the HTML5 canvas renderer (Render) or the browser's animation loop (Runner). To run automated tests in a Node.js environment:

  1. Import only the core modules (Engine, World, Bodies, Body, Events).
  2. Do not instantiate Matter.Render or Matter.Runner.
  3. Manage the passage of time manually to ensure total determinism across test runs.

Implementing Deterministic Timesteps

Using the standard Runner creates asynchronous, variable timesteps that cause flaky tests. Instead, advance the simulation synchronously using Engine.update(engine, delta) with a fixed delta value (typically 1000 / 60 for 60 FPS).

function stepSimulation(engine, ticks = 1, delta = 1000 / 60) {
  for (let i = 0; i < ticks; i++) {
    Engine.update(engine, delta);
  }
}

Testing Custom Forces and Behaviors

Custom behaviors are typically implemented using engine lifecycle hooks, such as beforeUpdate, or by directly manipulating body velocities and forces.

Consider a custom behavior: a directional wind force applied to all dynamic bodies within a specific zone.

// behavior.js
export function applyWindForce(engine, bodies, forceVector) {
  Events.on(engine, 'beforeUpdate', () => {
    bodies.forEach((body) => {
      if (!body.isStatic) {
        Body.applyForce(body, body.position, forceVector);
      }
    });
  });
}

To test this behavior, construct an isolated test suite:

// behavior.test.js
import { Engine, Bodies, Composite } from 'matter-js';
import { applyWindForce } from './behavior';

describe('Custom Physics: Wind Force Behavior', () => {
  let engine;

  beforeEach(() => {
    engine = Engine.create({ gravity: { x: 0, y: 0 } }); // Zero gravity for isolation
  });

  test('should accelerate bodies along the X-axis over multiple steps', () => {
    const box = Bodies.rectangle(0, 0, 50, 50);
    Composite.add(engine.world, box);

    const windForce = { x: 0.005, y: 0 };
    applyWindForce(engine, [box], windForce);

    // Initial state
    expect(box.position.x).toBe(0);
    expect(box.velocity.x).toBe(0);

    // Advance 10 frames
    stepSimulation(engine, 10);

    // Assert movement and velocity increases
    expect(box.velocity.x).toBeGreaterThan(0);
    expect(box.position.x).toBeGreaterThan(0);
  });
});

Asserting Custom Collision Logic

When testing custom collision triggers (like trampolines, portals, or damage zones), register an event listener on collisionStart and evaluate the state after advancing the simulation until the collision occurs.

test('should trigger a jump impulse when a body enters a launch pad', () => {
  const jumper = Bodies.circle(100, 50, 10);
  const pad = Bodies.rectangle(100, 100, 50, 10, { isStatic: true, label: 'launcher' });
  Composite.add(engine.world, [jumper, pad]);

  Events.on(engine, 'collisionStart', (event) => {
    event.pairs.forEach(({ bodyA, bodyB }) => {
      if (bodyA.label === 'launcher' || bodyB.label === 'launcher') {
        const target = bodyA.label === 'launcher' ? bodyB : bodyA;
        Body.setVelocity(target, { x: 0, y: -15 });
      }
    });
  });

  // Step simulation until contact is made
  stepSimulation(engine, 15);

  expect(jumper.velocity.y).toBeCloseTo(-15, 1);
});

Best Practices for Matter.js Tests