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:
- Calculate the projectile's target position for the upcoming frame based on its current velocity.
- Cast a ray from the current center of the projectile to the anticipated target point.
- Pass the projectile's bounding width to
Matter.Query.rayto approximate a swept circle or box. - 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
- Ray Width for Extruded Shapes: Passing a
rayWidthargument toQuery.raysimulates a capsule sweep. For non-zero-radius projectiles like bullets or cannonballs, setrayWidthto the diameter of the projectile to catch edge-clipping scenarios. - Obstacle Filtering: Avoid querying against dynamic
entities that should not block the projectile, or against the projectile
itself. Maintain a distinct array of valid collision targets rather than
querying the entire
Composite.allBodies(engine.world)array. - Sub-stepping and Time Delta: If using variable
delta updates, compute the projected vector as
Vector.mult(projectile.velocity, delta / 1000)instead of purevelocityto account for variable frame lengths.