Canvas Particle System

Mental model: A frame is not a unit of time. Multiply every change by the seconds the last frame actually took, and your animation looks the same on a 30 Hz laptop and a 144 Hz monitor.

Level: intermediate · about 26 minutes

A thousand dots falling under gravity is the smallest program that forces you to be honest about time and allocation. Both of those habits transfer straight to games, charts, drag interactions and scroll effects. You will finish with a loop you can paste into any animated thing you build, and a HUD that tells you when you have gone over budget.

The lab gives you a stage, four sliders (count, gravity, wind, lifetime), an integration switch (delta time or a fixed 1/60 step), a frame pacing selector that simulates 30 and 120 fps, and a pooling toggle. The chips count what matters: alive, pooled, allocated, reused. Flip integration to fixed and change the pacing, and the same scene moves at a different speed. That is the bug you are here to feel.

The build, decision by decision

  1. Draw in CSS pixels, paint at device resolution A canvas has two sizes: the CSS box and the pixel buffer. Set the buffer to the box times devicePixelRatio, then set one transform per frame so all your maths stays in CSS pixels.
  2. Make the particle boring on purpose Every field is set in the constructor, so the object shape never changes and the engine can keep it in one hidden class. reset re-initialises the same object instead of allocating a new one.
  3. Vectors are pairs of numbers, and that is enough Acceleration changes velocity. Velocity changes position. Both are multiplied by dt, and there is no vector library in the whole lab.
  4. Clamp the delta before you trust it Background the tab and the next frame hands you a gap of seconds. Multiply by that and everything teleports through the floor. Clamp to about 50 ms and take the small inaccuracy instead.
  5. Own the loop with requestAnimationFrame rAF runs just before the next paint and hands you a timestamp. Never animate with setInterval: it does not know about paints, and it keeps firing in a hidden tab. Pausing means cancelling the handle, not setting a flag.
  6. Remove the dead in O(1) Splicing out of the middle of a thousand-element array shifts everything after it, every frame. Swap the last element into the hole and pop. Iterate backwards so the element you swapped in is one you have already visited.
  7. Recycle instead of allocating A thousand multiplications per frame is nothing. A thousand fresh objects per second is work for the garbage collector, and it collects whenever it likes, usually mid-animation. Watch the allocated chip: with pooling on it stops climbing after a second or two.
  8. Measure your own work against the budget At 60 fps you own 16.7 ms per frame, and that includes layout, paint, and every other script on the page. Time your update plus draw, smooth it, and show it. A number on screen beats a hunch.
  9. Respect reduced motion If the system asks for reduced motion, do not autoplay. The lab disables Play, keeps Step, and says why. The same scene is still fully explorable one frame at a time.

The core mechanism

const GRAVITY = 1000; // px per second squared

function fall(frames, frameMs, mode) {
  let y = 0;
  let vy = 0;
  for (let i = 0; i < frames; i += 1) {
    const dt = mode === 'fixed' ? 1 / 60 : frameMs / 1000;
    vy += GRAVITY * dt;
    y += vy * dt;
  }
  return Math.round(y);
}

// Each row is one second of real time.
console.log('fixed step,  30 fps ->', fall(30, 1000 / 30, 'fixed'));    // -> 129
console.log('fixed step,  60 fps ->', fall(60, 1000 / 60, 'fixed'));    // -> 508
console.log('fixed step, 120 fps ->', fall(120, 1000 / 120, 'fixed'));  // -> 2017

console.log('delta time,  30 fps ->', fall(30, 1000 / 30, 'delta'));    // -> 517
console.log('delta time,  60 fps ->', fall(60, 1000 / 60, 'delta'));    // -> 508
console.log('delta time, 120 fps ->', fall(120, 1000 / 120, 'delta'));  // -> 504

One second of falling, simulated at three refresh rates. The fixed step is wrong twice out of three.

The delta rows agree to within about two per cent, and they converge as the steps get smaller. The fixed rows are out by a factor of four in each direction, because a fixed step measures your animation in frames and the user does not own the same screen you do.

function createPool(factory) {
  const free = [];
  return {
    allocated: 0,
    reused: 0,
    take() {
      const spare = free.pop();
      if (spare) {
        this.reused += 1;
        return spare;
      }
      this.allocated += 1;
      return factory();
    },
    give(item) {
      free.push(item);
    },
  };
}

const pool = createPool(() => ({ x: 0, y: 0, life: 0 }));
let alive = [];

// three bursts of 40, with everything dying in between
for (let burst = 0; burst < 3; burst += 1) {
  for (let i = 0; i < 40; i += 1) alive.push(pool.take());
  for (const p of alive) pool.give(p);
  alive = [];
  console.log('after burst ' + (burst + 1) + ': allocated', pool.allocated, 'reused', pool.reused);
}
// -> after burst 1: allocated 40 reused 0
// -> after burst 2: allocated 40 reused 40
// -> after burst 3: allocated 40 reused 80

The pool, and the counter that proves it is working.

// swap-remove, but iterating forwards. Uppercase letters should die.
const alive = ['a', 'B', 'C', 'D'];

for (let i = 0; i < alive.length; i += 1) {
  if (alive[i] === alive[i].toUpperCase()) {
    const last = alive.pop();
    if (i < alive.length) alive[i] = last;
  }
}

console.log(alive.join(','));

Swap-remove drops an unexamined element into the index you just handled, and a forward loop moves past it. D was swapped into index 1 and never tested. Iterate backwards, as the lab does, and the element you swap in always comes from a position you have already passed.

16.7 ms
the whole frame budget at 60 fps, shared with layout, paint and every other script
8 ms
a sane ceiling for your own update plus draw, leaving room for the browser
`performance.now()`
sub-millisecond, monotonic. Date.now() can go backwards when the clock syncs
the rAF timestamp
already on the performance.now() clock, so use it instead of measuring again

Extend it

  1. Add a wind gust: modulate wind with a sine wave driven by elapsed seconds, not by frame count.
  2. Emit on a schedule instead of topping up: keep a spawnAccumulator so "40 particles per second" is honest at any frame rate.
  3. Add trails by keeping the last three positions per particle, and reuse those slots from the pool too.
  4. Add mouse repulsion: push particles away from the pointer with a force that falls off with distance squared.
  5. Draw to an OffscreenCanvas in a worker and post the bitmap back, then compare the HUD numbers.

Try it yourself

Same distance, any frame rate

function travel(frames, frameMs, speed) {
  let x = 0;
  for (let i = 0; i < frames; i += 1) x += speed * (frameMs / 1000);
  return Math.round(x);
}

const SPEED = 240; // px per second

console.log('30 fps  ->', travel(30, 1000 / 30, SPEED), 'px in one second');
console.log('60 fps  ->', travel(60, 1000 / 60, SPEED), 'px in one second');
console.log('144 fps ->', travel(144, 1000 / 144, SPEED), 'px in one second');

// the same speed expressed per frame, which is the bug
const perFrame = 4;
console.log('per frame at 30  ->', 30 * perFrame);
console.log('per frame at 144 ->', 144 * perFrame);

Change the step count to 7 or 999. With delta time the distance barely moves. Now set gravity above zero and watch the numbers separate slightly, because acceleration integrated in bigger steps is less accurate.

How many can you afford?

const BUDGET_MS = 16.7;
const OVERHEAD_MS = 4.5;      // the browser's layout and paint, roughly
const PER_PARTICLE_US = 3.2;  // microseconds for one update plus one arc

const usable = BUDGET_MS - OVERHEAD_MS;
const affordable = Math.floor((usable * 1000) / PER_PARTICLE_US);

console.log('usable per frame:', usable.toFixed(1), 'ms');
console.log('particles that fit:', affordable);
console.log('at 1200 particles we spend', ((1200 * PER_PARTICLE_US) / 1000).toFixed(1), 'ms of', usable.toFixed(1));

Raise the per-particle cost until the answer drops below your target count. That number is your budget conversation with the design.

Exercises

Step a particle on delta time

Write stepParticle(p, dtMs, opts). Convert dtMs to seconds, clamping it into the range 0 to opts.maxStepMs (default 50). Add opts.wind to vx and opts.gravity to vy, both scaled by dt. Then move x and y by the updated velocities, also scaled by dt, and reduce life by dt in seconds. Return a new particle object with every other field carried over, and do not mutate the input.

An object pool with honest counters

Write createPool(factory, limit = Infinity). take() returns a recycled object if one is free, otherwise calls factory(). give(item) puts an object back, unless the free list already holds limit items. Track allocated (how many objects were ever created) and reused (how many takes were served from the free list), and expose size as the number of objects currently free. Add prewarm(n) which fills the free list up to n objects, counting them as allocated.

Check yourself

What does this print on a 120 Hz display, assuming one call per frame for one second?
480 — 120 frames times 4 is 480, where the same code on a 60 Hz screen gives 240. Speed expressed per frame is speed multiplied by whatever hardware the user bought. Express it per second and multiply by the measured delta instead.
Why does the loop clamp dtMs to about 50 ms?
Because a backgrounded or blocked tab returns a gap of seconds, which would move everything a huge distance in one step — Switch tabs for ten seconds and the next timestamp is ten seconds later. Integrating that in one step teleports every particle through the floor and past the walls. Clamping trades a little accuracy for a simulation that survives being ignored.
Pooling is on, particles are dying and spawning constantly, and the allocated chip has stopped changing. What does that tell you?
Every new particle is a recycled one, so the garbage collector has nothing to do — allocated only increases when the factory runs. A flat line with a busy stage means the free list is covering demand, which is exactly the goal: steady state with no fresh garbage, so no collection pause lands in the middle of a frame.
Which is the safest way to remove a dead particle from a large alive array inside the update loop?
Swap the last element into index i and pop, while looping backwards — splice shifts every later element, filter allocates a new array every frame, and null holes turn every read into a branch. Swap-remove is one write and one pop, and looping backwards means the swapped element is always one you have already handled.

Common mistakes

  • Expressing speed per frame, so the animation runs at the speed of the user hardware.
  • Trusting the raw delta after a tab switch, which teleports everything off screen.
  • Animating with setInterval, which drifts, doubles up, and keeps running in a hidden tab.
  • Forgetting cancelAnimationFrame on unmount, leaving a loop running behind the next view.
  • Allocating objects, arrays or closures inside the per-frame loop.
  • Sizing the canvas in CSS only, so the drawing is blurry on a high density screen.
  • Autoplaying an animation when the system has asked for reduced motion.

Takeaways

  • Multiply every change by the seconds the last frame took, and clamp that number before you use it.
  • Acceleration changes velocity, velocity changes position, and both scale with dt.
  • Update velocity before position: it is one line of ordering and much more stable.
  • Pausing means cancelling the frame handle, not setting a flag.
  • Swap-remove and a pool keep the hot loop free of shifting and allocation.
  • The budget is 16.7 ms for everything, so measure your share and put it on screen.