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:
- 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.
- 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:
- Collision Filtering: Set
body.collisionFilter.mask = 0on distant bodies to instantly skip them during collision routines while retaining their world coordinates. - Sleeping State: Force off-screen bodies to sleep by
setting
Matter.Sleeping.set(body, true). - Dynamic World Management: Add bodies to
engine.worldonly when their partition cell intersects with active chunks, and remove them viaMatter.Composite.remove(engine.world, body)when they leave.
Best Practices for Massive Matter.js Worlds
- Enable Engine Sleeping: Always set
engine.enableSleeping = true. Matter.js will automatically stop computing resting bodies, drastically reducing the number of dynamic entities that need spatial re-hashing. - Tune Cell Size: If the cell size is too small, large bodies span multiple cells, increasing hash map overhead. If it is too large, too many bodies fall into a single cell, degrading performance back to \(O(N^2)\). Set cell dimensions to roughly double the diameter of your average body.
- Separate Static Geometry: Avoid querying static
terrain every frame. Insert static bodies into the spatial partition
once, and only re-hash dynamic entities during
beforeUpdate.