Why Avoid Mutating Arrays in Matter.js Callbacks
Mutating array lengths directly inside Matter.js event callbacks can
destabilize your physics simulation, cause skipped collision checks, and
trigger unexpected runtime errors. During events such as
collisionStart, collisionActive, or
beforeUpdate, the physics engine is actively traversing its
internal lists of bodies, pairs, and constraints. Altering the size of
these collections mid-iteration disrupts the engine's internal pointers
and state tracking. To maintain simulation integrity and avoid silent
failures, any operations that add or remove physics bodies should be
deferred until the current update step completes.
How Matter.js Iterates Over Collections
Matter.js relies on deterministic loops to process physical interactions. In a typical frame, the engine performs several sequential phases:
- Broadphase and Narrowphase Detection: Finding potential and actual overlapping body pairs.
- Event Dispatching: Triggering callbacks like
collisionStartwith a list of active collision pairs. - Constraint and Velocity Resolution: Calculating impulses and updating body positions.
When you listen to an event such as collisionStart, the
engine passes an event object containing an array of
collision pairs. The engine is either in the middle of looping through
this collection or preparing to read from linked composite arrays
immediately afterward.
The Consequences of Mutating Array Lengths Mid-Callback
Modifying array lengths inside a callback—either by directly splicing
an array or calling
Composite.remove(world, body)—introduces several critical
problems:
- Index Shifting and Skipped Elements: If an array is
being iterated forward with a standard counter and an element is removed
via
splice(), all subsequent elements shift left by one index. The iterator then advances past the next immediate element without processing it, resulting in dropped collision events and missed physics logic. - Undefined Property Access: If the engine or your
custom listener relies on a cached array length, reducing the array size
causes subsequent loop iterations to look for elements that no longer
exist. This frequently results in
TypeError: Cannot read properties of undefinedcrashes. - Corrupted Broadphase and Pair Caches: Matter.js
maintains internal mapping tables (such as
pair.id) to track collisions across frames. Removing a body or pair directly during collision resolution can leave dangling references in the collision detector, preventing new collisions from registering or causing persistent "ghost" collisions.
The Deferred Mutation Pattern
The correct approach to adding or removing bodies during an event callback is to defer the mutation until the engine has finished resolving the current tick.
Instead of removing an entity immediately inside the callback,
collect the target entities in a queue, then remove them during the
afterUpdate event.
const bodiesToRemove = new Set();
// 1. Mark bodies for removal inside the collision callback
Matter.Events.on(engine, 'collisionStart', (event) => {
event.pairs.forEach((pair) => {
if (pair.bodyA.label === 'projectile' || pair.bodyB.label === 'projectile') {
bodiesToRemove.add(pair.bodyA);
bodiesToRemove.add(pair.bodyB);
}
});
});
// 2. Safely mutate the world array after the physics step completes
Matter.Events.on(engine, 'afterUpdate', () => {
if (bodiesToRemove.size > 0) {
bodiesToRemove.forEach((body) => {
Matter.Composite.remove(engine.world, body);
});
bodiesToRemove.clear();
}
});Using a Set instead of an array for the removal queue
automatically handles duplicates if a body is involved in multiple
collisions during the same tick. Processing removals within
afterUpdate guarantees that the physics engine has finished
all loop iterations, position updates, and collision dispatches for the
current frame, ensuring smooth and error-free execution.