Matter.js Raycast Sweeps to Prevent Tunneling

High-speed projectiles in Matter.js frequently suffer from tunneling, an artifact where discrete physics steps allow fast-moving bodies to pass entirely through colliders without triggering a collision. This article explains how to implement predictive continuous collision detection (CCD) using Matter.js raycasting (Matter.Query.ray). By sweeping a ray from a projectile's current position to its projected next position ahead of each physics tick, you can anticipate collisions, position the body precisely at the point of impact, and handle collision responses before tunneling occurs.

The Tunneling Problem

Matter.js integrates physics discretely across fixed time steps. If a projectile moves at a speed greater than the thickness of an obstacle within a single frame, its position from step \(t\) to step \(t+1\) skips cleanly over the obstacle's collider. Because the bodies never overlap at the precise moment collision detection runs, no contact resolution or event triggers.

The Raycast Sweep Strategy

A raycast sweep acts as a predictive collision probe. Before the engine advances:

  1. Calculate the projectile's target position for the upcoming frame based on its current velocity.
  2. Cast a ray from the current center of the projectile to the anticipated target point.
  3. Pass the projectile's bounding width to Matter.Query.ray to approximate a swept circle or box.
  4. If an intersection occurs, clamp the projectile to the contact point, adjust its velocity (e.g., stop, deflect, or destroy), and manually invoke any hit logic.

Implementation

Bind the sweep routine to the beforeUpdate event of your engine instance so corrections apply prior to the broadphase and narrowphase checks.

import Matter from 'matter-js';

const { Events, Query, Vector, Body } = Matter;

function enableRaycastSweep(engine, projectile, obstacles, projectileRadius = 0) {
    Events.on(engine, 'beforeUpdate', () => {
        // Only sweep if the body is actively moving
        const speed = Vector.magnitude(projectile.velocity);
        if (speed === 0) return;

        const startPoint = projectile.position;
        // Project position based on current velocity
        const endPoint = Vector.add(startPoint, projectile.velocity);

        // Perform raycast sweep; rayWidth accounts for projectile dimensions
        const rayWidth = projectileRadius * 2;
        const collisions = Query.ray(obstacles, startPoint, endPoint, rayWidth);

        if (collisions.length > 0) {
            // Sort collisions by distance to find the earliest impact
            collisions.sort((a, b) => {
                const distA = Vector.magnitudeSquared(Vector.sub(a.body.position, startPoint));
                const distB = Vector.magnitudeSquared(Vector.sub(b.body.position, startPoint));
                return distA - distB;
            });

            const firstHit = collisions[0];
            
            // Calculate fractional distance along trajectory to place body at impact site
            // Query.ray returns collision objects containing point of intersection
            const hitPoint = {
                x: (firstHit.bodyA.position.x + firstHit.bodyB.position.x) / 2,
                y: (firstHit.bodyA.position.y + firstHit.bodyB.position.y) / 2
            };

            // Reposition the projectile just at the collision threshold
            Body.setPosition(projectile, hitPoint);

            // Zero out or reflect velocity to stop further movement through the collider
            Body.setVelocity(projectile, { x: 0, y: 0 });

            // Trigger custom impact handling
            onProjectileHit(projectile, firstHit.body);
        }
    });
}

function onProjectileHit(projectile, obstacle) {
    // Custom logic: damage calculation, particle spawning, or body removal
    Matter.Composite.remove(engine.world, projectile);
}

Critical Considerations