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:

  1. Broad-phase: Quickly eliminates pairs of bodies that are too far apart to collide using axis-aligned bounding boxes (AABB).
  2. 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