Custom Broad-Phase Detector in Matter.js
This guide explains how to build and integrate a custom broad-phase collision detector in Matter.js to replace the engine's default broad-phase logic. You will learn the mechanics of collision detection in Matter.js, how to implement a spatial partitioning strategy such as a spatial hash grid, and how to hook your custom detector into the engine update loop to optimize collision detection for scenes with high body counts.
Understanding Matter.js Collision Detection
Matter.js splits collision detection into two primary phases:
- Broad-phase: Quickly eliminates pairs of bodies that are too far apart to collide using axis-aligned bounding boxes (AABB).
- Narrow-phase: Performs exact geometric tests (using the Separating Axis Theorem, or SAT) on the remaining pairs to determine actual contacts, penetration depth, and collision normals.
In Matter.js, the Matter.Detector module coordinates
this process. By default, Detector.collisions() checks
pairs and tests them using SAT.collides(). Replacing the
broad-phase involves intercepting pair generation so only viable
candidates reach the computationally expensive narrow-phase step.
Step 1: Implement a Spatial Data Structure
A custom broad-phase relies on an efficient spatial data structure, such as a Spatial Hash Grid, Quadtree, or R-Tree. Below is a lightweight Spatial Hash Grid implementation designed for fast broad-phase lookups:
class SpatialHashGrid {
constructor(cellSize) {
this.cellSize = cellSize;
this.grid = new Map();
}
_getKey(x, y) {
const gx = Math.floor(x / this.cellSize);
const gy = Math.floor(y / this.cellSize);
return `${gx}:${gy}`;
}
clear() {
this.grid.clear();
}
insert(body) {
const { min, max } = body.bounds;
const startX = Math.floor(min.x / this.cellSize);
const endX = Math.floor(max.x / this.cellSize);
const startY = Math.floor(min.y / this.cellSize);
const endY = Math.floor(max.y / this.cellSize);
for (let x = startX; x <= endX; x++) {
for (let y = startY; y <= endY; y++) {
const key = `${x}:${y}`;
if (!this.grid.has(key)) {
this.grid.set(key, []);
}
this.grid.get(key).push(body);
}
}
}
getCandidates() {
const pairs = new Set();
const candidatePairs = [];
for (const cell of this.grid.values()) {
const len = cell.length;
if (len < 2) continue;
for (let i = 0; i < len; i++) {
for (let j = i + 1; j < len; j++) {
const bodyA = cell[i];
const bodyB = cell[j];
// Skip pairs that should not collide
if (bodyA.isStatic && bodyB.isStatic) continue;
if (!Matter.Detector.canCollide(bodyA.collisionFilter, bodyB.collisionFilter)) continue;
// Ensure unique pairs regardless of order
const id = bodyA.id < bodyB.id ? `${bodyA.id}_${bodyB.id}` : `${bodyB.id}_${bodyA.id}`;
if (!pairs.has(id)) {
pairs.add(id);
candidatePairs.push([bodyA, bodyB]);
}
}
}
}
return candidatePairs;
}
}Step 2: Create the Custom Broad-Phase Function
Create a replacement for the default collision detection logic. Your
detector will populate the spatial structure with the engine's active
bodies, extract potential collision pairs, and then delegate to
Matter.SAT.collides to resolve narrow-phase collisions.
const grid = new SpatialHashGrid(100); // 100px cell size
function customBroadphaseDetector(engine) {
const bodies = Matter.Composite.allBodies(engine.world);
const collisions = [];
grid.clear();
// Populate spatial structure
for (let i = 0; i < bodies.length; i++) {
const body = bodies[i];
if (body.parts.length > 1) {
// For compound bodies, insert sub-parts
for (let p = 1; p < body.parts.length; p++) {
grid.insert(body.parts[p]);
}
} else {
grid.insert(body);
}
}
// Get candidate pairs from broad-phase
const candidatePairs = grid.getCandidates();
// Execute narrow-phase checks on candidate pairs only
for (let i = 0; i < candidatePairs.length; i++) {
const [bodyA, bodyB] = candidatePairs[i];
// Bounding box pre-check
if (Matter.Bounds.overlaps(bodyA.bounds, bodyB.bounds)) {
const collision = Matter.SAT.collides(bodyA, bodyB);
if (collision && collision.collided) {
collisions.push(collision);
}
}
}
return collisions;
}Step 3: Override the Default Matter.js Detector
You can attach the custom detector directly by overriding
Matter.Detector.collisions. This ensures Matter's collision
pipeline processes your pairs seamlessly:
// Preserve original narrow-phase collision handling while replacing broad-phase
Matter.Detector.collisions = function(detector) {
return customBroadphaseDetector(detector.engine || engine);
};Alternatively, if you run a custom game loop using
Matter.Engine.update(), you can bypass the default detector
entirely by disabling the detector on the engine and managing collision
events manually using
Matter.Events.trigger(engine, 'collisionStart', { pairs: collisions }).
Optimization Considerations
- Cell Size Tuning: Set grid cell sizes relative to the average size of your dynamic bodies. Setting cells too small increases hashing overhead; setting them too large increases narrow-phase checks.
- Static Bodies: If your scene contains static bodies that never move, cache their grid assignments rather than reinserting them on every tick.
- Compound Bodies: Ensure parts of compound bodies are handled properly so that internal sub-shapes generate correct collision normals.