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:
- Import only the core modules (
Engine,World,Bodies,Body,Events). - Do not instantiate
Matter.RenderorMatter.Runner. - 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
- Floating-Point Tolerances: Physics engines
accumulate precision drift. Always use matchers like
toBeCloseTo()instead of strict equality (toBe()) when validating coordinates, speed, or angles. - Isolate World Gravity: Set
engine.gravity.scale = 0or adjustengine.gravity.x / yto zero when testing isolated horizontal forces or specific mechanics. - Avoid Shared State: Create fresh
EngineandWorldinstances inbeforeEachhooks to prevent accumulated forces, velocity, or constraints from leaking across tests.