Matter.js Continuous Collision Detection for Fast Bullets

High-speed projectiles in 2D physics engines frequently suffer from "tunneling," a phenomenon where an object moves so fast in a single frame update that it passes completely through a thin barrier without triggering a collision. Because Matter.js uses discrete collision detection rather than native continuous collision detection (CCD), developers must implement custom logic to bridge this gap. This article outlines how to implement custom CCD for fast-moving bullets in Matter.js by tracking previous body positions and utilizing raycasting queries to intercept collisions before they are missed.

Understanding the Tunneling Problem

Matter.js updates body positions at fixed time steps. If a bullet travels 100 pixels per frame, and an obstacle is only 20 pixels wide, the bullet can exist at position \(x = 0\) on frame one and \(x = 100\) on frame two. Since the bullet never overlaps with the barrier during either frame check, the discrete solver registers no collision.

The Raycasting Approach

The standard solution for custom CCD is a raycast-based approach:

  1. Store the bullet's starting position at the beginning of the frame update.
  2. Predict or calculate its movement trajectory toward its destination.
  3. Cast a ray along that line segment against target colliders.
  4. If an intersection occurs, clamp the bullet's position to the contact point and trigger the collision behavior manually.

Matter.js includes built-in raycasting via Matter.Query.ray(), which tests an array of bodies against a line segment.

Implementing Custom CCD

To implement CCD, hook into the Matter.js engine update loop using Events.on(engine, 'beforeUpdate', callback).

Step 1: Track Bullets and Valid Targets

Maintain a collection of active bullets and an array of potential obstacles to keep query operations performant:

const bullets = [];
const obstacles = [wall1, wall2, enemyBody];

Step 2: Implement the Update Hook

Check for collisions along the path the bullet will travel during the current tick:

Matter.Events.on(engine, 'beforeUpdate', () => {
    for (let i = bullets.length - 1; i >= 0; i--) {
        const bullet = bullets[i];
        
        // Calculate the current start position and projected end position
        const startPoint = { x: bullet.position.x, y: bullet.position.y };
        const endPoint = {
            x: bullet.position.x + bullet.velocity.x,
            y: bullet.position.y + bullet.velocity.y
        };

        // Perform raycast across potential targets
        // Set rayWidth to match bullet radius/thickness
        const rayWidth = bullet.circleRadius || 2;
        const collisions = Matter.Query.ray(obstacles, startPoint, endPoint, rayWidth);

        if (collisions.length > 0) {
            // Sort collisions by distance to startPoint to handle the closest impact first
            collisions.sort((a, b) => {
                const distA = Math.hypot(a.point.x - startPoint.x, a.point.y - startPoint.y);
                const distB = Math.hypot(b.point.x - startPoint.x, b.point.y - startPoint.y);
                return distA - distB;
            });

            const hit = collisions[0];

            // Move the bullet precisely to the impact location
            Matter.Body.setPosition(bullet, { x: hit.point.x, y: hit.point.y });

            // Trigger custom hit resolution
            handleBulletImpact(bullet, hit.body);

            // Remove bullet from simulation
            Matter.Composite.remove(engine.world, bullet);
            bullets.splice(i, 1);
        }
    }
});

function handleBulletImpact(bullet, targetBody) {
    // Apply damage, spawn particles, or apply impact forces
    Matter.Body.applyForce(targetBody, bullet.position, {
        x: bullet.velocity.x * 0.001,
        y: bullet.velocity.y * 0.001
    });
}

Performance Optimizations

Running raycasts for numerous fast-moving projectiles can become a computational bottleneck. To ensure consistent frame rates: