Learn Creative Coding (#11) - Particle Systems from Scratch

in StemSocial14 hours ago

Learn Creative Coding (#11) - Particle Systems from Scratch

cc-banner

Particle systems are everywhere in creative coding. Smoke, fire, rain, snow, star fields, explosions, flocking birds, flowing rivers of light. Any time you see thousands of small elements behaving as a group — each one following simple rules, but producing complex emergent behavior together — that's a particle system.

The idea comes from William Reeves at Lucasfilm, who invented the technique in 1982 for the Genesis sequence in Star Trek II: The Wrath of Khan. A planet forms from a wall of fire, and that fire was made of thousands of tiny points following simple physics rules. The technique won him an Oscar nomination and became one of the foundational tools of computer graphics.

We're building one from scratch. No p5, no libraries. Just a class, some physics, and a loop. By the end of this episode you'll have a complete particle engine with forces, emitters, object pooling, attraction fields, and inter-particle connections — the same building blocks used in every creative coding particle system from Processing to TouchDesigner.

The Particle class: position, velocity, life

A particle is a thing with a position, a velocity, and a lifetime. That's the minimum viable particle. Everything else — color, size, acceleration, rotation — is optional decoration on top of those three core properties.

class Particle {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.vx = (Math.random() - 0.5) * 4;
    this.vy = (Math.random() - 0.5) * 4;
    this.life = 1.0;  // starts at 1, dies at 0
    this.decay = 0.005 + Math.random() * 0.01;
    this.size = 2 + Math.random() * 4;
    this.hue = Math.random() * 60 + 10;  // warm range
  }

  update() {
    this.x += this.vx;
    this.y += this.vy;
    this.life -= this.decay;
  }

  draw(ctx) {
    ctx.fillStyle = `hsla(${this.hue}, 80%, 60%, ${this.life})`;
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.size * this.life, 0, Math.PI * 2);
    ctx.fill();
  }

  isDead() {
    return this.life <= 0;
  }
}

Each frame: update position by adding velocity, reduce life by the decay rate. When life hits zero, the particle is done. The size shrinks with life (this.size * this.life), the opacity fades with life (it's the alpha in hsla). Two simple rules, but wait until you see a thousand of them together.

The velocity is randomized in the constructor: (Math.random() - 0.5) * 4 gives a value between -2 and +2. This means particles spray outward in all directions from their spawn point. The decay has randomness too — some particles live longer than others, which prevents the unnatural look of everything dying at the same moment.

Why life as a 0-to-1 float instead of a frame counter? Because it maps directly to alpha and size without conversion. this.life IS the opacity. this.size * this.life IS the current size. No map() calls, no normalization. Clean.

The animation loop and particle management

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 600;
canvas.height = 600;

let particles = [];

function animate() {
  // semi-transparent clear for trails
  ctx.fillStyle = 'rgba(10, 10, 15, 0.1)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // spawn new particles at center
  for (let i = 0; i < 3; i++) {
    particles.push(new Particle(300, 300));
  }

  // update and draw (backward loop for safe removal)
  for (let i = particles.length - 1; i >= 0; i--) {
    particles[i].update();
    particles[i].draw(ctx);

    if (particles[i].isDead()) {
      particles.splice(i, 1);
    }
  }

  requestAnimationFrame(animate);
}

animate();

Three new particles per frame, spawning at the center, drifting outward in random directions, fading as they go. The trail effect from the semi-transparent background clear (alpha 0.1) makes it look like glowing embers floating in darkness.

That backwards loop — for (let i = particles.length - 1; i >= 0; i--) — is critical. We're removing elements while iterating. If you loop forward and splice index 5, then former index 6 becomes index 5, and you skip it. Backward iteration avoids this because removed elements are always behind the cursor.

At steady state, the array stabilizes around a constant size: 3 particles born per frame, roughly 3 dying per frame (because of the random decay rate). But splice() has a hidden cost — every removal shifts all subsequent elements. With 500 particles, splicing from the middle copies ~250 array entries. We'll fix this with object pooling later.

Adding forces: gravity, wind, friction

Newton's second law: Force = Mass × Acceleration. For particle systems we usually assume mass = 1, so force IS acceleration. Applying a force means adding to velocity:

update() {
  // gravity: constant downward acceleration
  this.vy += 0.05;

  // wind: oscillating horizontal force
  this.vx += Math.sin(Date.now() * 0.001) * 0.02;

  // friction: velocity damping (multiply, don't subtract)
  this.vx *= 0.99;
  this.vy *= 0.99;

  // integrate: velocity → position
  this.x += this.vx;
  this.y += this.vy;
  this.life -= this.decay;
}

Gravity (this.vy += 0.05) adds a constant downward acceleration. Each frame the vertical velocity increases by 0.05, so particles arc upward (if launched up) and curve back down. Change the sign to -0.02 and they float upward like sparks from a campfire. Change the axis to this.vx += 0.05 and gravity pulls sideways. The number is arbitrary — tune it until it looks right.

Wind uses Math.sin(Date.now() * 0.001) to create an oscillating force. The 0.001 controls how fast the wind changes direction. Multiply the frequency up and you get turbulent gusting. The * 0.02 is the force strength — keep it small relative to gravity or the wind overpowers everything.

Friction (*= 0.99) is multiplicative damping. Every frame, velocity is reduced by 1%. This means fast particles slow down more than slow particles (because 1% of a big number is bigger than 1% of a small number). It's physically incorrect (real drag is proportional to velocity squared, so *= (1 - k * speed)) but it looks convincing and is cheap to compute. Without friction, particles accelerate forever under gravity and shoot off-screen. With friction, they reach a terminal velocity where the gravitational acceleration equals the frictional deceleration.

The order matters: apply forces first (modify velocity), then integrate (modify position). This is Euler integration — the simplest numerical integration method. It's not perfectly accurate (Verlet integration is better for stiff systems), but for visual particle effects the error is invisible and the simplicity is worth it.

Mouse-driven spawning and velocity inheritance

Replace the center spawn with mouse position:

let mouse = { x: 300, y: 300, px: 300, py: 300, down: false };

canvas.addEventListener('mousemove', e => {
  mouse.px = mouse.x;
  mouse.py = mouse.y;
  mouse.x = e.offsetX;
  mouse.y = e.offsetY;
});
canvas.addEventListener('mousedown', () => mouse.down = true);
canvas.addEventListener('mouseup', () => mouse.down = false);

// in animate(), replace the spawn section:
if (mouse.down) {
  let dx = mouse.x - mouse.px;
  let dy = mouse.y - mouse.py;

  for (let i = 0; i < 8; i++) {
    let p = new Particle(mouse.x, mouse.y);
    // inherit mouse velocity + random spread
    p.vx = dx * 0.3 + (Math.random() - 0.5) * 3;
    p.vy = dy * 0.3 + (Math.random() - 0.5) * 3;
    particles.push(p);
  }
}

The dx/dy between current and previous mouse position IS the mouse velocity (in pixels per frame). Multiplying it by 0.3 and adding it to the particle's initial velocity makes particles shoot in the direction you're dragging. Fast hand movement = fast particles. The random spread ((Math.random() - 0.5) * 3) prevents them from forming a perfectly straight line — it's the "spray" of the emitter.

This feels physically connected to your hand. Move slowly and particles drift gently. Whip the cursor fast and they explode outward. That one multiplication (dx * 0.3) is the difference between "particles appear at mouse" and "particles shoot from mouse." Huge perceptual difference from one line of math.

Object pooling: recycling instead of garbage collection

splice() and new Particle() every frame creates garbage for the JavaScript engine to collect. At hundreds of particles this doesn't matter. At thousands, you'll see periodic stutters — the garbage collector freezes execution for a few milliseconds while it cleans up dead objects. The fix: pre-allocate all particles once and recycle them.

class Particle {
  constructor() {
    this.active = false;
    this.reset(0, 0);
  }

  reset(x, y) {
    this.x = x;
    this.y = y;
    this.vx = (Math.random() - 0.5) * 4;
    this.vy = (Math.random() - 0.5) * 4;
    this.life = 1.0;
    this.decay = 0.005 + Math.random() * 0.01;
    this.size = 2 + Math.random() * 4;
    this.hue = Math.random() * 60 + 10;
    this.active = true;
  }

  update() {
    if (!this.active) return;
    this.vy += 0.05;
    this.vx *= 0.99;
    this.vy *= 0.99;
    this.x += this.vx;
    this.y += this.vy;
    this.life -= this.decay;
    if (this.life <= 0) this.active = false;
  }

  draw(ctx) {
    if (!this.active) return;
    ctx.fillStyle = `hsla(${this.hue}, 80%, 60%, ${this.life})`;
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.size * this.life, 0, Math.PI * 2);
    ctx.fill();
  }
}

class ParticlePool {
  constructor(maxSize) {
    this.particles = new Array(maxSize);
    this.max = maxSize;
    this.nextFree = 0;

    for (let i = 0; i < maxSize; i++) {
      this.particles[i] = new Particle();
    }
  }

  spawn(x, y) {
    // scan for an inactive slot
    for (let attempt = 0; attempt < this.max; attempt++) {
      let idx = (this.nextFree + attempt) % this.max;
      if (!this.particles[idx].active) {
        this.particles[idx].reset(x, y);
        this.nextFree = (idx + 1) % this.max;
        return this.particles[idx];
      }
    }
    return null;  // pool exhausted
  }

  update(ctx) {
    for (let i = 0; i < this.max; i++) {
      this.particles[i].update();
      this.particles[i].draw(ctx);
    }
  }
}

// usage
const pool = new ParticlePool(5000);

function animate() {
  ctx.fillStyle = 'rgba(10, 10, 15, 0.1)';
  ctx.fillRect(0, 0, 600, 600);

  for (let i = 0; i < 5; i++) {
    pool.spawn(300, 300);
  }

  pool.update(ctx);
  requestAnimationFrame(animate);
}

Zero allocations during the animation. Every Particle object is created once in the constructor and never garbage collected. spawn() finds an inactive slot and resets it. update() iterates the entire fixed-size array — inactive particles skip their update and draw with an early return.

The nextFree pointer is a small optimization: instead of scanning from index 0 every time, we start scanning from where we last found a free slot. Since particles die roughly in the order they were born, free slots cluster near nextFree, so most spawns find a slot on the first check.

This pattern is standard in game development. Particle pools of 10,000-50,000 are common in Unity and Unreal. The JavaScript version works the same way — pre-allocate, recycle, never let the garbage collector see your particles.

Emitter patterns: defining behavior through initial conditions

The Particle class is generic — it doesn't know if it's a spark, a raindrop, or a snowflake. The emitter decides that by setting initial conditions:

function emitFountain(pool, x, y) {
  let p = pool.spawn(x, y);
  if (!p) return;
  p.vx = (Math.random() - 0.5) * 2;
  p.vy = -4 - Math.random() * 4;       // strong upward burst
  p.hue = 200 + Math.random() * 40;     // blue
  p.size = 2 + Math.random() * 3;
  p.decay = 0.004 + Math.random() * 0.006;  // long-lived
}

function emitFire(pool, x, y) {
  let p = pool.spawn(x, y);
  if (!p) return;
  p.vx = (Math.random() - 0.5) * 1.5;
  p.vy = -1 - Math.random() * 2;        // slow upward drift
  p.hue = Math.random() * 40;           // red-orange
  p.size = 4 + Math.random() * 8;       // bigger particles
  p.decay = 0.015 + Math.random() * 0.02;   // dies fast
}

function emitSnow(pool, x, y) {
  let p = pool.spawn(x, y);
  if (!p) return;
  p.vx = (Math.random() - 0.5) * 0.5;
  p.vy = 0.3 + Math.random() * 0.7;    // slow downward
  p.hue = 0;
  p.size = 1 + Math.random() * 3;
  p.decay = 0.001 + Math.random() * 0.002;  // very long-lived
}

function emitExplosion(pool, x, y, count) {
  for (let i = 0; i < count; i++) {
    let p = pool.spawn(x, y);
    if (!p) continue;
    let angle = Math.random() * Math.PI * 2;
    let speed = 2 + Math.random() * 6;
    p.vx = Math.cos(angle) * speed;
    p.vy = Math.sin(angle) * speed;
    p.hue = 30 + Math.random() * 30;    // gold-orange
    p.size = 2 + Math.random() * 5;
    p.decay = 0.01 + Math.random() * 0.015;
  }
}

// in animate():
for (let i = 0; i < 3; i++) emitFountain(pool, 150, 500);
for (let i = 0; i < 5; i++) emitFire(pool, 450, 500);

// spawn snow from random positions along the top
for (let i = 0; i < 2; i++) emitSnow(pool, Math.random() * 600, 0);

Same particle class, four completely different visual effects. The emitter IS the creative tool — it defines what the system looks like. A fountain has strong upward velocity, blue color, long life. Fire has slow drift, warm color, short life (flames flicker and die fast). Snow is slow, white, very long-lived. An explosion radiates outward from a single point at high speed.

The explosion emitter uses polar coordinates for the initial velocity: a random angle and a random speed, converted to vx/vy with cos/sin. This guarantees uniform radial distribution. If you randomized vx and vy independently, you'd get a square distribution (corners have faster particles than edges), which doesn't look like an explosion.

Attraction and repulsion: forces between particles and points

Gravity is a constant force that always points the same direction. Attraction is a force that depends on distance — particles close to the attractor feel a strong pull, particles far away feel almost nothing. This is the inverse-square law (same math as real gravity between planets):

function attract(particle, ax, ay, strength) {
  let dx = ax - particle.x;
  let dy = ay - particle.y;
  let distSq = dx * dx + dy * dy;

  // avoid division by zero and extreme forces at close range
  let minDist = 2500;  // 50 pixels squared
  if (distSq < minDist) distSq = minDist;

  let force = strength / distSq;

  // normalize direction and apply force
  let dist = Math.sqrt(distSq);
  particle.vx += (dx / dist) * force;
  particle.vy += (dy / dist) * force;
}

strength / distSq is the inverse-square falloff. At distance 50, the force is strength / 2500. At distance 100, it's strength / 10000 — four times weaker at double the distance. The minDist clamp prevents the force from going to infinity when a particle gets very close to the attractor (which would launch it off-screen at ridiculous speed).

The dx / dist part normalizes the direction vector to unit length. Without normalization, closer particles would get a double boost: stronger force (from inverse-square) AND longer direction vector (because dx is larger at close range). We want only the inverse-square effect.

Use it in the update loop:

// in animate(), before pool.update():
for (let i = 0; i < pool.max; i++) {
  let p = pool.particles[i];
  if (!p.active) continue;
  attract(p, 300, 300, 500);          // pull toward center
  attract(p, mouse.x, mouse.y, 200);  // pull toward mouse
}

Particles orbit the center and get deflected by your cursor. With the right strength values, they settle into stable orbits. Push the strength higher and they slam into the attractor. Negate the strength and you get repulsion — particles flee from the point.

Multiple attractors create complex gravitational fields. Two attractors produce figure-eight orbits. Three or more create chaotic trajectories that never repeat. This is the three-body problem playing out in your browser.

Inter-particle connections: network visualizations

Draw lines between particles that are close enough to each other:

function drawConnections(particles, ctx, maxDist) {
  let maxDistSq = maxDist * maxDist;  // avoid sqrt in the inner loop

  for (let i = 0; i < particles.length; i++) {
    if (!particles[i].active) continue;

    for (let j = i + 1; j < particles.length; j++) {
      if (!particles[j].active) continue;

      let dx = particles[i].x - particles[j].x;
      let dy = particles[i].y - particles[j].y;
      let distSq = dx * dx + dy * dy;

      if (distSq < maxDistSq) {
        let alpha = 1 - Math.sqrt(distSq) / maxDist;
        let avgLife = (particles[i].life + particles[j].life) / 2;

        ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * avgLife * 0.3})`;
        ctx.lineWidth = alpha;
        ctx.beginPath();
        ctx.moveTo(particles[i].x, particles[i].y);
        ctx.lineTo(particles[j].x, particles[j].y);
        ctx.stroke();
      }
    }
  }
}

This creates those network/constellation visualizations you see on every tech startup landing page (minus the VC funding). The lines fade as particles drift apart. The avgLife factor makes connections fade as either particle dies — no orphan lines hanging in space after one endpoint disappears.

The maxDistSq comparison avoids calling Math.sqrt() in the inner loop. We only call sqrt for the pairs that actually pass the distance check (which is a small fraction). This is a standard optimization — squared distance comparison is one of the most common tricks in spatial algorithms.

Performance warning: this is O(n²). With 500 particles that's 124,750 distance checks per frame. At 1000 particles: 499,500 checks. Keeping particle count under 300 is practical for connections. Beyond that, you need spatial partitioning:

class SpatialGrid {
  constructor(cellSize, width, height) {
    this.cellSize = cellSize;
    this.cols = Math.ceil(width / cellSize);
    this.rows = Math.ceil(height / cellSize);
    this.cells = new Array(this.cols * this.rows);
  }

  clear() {
    for (let i = 0; i < this.cells.length; i++) {
      this.cells[i] = [];
    }
  }

  insert(particle) {
    let cx = Math.floor(particle.x / this.cellSize);
    let cy = Math.floor(particle.y / this.cellSize);
    if (cx >= 0 && cx < this.cols && cy >= 0 && cy < this.rows) {
      this.cells[cy * this.cols + cx].push(particle);
    }
  }

  getNeighbors(particle) {
    let cx = Math.floor(particle.x / this.cellSize);
    let cy = Math.floor(particle.y / this.cellSize);
    let neighbors = [];

    for (let dy = -1; dy <= 1; dy++) {
      for (let dx = -1; dx <= 1; dx++) {
        let nx = cx + dx;
        let ny = cy + dy;
        if (nx >= 0 && nx < this.cols && ny >= 0 && ny < this.rows) {
          let cell = this.cells[ny * this.cols + nx];
          for (let p of cell) neighbors.push(p);
        }
      }
    }

    return neighbors;
  }
}

// usage: set cellSize = maxDist so only neighboring cells are checked
const grid = new SpatialGrid(80, 600, 600);

// each frame:
grid.clear();
for (let p of activeParticles) grid.insert(p);

// then for connections, only check neighbors:
for (let p of activeParticles) {
  let nearby = grid.getNeighbors(p);
  for (let other of nearby) {
    // distance check + draw line...
  }
}

The grid divides the canvas into cells. Each particle is inserted into its cell. When checking connections, you only compare against particles in the same cell and its 8 neighbors — not the entire array. With a cell size equal to maxDist, this guarantees you never miss a connection while checking far fewer pairs. Complexity drops from O(n²) to roughly O(n × k) where k is the average number of particles per cell neighborhood. For 2000 particles in a 600×600 canvas with 80px cells, that's about 50 neighbors per particle instead of 2000. Massive speedup.

Complete project: interactive particle universe

Let's bring everything together — a particle system with multiple emitter modes, attraction, connections, and keyboard controls. This is a full creative coding toy you can play with.

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = 600, H = 600;
canvas.width = W;
canvas.height = H;

const TAU = Math.PI * 2;
function rand(min, max) { return Math.random() * (max - min) + min; }

// --- Particle with pooling ---
class Particle {
  constructor() { this.active = false; }

  reset(x, y, vx, vy, hue, size, decay) {
    this.x = x; this.y = y;
    this.vx = vx; this.vy = vy;
    this.hue = hue;
    this.size = size;
    this.decay = decay;
    this.life = 1.0;
    this.active = true;
  }

  update(gravity, friction) {
    if (!this.active) return;
    this.vy += gravity;
    this.vx *= friction;
    this.vy *= friction;
    this.x += this.vx;
    this.y += this.vy;
    this.life -= this.decay;
    if (this.life <= 0) this.active = false;
  }

  draw(ctx) {
    if (!this.active) return;
    let r = this.size * this.life;
    if (r < 0.3) return;  // skip sub-pixel particles
    ctx.fillStyle = `hsla(${this.hue}, 80%, 60%, ${this.life})`;
    ctx.beginPath();
    ctx.arc(this.x, this.y, r, 0, TAU);
    ctx.fill();
  }
}

// --- Pool ---
const MAX = 4000;
const particles = new Array(MAX);
let nextFree = 0;
for (let i = 0; i < MAX; i++) particles[i] = new Particle();

function spawn(x, y, vx, vy, hue, size, decay) {
  for (let a = 0; a < MAX; a++) {
    let idx = (nextFree + a) % MAX;
    if (!particles[idx].active) {
      particles[idx].reset(x, y, vx, vy, hue, size, decay);
      nextFree = (idx + 1) % MAX;
      return particles[idx];
    }
  }
  return null;
}

// --- State ---
let mouse = { x: W/2, y: H/2, px: W/2, py: H/2, down: false };
let mode = 'fountain';   // fountain, fire, snow, spray, attract
let baseHue = rand(0, 360);
let showConnections = false;

canvas.addEventListener('mousemove', e => {
  mouse.px = mouse.x; mouse.py = mouse.y;
  mouse.x = e.offsetX; mouse.y = e.offsetY;
});
canvas.addEventListener('mousedown', () => mouse.down = true);
canvas.addEventListener('mouseup', () => mouse.down = false);

window.addEventListener('keydown', e => {
  switch(e.key) {
    case '1': mode = 'fountain'; break;
    case '2': mode = 'fire'; break;
    case '3': mode = 'snow'; break;
    case '4': mode = 'spray'; break;
    case '5': mode = 'attract'; break;
    case 'c': showConnections = !showConnections; break;
    case 'r': baseHue = rand(0, 360); break;
  }
});

// --- Emitters ---
function emitFrame() {
  let mx = mouse.x, my = mouse.y;
  let dx = mx - mouse.px, dy = my - mouse.py;

  switch(mode) {
    case 'fountain':
      for (let i = 0; i < 4; i++) {
        spawn(mx, my,
          rand(-1.5, 1.5), rand(-6, -3),
          baseHue + rand(0, 30), rand(2, 4), rand(0.004, 0.008));
      }
      break;

    case 'fire':
      for (let i = 0; i < 6; i++) {
        spawn(mx, my,
          rand(-1, 1), rand(-2.5, -0.5),
          rand(0, 40), rand(4, 10), rand(0.012, 0.025));
      }
      break;

    case 'snow':
      for (let i = 0; i < 2; i++) {
        spawn(rand(0, W), 0,
          rand(-0.3, 0.3), rand(0.3, 1),
          0, rand(1, 3), rand(0.001, 0.003));
      }
      break;

    case 'spray':
      if (mouse.down) {
        for (let i = 0; i < 10; i++) {
          spawn(mx, my,
            dx * 0.3 + rand(-3, 3), dy * 0.3 + rand(-3, 3),
            baseHue + rand(-20, 20), rand(2, 5), rand(0.006, 0.012));
        }
      }
      break;

    case 'attract':
      // continuous emission from edges
      for (let i = 0; i < 3; i++) {
        let side = Math.floor(rand(0, 4));
        let sx, sy;
        if (side === 0) { sx = rand(0, W); sy = 0; }
        else if (side === 1) { sx = W; sy = rand(0, H); }
        else if (side === 2) { sx = rand(0, W); sy = H; }
        else { sx = 0; sy = rand(0, H); }
        spawn(sx, sy, rand(-1, 1), rand(-1, 1),
          baseHue + 180 + rand(-20, 20), rand(2, 4), rand(0.003, 0.006));
      }
      break;
  }
}

// --- Attract ---
function applyAttraction() {
  if (mode !== 'attract') return;
  for (let i = 0; i < MAX; i++) {
    let p = particles[i];
    if (!p.active) continue;
    let dx = mouse.x - p.x;
    let dy = mouse.y - p.y;
    let distSq = dx * dx + dy * dy;
    if (distSq < 900) distSq = 900;
    let dist = Math.sqrt(distSq);
    let force = 300 / distSq;
    p.vx += (dx / dist) * force;
    p.vy += (dy / dist) * force;
  }
}

// --- Connections (only when toggled, limited particles) ---
function drawLinks() {
  if (!showConnections) return;
  let active = [];
  for (let i = 0; i < MAX; i++) {
    if (particles[i].active) active.push(particles[i]);
    if (active.length >= 200) break;  // cap for performance
  }

  let maxD = 60;
  let maxDSq = maxD * maxD;

  for (let i = 0; i < active.length; i++) {
    for (let j = i + 1; j < active.length; j++) {
      let dx = active[i].x - active[j].x;
      let dy = active[i].y - active[j].y;
      let dSq = dx * dx + dy * dy;
      if (dSq < maxDSq) {
        let alpha = (1 - Math.sqrt(dSq) / maxD) * 0.2;
        ctx.strokeStyle = `rgba(255,255,255,${alpha})`;
        ctx.lineWidth = 0.5;
        ctx.beginPath();
        ctx.moveTo(active[i].x, active[i].y);
        ctx.lineTo(active[j].x, active[j].y);
        ctx.stroke();
      }
    }
  }
}

// --- Main loop ---
let gravity = 0.04;
let friction = 0.995;

function animate() {
  ctx.fillStyle = mode === 'snow'
    ? 'rgba(10, 10, 30, 0.03)'     // long trails for snow
    : 'rgba(10, 10, 15, 0.1)';
  ctx.fillRect(0, 0, W, H);

  emitFrame();
  applyAttraction();

  for (let i = 0; i < MAX; i++) {
    let g = mode === 'snow' ? 0.01 : (mode === 'attract' ? 0 : gravity);
    particles[i].update(g, friction);
    particles[i].draw(ctx);
  }

  drawLinks();

  // HUD
  ctx.fillStyle = 'rgba(0,0,0,0.5)';
  ctx.fillRect(0, 0, W, 24);
  ctx.fillStyle = '#aaa';
  ctx.font = '12px monospace';
  let count = 0;
  for (let i = 0; i < MAX; i++) if (particles[i].active) count++;
  ctx.fillText(`Mode: ${mode} | Particles: ${count}/${MAX} | Keys 1-5 | C=connect R=recolor`, 8, 16);

  requestAnimationFrame(animate);
}

animate();

Five modes (fountain, fire, snow, spray-on-click, attract-to-mouse), toggleable connections, runtime palette changes. The whole thing runs at 60fps with 4000 pooled particles and zero garbage collection pressure. Press 5, move your mouse, and watch particles stream in from the edges and orbit your cursor. Press C to see the connection web form between nearby particles. Press R to shift the entire palette.

This is genuinely the kind of interactive demo that gets shared on creative coding Twitter. The building blocks are simple — everything in this episode — but the combination creates something people want to play with. That's the whole point of particle systems.

't Komt erop neer...

  • A particle = position + velocity + lifetime. Everything else is decoration on those three properties
  • Forces (gravity, wind, friction) are velocity modifications per frame — Newton's second law at its simplest
  • Euler integration: apply forces → update velocity → update position. Simple, fast, good enough for visuals
  • Friction is multiplicative (*= 0.99), not subtractive — physically wrong but visually convincing
  • Backward iteration (i--) for safe removal during array traversal
  • Object pooling pre-allocates all particles and recycles them — zero garbage collection, essential above ~500 particles
  • Emitter patterns define behavior through initial conditions: same particle class, infinite variety of effects
  • Attraction/repulsion uses inverse-square falloff (strength / distSq) with a minimum distance clamp to prevent infinity
  • Velocity inheritance from mouse movement makes particles feel physically connected to your hand
  • Inter-particle connections are O(n²) — keep under 300 particles, or use spatial grid hashing to reduce to O(n × k)
  • Spatial grids divide the canvas into cells, only checking neighbors — standard optimization for any distance-based interaction

Next episode: implementing Perlin noise from scratch. We've been using p5's noise() as a black box since episode 4 — time to understand how it actually works, build our own, and use it to drive flow fields and organic motion.

Sallukes! Thanks for reading.

X

@femdev