Spatial Partitioning in Matter.js for Large Worlds

This article explains how to dramatically boost performance in large-scale Matter.js simulations by implementing spatial partitioning techniques. When a physics simulation contains thousands of bodies, standard broadphase collision detection slows down significantly due to unnecessary pairwise checks. By structuring the physics space into uniform grids, quadtrees, or spatial hash grids, you can ensure that the engine only tests collisions between proximate bodies, keeping frame rates high and CPU overhead low.

The Bottleneck in Large Matter.js Worlds

Matter.js handles collision detection in two stages: the broadphase (identifying which bodies might be colliding) and the narrowphase (calculating exact contact points and responses).

By default, Matter.js uses an internal grid and bounding box checks via its Detector module. While sufficient for small to medium scenes, simulations with thousands of dynamic bodies—or expansive worlds with sparse populations—suffer from performance degradation. Without proper spatial partitioning, the broadphase spends excessive CPU cycles testing bodies that are too far apart to ever interact.

Choosing a Spatial Partitioning Strategy

Depending on your world’s design, choose the structure that best matches your object distribution:

  1. Uniform Grid (or Spatial Hash Grid): Divides space into fixed-size square cells. Fast \(O(1)\) lookups and insertions make it ideal for evenly distributed entities of roughly similar sizes.
  2. Quadtree: Recursively divides 2D space into four quadrants based on entity density. Ideal for vast, unevenly populated worlds where clusters of objects exist alongside large expanses of empty space.

For most continuous 2D games and simulations, a Spatial Hash Grid is the preferred choice because it accommodates infinite or unbounded worlds without requiring dynamic tree rebalancing.

Implementing a Spatial Hash Grid Broadphase

To integrate spatial partitioning with Matter.js, you filter collision candidate pairs before they reach the engine's narrowphase solver.

1. Define the Spatial Hash Structure

Create a hash grid that maps cell coordinates to arrays of physics bodies based on their bounding boxes (body.bounds).

class SpatialHashGrid {
  constructor(cellSize) {
    this.cellSize = cellSize;
    this.grid = new Map();
  }

  _hash(x, y) {
    const cellX = Math.floor(x / this.cellSize);
    const cellY = Math.floor(y / this.cellSize);
    return `${cellX}:${cellY}`;
  }

  clear() {
    this.grid.clear();
  }

  insert(body) {
    const minX = Math.floor(body.bounds.min.x / this.cellSize);
    const maxX = Math.floor(body.bounds.max.x / this.cellSize);
    const minY = Math.floor(body.bounds.min.y / this.cellSize);
    const maxY = Math.floor(body.bounds.max.y / this.cellSize);

    for (let x = minX; x <= maxX; x++) {
      for (let y = minY; y <= maxY; y++) {
        const key = `${x}:${y}`;
        if (!this.grid.has(key)) {
          this.grid.set(key, []);
        }
        this.grid.get(key).push(body);
      }
    }
  }

  getPotentialPairs() {
    const pairs = new Set();

    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 are both static or sleeping
          if ((bodyA.isStatic || bodyA.isSleeping) && (bodyB.isStatic || bodyB.isSleeping)) {
            continue;
          }

          const pairKey = bodyA.id < bodyB.id 
            ? `${bodyA.id}_${bodyB.id}` 
            : `${bodyB.id}_${bodyA.id}`;

          pairs.add(pairKey);
        }
      }
    }
    return pairs;
  }
}

2. Hooking into the Engine Cycle

Instead of replacing the core engine, use spatial partitioning to dynamically control which bodies Matter.js actively simulates.

Update the spatial grid every tick using the beforeUpdate event:

const grid = new SpatialHashGrid(128); // Cell size should typically be 2-3x the size of average bodies

Matter.Events.on(engine, 'beforeUpdate', () => {
  grid.clear();

  const bodies = Matter.Composite.allBodies(engine.world);
  for (let i = 0; i < bodies.length; i++) {
    grid.insert(bodies[i]);
  }
});

3. Chunk-Based Activation (Viewport Culling)

In massive maps, bodies outside the active viewport or player radius should not participate in collision updates at all. Use the spatial structure to enable or disable simulation dynamically:

Best Practices for Massive Matter.js Worlds