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:
- Store the bullet's starting position at the beginning of the frame update.
- Predict or calculate its movement trajectory toward its destination.
- Cast a ray along that line segment against target colliders.
- 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:
- Filter Target Bodies: Never pass
engine.world.bodiesdirectly intoMatter.Query.ray(). Only include collidable, static geometry or target entities. - Collision Categories: Use bitmasks
(
body.collisionFilter.categoryandmask) to bypass irrelevant bodies before running manual checks. - Sub-stepping: For complex scenes where bullets bounce rather than vanish on impact, perform sub-stepping by dividing the velocity vector into smaller increments, testing collisions sequentially until the full frame delta is resolved.