A canvas particle trail that uses a time-bucket queue to cap work

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.