Beginner / Collision
Remove Consumed Objects before They Score Twice

A gem that touches the paddle remains near it for more than one animation frame. If every overlapping frame awards another point, one visible gem can count several times. Object lifetime is therefore part of scoring correctness, not just a memory-management concern.
What you'll build
We will follow the collector's object lifecycle from creation through contact to removal. Then we will compare its simple filter-based cleanup with an optional in-place approach. The existing implementation favors readability over an unmeasured optimization.
Prerequisites
Understand arrays and the collector update loop. Objects contain positions and a hazard flag. They are plain data; the array does not hold browser elements or scheduled callbacks, so removing an entry is enough to stop future updates of that entry.
Give contact a one-time effect
When contact occurs, the collector moves the object to HEIGHT + 60. The later filter removes objects whose y is no longer within the permitted range. The moved object is therefore gone before the next update can detect another contact.
function update(dt, direction = 0) {
if (state.finished) return;
state.time += dt;
state.spawn += dt;
state.x = clamp(state.x + direction * 500 * dt, 45, WIDTH - 45);
if (state.spawn >= 0.52 - difficulty * 0.035) {
state.spawn = 0;
state.objects.push({ x: 40 + random() * (WIDTH - 80), y: -25,
bad: random() < 0.23 + difficulty * 0.015 });
}
for (const object of state.objects) {
object.y += (150 + difficulty * 25 + state.time * 1.2) * dt;
if (object.y > HEIGHT - 78 && object.y < HEIGHT - 25 &&
Math.abs(object.x - state.x) < 52) {
object.y = HEIGHT + 60; // Consume once, then remove below.
if (object.bad) state.lives--;
else state.score++;
}
}
state.objects = state.objects.filter(object => object.y < HEIGHT + 30);
state.won = state.score >= goal;
state.finished = state.won || state.lives <= 0 || state.time >= 75;
}
This is a compact consumption marker. An alternative would add an explicit consumed flag and filter on that flag. Either approach needs the same invariant: a handled object must not remain eligible for another scoring event.
Using a position marker keeps the teaching example small, but it couples consumption with offscreen cleanup. If you add more lifecycle states, such as an explosion animation, a named state may communicate intent better than several special y values.
Remove objects that were missed
The same filter removes objects that naturally fall below the arena. A missed gem does not currently remove a life. This shows why removal should not itself imply a scoring penalty. The reason for removal and the rule outcome are different facts.
An array that never removes missed objects grows throughout the round. Even when old objects are invisible, the update and draw loops may still visit them. For these short demos the count might remain modest, but cleanup is still necessary for bounded work and understandable state.
Avoid splicing through a forward loop
Deleting an array element with splice while iterating forward changes later indexes. If the loop increments normally, it can skip the element that shifted into the removed position. This bug often appears when two adjacent objects leave the screen together.
The current filter creates a new array containing only surviving objects. It avoids index adjustment and makes the condition easy to read. The cost is a new array allocation, which is reasonable until profiling shows that allocation to be a meaningful bottleneck.
If you choose in-place compaction, use separate read and write indexes rather than modifying the collection unpredictably while scanning it:
let writeIndex = 0;
for (let readIndex = 0; readIndex < objects.length; readIndex++) {
const object = objects[readIndex];
if (object.y < limit) objects[writeIndex++] = object;
}
objects.length = writeIndex;
This is an optional replacement, not code currently required by the game. It retains object order and avoids allocating the survivor array, but introduces more bookkeeping that should earn its place through measurement.
Keep drawing out of the lifecycle decision
A renderer should not delete objects merely because it decides not to paint them. Otherwise changing a visual culling rule could alter scoring or simulation. The collector updates and filters the model first, and its draw method reads the resulting objects.
That division also helps tests. A rule test can create an object in the contact band, call update, and inspect score and array length without a Canvas context. A screenshot checks appearance, but it cannot reliably prove that an invisible consumed object has actually left the model.
Test repeated and adjacent cases
Insert one catchable gem, update once, then update again. Score must increase only once. Repeat with a hazard and verify only one life is removed. Add two adjacent offscreen entries and ensure both disappear; this catches the common forward-splice mistake.
Also place one valid survivor between removed entries and verify its position and order remain intact. A cleanup optimization that keeps the right count but substitutes the wrong object is still incorrect.
Next steps
Compare the cleanup code in Collector with Lane Racer. Both need one-time contact and bounded object arrays, even though their scoring differs. Preserve those invariants if you later add animations, pooling, or a more sophisticated collision method.