Merge Adjacent Static Tiles in Matter.js

Creating individual rigid bodies for every tile in a grid-based level often leads to severe performance degradation and "ghost collisions," where dynamic objects snag on flat, internal seams. Merging adjacent static rectangular tiles into continuous, convex boundary bodies resolves these issues by drastically reducing the overall physics body count and eliminating internal edges. This guide explains how to combine tilemap grids into optimized static colliders for Matter.js using 2D greedy meshing.

The Problem with Individual Tile Bodies

Instantiating a Matter.Bodies.rectangle for each solid tile in a tilemap creates two primary issues:

  1. Internal Edge Snagging: When a moving body slides across two adjacent static bodies with coplanar surfaces, slight numerical inaccuracies can cause the moving body to catch on the seam as if it hit a wall.
  2. Broadphase Overhead: Hundreds or thousands of individual static bodies place unnecessary strain on the broadphase collision detection phase, even when spatial hashing is utilized.

Because Matter.js works natively with convex polygons, merging adjacent tiles into larger rectangular bounding boxes is the most efficient solution. Rectangles are inherently convex, eliminating the need for expensive polygon triangulation or decomposition.

The Greedy Meshing Algorithm

Greedy meshing scans a 2D tile array, finds contiguous horizontal strips of solid tiles, and expands them vertically as far as possible before converting them into a single collider.

The process works as follows:

  1. Traverse the grid row by row, column by column.
  2. When an unvisited solid tile is encountered, determine how far it extends horizontally to establish the width of the rectangle.
  3. Check the subsequent rows directly below that horizontal strip. If the entire segment of identical width is solid and unvisited, increase the height of the rectangle.
  4. Mark all tiles contained within this combined rectangle as visited.
  5. Repeat the scan until the entire grid has been processed.
  6. Convert each resulting rectangle into a static Matter.js body.

Implementation

The following function processes a 2D binary grid (where 1 represents a solid tile and 0 represents empty space) and returns an array of optimized static Matter.js bodies.

function generateMergedTileBodies(grid, tileSize) {
    const rows = grid.length;
    const cols = grid[0].length;
    const visited = Array.from({ length: rows }, () => Array(cols).fill(false));
    const bodies = [];

    for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
            // Skip empty or already processed tiles
            if (grid[r][c] !== 1 || visited[r][c]) {
                continue;
            }

            // Step 1: Determine width along the current row
            let width = 0;
            while (c + width < cols && grid[r][c + width] === 1 && !visited[r][c + width]) {
                width++;
            }

            // Step 2: Determine height by checking identical spans below
            let height = 1;
            let canExpand = true;

            while (r + height < rows && canExpand) {
                for (let k = 0; k < width; k++) {
                    if (grid[r + height][c + k] !== 1 || visited[r + height][c + k]) {
                        canExpand = false;
                        break;
                    }
                }
                if (canExpand) {
                    height++;
                }
            }

            // Step 3: Mark tiles within the merged bounds as visited
            for (let dr = 0; dr < height; dr++) {
                for (let dc = 0; dc < width; dc++) {
                    visited[r + dr][c + dc] = true;
                }
            }

            // Step 4: Calculate center positions and dimensions
            const pixelWidth = width * tileSize;
            const pixelHeight = height * tileSize;
            const x = (c * tileSize) + (pixelWidth / 2);
            const y = (r * tileSize) + (pixelHeight / 2);

            // Step 5: Instantiate static Matter.js rectangle body
            const body = Matter.Bodies.rectangle(x, y, pixelWidth, pixelHeight, {
                isStatic: true,
                friction: 0.1,
                restitution: 0
            });

            bodies.push(body);
        }
    }

    return bodies;
}

Adding Bodies to the Physics Engine

Once the merged bodies are generated, add them in a single batch to the Matter.js composite:

const tileSize = 32;
const levelGrid = [
    [1, 1, 1, 1, 1, 1],
    [1, 0, 0, 0, 0, 1],
    [1, 0, 1, 1, 0, 1],
    [1, 1, 1, 1, 1, 1]
];

const boundaryBodies = generateMergedTileBodies(levelGrid, tileSize);
Matter.Composite.add(engine.world, boundaryBodies);

Alternative: Polygon Decomposition

If your boundary requires non-axis-aligned slopes or continuous perimeter contours rather than discrete grid blocks, you can trace the outer boundary edges of your map using a contour-following algorithm (such as Moore-Neighbor tracing).

Because arbitrary perimeter outlines are typically concave, you must pass the resulting vertex list to Matter.Bodies.fromVertices(x, y, vertexSets, { isStatic: true }). This method automatically invokes the poly-decomp library to break the complex concave perimeter into the minimal set of convex sub-bodies required by the Matter.js physics engine. For pure grid-based systems, however, 2D rectangular greedy meshing remains the faster and more predictable method.