Visual Effects

Advanced Particle Systems

30 min read Advanced 4 Live Effects

The Problem with Naive Particles

Spawning particles with new Particle() and removing them with splice() is the most common beginner approach — and the worst for performance. Garbage collection spikes destroy your frame rate when hundreds of particles die each second.

Professional games use two principles to fix this: Object Pooling (reuse objects, never delete them) and Typed Arrays (store data in contiguous memory). In this tutorial, we'll build a clean pool-based system and use it to create fire, magic, and explosion effects.

Goal: A particle system that can handle 2,000+ particles at 60fps without garbage collection hitches — using just a Canvas 2D context.

Interactive Effects Lab

Click the effect buttons to switch modes. Move your mouse over the canvas to control emitter position.

Effect: Fire

The Particle Pool Zero GC

An Object Pool pre-allocates all particles upfront. When one "dies", it's flagged inactive and returned to the pool — not garbage collected. The next spawn pulls from the pool instead of calling new.

particle-pool.js
class ParticlePool {
    constructor(maxSize = 2000) {
        // Pre-allocate all particles ONCE at startup
        this.pool = Array.from({ length: maxSize }, () => ({
            x: 0, y: 0, vx: 0, vy: 0,
            life: 0, maxLife: 0, size: 0,
            r: 255, g: 100, b: 0, alpha: 1,
            active: false
        }));
        this.activeCount = 0;
    }

    spawn(props) {
        // Find an inactive particle in the pool
        for (const p of this.pool) {
            if (!p.active) {
                Object.assign(p, props);
                p.active = true;
                this.activeCount++;
                return p;
            }
        }
        // Pool exhausted — return null (don't exceed budget)
        return null;
    }

    update(deltaMs) {
        const dt = deltaMs / 16; // Normalize to 60fps
        for (const p of this.pool) {
            if (!p.active) continue;

            p.life -= dt;

            if (p.life <= 0) {
                p.active = false; // Return to pool (no delete!)
                this.activeCount--;
                continue;
            }

            // Physics
            p.x += p.vx * dt;
            p.y += p.vy * dt;
            p.vy += 0.05 * dt; // Gravity
            p.alpha = p.life / p.maxLife;
        }
    }

    draw(ctx) {
        for (const p of this.pool) {
            if (!p.active) continue;
            ctx.globalAlpha = p.alpha;
            ctx.fillStyle = `rgb(${p.r},${p.g},${p.b})`;
            ctx.beginPath();
            ctx.arc(p.x, p.y, p.size * p.alpha, 0, Math.PI * 2);
            ctx.fill();
        }
        ctx.globalAlpha = 1;
    }
}

Emitter #1: Fire

Fire works by continuously spawning particles from a base point with upward velocity, a slight horizontal spread, and a color that shifts from bright yellow → orange → red → transparent as the particle ages.

fire-emitter.js
function emitFire(pool, x, y, count = 5) {
    for (let i = 0; i < count; i++) {
        const life = 30 + Math.random() * 30;
        pool.spawn({
            x: x + (Math.random() - 0.5) * 20,
            y,
            vx: (Math.random() - 0.5) * 1.5,
            vy: -2 - Math.random() * 2,
            life, maxLife: life,
            size: 4 + Math.random() * 6,
            r: 255,
            g: Math.floor(100 + Math.random() * 155), // Yellow to orange
            b: 0,
        });
    }
}

// In your custom update loop, animate color over lifetime:
// t = p.life / p.maxLife  (1.0 = fresh, 0.0 = dead)
// p.g = Math.floor(t * 255);    // Green fades → creates red-only look
// p.size = p.size * t;           // Shrinks as it ages

Blend Modes: The Secret Sauce

The visual quality difference between fire that looks "meh" and fire that looks amazing is often just one canvas property:

blend-modes.js
// Before drawing particles:
ctx.globalCompositeOperation = 'lighter'; // Additive blending!

// Draw all particles here...
pool.draw(ctx);

// CRITICAL: Reset afterward, or everything else glows
ctx.globalCompositeOperation = 'source-over';

// Available modes for effects:
// 'lighter'       → Additive: overlapping particles get BRIGHTER (perfect for fire, magic)
// 'screen'        → Softer additive, good for fog/smoke
// 'multiply'      → Darkening, great for shadows
// 'overlay'       → High contrast, good for glows
Additive Blending is the single most impactful trick for particle VFX. It makes overlapping particles accumulate brightness, so a dense core of particles appears white-hot, with a softer colored glow at the edges. This is how real fire, magic, and energy effects look in commercial games.

Emitter #2: Burst Explosions

Unlike fire (continuous stream), explosions emit all particles in a single frame, spread radially in all directions, and use varying velocities and lifetimes for an organic look.

explosion.js
function emitExplosion(pool, x, y) {
    const PARTICLE_COUNT = 120;
    
    for (let i = 0; i < PARTICLE_COUNT; i++) {
        const angle = Math.random() * Math.PI * 2;
        const speed = 2 + Math.random() * 8; // Varying speeds
        const life = 20 + Math.random() * 40;

        // Core: hot white particles
        if (i < PARTICLE_COUNT * 0.3) {
            pool.spawn({
                x, y, 
                vx: Math.cos(angle) * speed,
                vy: Math.sin(angle) * speed,
                life, maxLife: life,
                size: 3 + Math.random() * 5,
                r: 255, g: 255, b: 200, // White-yellow core
            });
        } else {
            // Debris: orange-red sparks
            pool.spawn({
                x, y,
                vx: Math.cos(angle) * speed * 0.6,
                vy: Math.sin(angle) * speed * 0.6 - 2,
                life: life * 1.5, maxLife: life * 1.5,
                size: 1.5 + Math.random() * 3,
                r: 255, g: Math.floor(Math.random() * 150), b: 0,
            });
        }
    }
}

Performance Checklist

Reaching 2,000 particles at 60fps requires a few more optimizations beyond pooling:

1. Avoid per-particle fillStyle strings

batch-by-color.js
// SLOW: 1,000 string allocations per frame
for (const p of pool) {
    ctx.fillStyle = `rgb(${p.r},${p.g},${p.b})`; // String created each time!
    ctx.fill();
}

// FAST: Sort particles by color, only set fillStyle on change
const sorted = activeParticles.sort((a, b) => (a.colorKey - b.colorKey));
let lastColor = null;
for (const p of sorted) {
    if (p.colorKey !== lastColor) {
        ctx.fillStyle = colorCache[p.colorKey]; // Pre-baked string
        lastColor = p.colorKey;
    }
    ctx.beginPath();
    ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
    ctx.fill();
}

2. Use ctx.save() / ctx.restore() sparingly

These are expensive. Instead, manually reset only the properties you change (globalAlpha, globalCompositeOperation) after your particle draw pass.

3. Skip tiny particles

skip-tiny.js
// Sub-pixel particles are invisible but still cost draw calls
if (p.size * p.alpha < 0.5) continue; // Skip invisible particles