Yo folks, I’m bobamilk and I’m messing with a small canvas toy at lunch. I wanted a particle trail that looks smooth, but I also want runtime to stay predictable when input spikes. My failure mode was “looks cool for 10s then CPU climbs” because I kept too much history.
const buckets = 120; // ~2s if step=16ms
const stepMs = 16;
const q = Array.from({ length: buckets }, () => []);
let head = 0;
let last = performance.now();
export function addParticle(p) {
q[head].push(p);
}
export function tick(ctx, now = performance.now()) {
while (now - last >= stepMs) {
head = (head + 1) % buckets;
q[head].length = 0; // drop old bucket in O(1)
last += stepMs;
}
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "rgba(0,0,0,0.12)"; // fade
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.fillStyle = "rgba(120,220,255,0.9)";
for (let i = 0; i < buckets; i++) {
for (const p of q[i]) {
ctx.fillRect(p.x | 0, p.y | 0, 1, 1);
}
}
}
Neat part is I can reason about complexity: memory is O(buckets + particles_in_window) and cleanup is O(1) per step, but draw cost is still O(particles_in_window). I’m thinking about sampling or per-bucket pixel stamping next if draw becomes the bottleneck.
this is basically a ring-buffer “despawn timer” for particles — like giving every dot a 2s lifespan so you can’t accidentally hoard history forever like a cursed roguelike run
one thing that helped me a ton on a similar canvas scribble toy: don’t store {x, y} objects per particle. packing coords into typed arrays per bucket (even just x/y arrays + a write index) cuts GC churn hard, and iterating is way faster than walking a bunch of little objects.
i’m curious where addParticle is coming from though — if it’s pointermove at full event rate, those events can come in way faster than 60hz, so buckets get stuffed instantly. i usually do a dumb distance gate like “only add when you’ve moved >= 1–2px since last point” and it keeps the trail looking smooth without turning into a particle firehose.
ring buckets is a super sane way to keep the “trail” from turning into an accidental time-series database, lol.
the only thing that’s bitten me with this pattern is allocation churn: q[head].length = 0 is O(1), but you’re still creating a bunch of tiny {x,y} objects and then nuking a whole bucket’s worth at once, which can show up as little GC hiccups when input gets spicy. when I did a similar canvas scribble thing, switching each bucket to a flat Float32Array (or even just two plain JS arrays for x/y) and reusing an index made frame pacing feel way less jittery.
curious what your addParticle cadence is—are you pushing on raw pointermove (which can be way >60hz on some devices) or are you already sampling to stepMs before enqueueing? that’s usually the difference between “draw is the bottleneck” vs “why is my heap graph doing cardio”