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

Before you continue

Direct answer: A particle system is a small group of short-lived visual objects updated and drawn each frame.

What you need first

The game loop and basic drawing.

After this lesson

You can explain the idea, change the supplied example, and choose the next related lesson.

When to use it

Use particles for feedback such as hits, pickups, and explosions. Do not leave expired particles in the update list.

Common mistake

Creating unlimited particles without removing them after their lifetime ends.

Try it and check it

This lesson includes its runnable example or code experiment above. Change one value, run it again, and confirm the visible result changes before moving on.

Real game connection

Compare feedback ideas with Galaxy Guardian.

Compatibility: Test in a current Chrome, Firefox, Safari, or Edge browser. Canvas and standard input work broadly in current browsers; audio still needs a user action.

Source and update

Reviewed against MDN Web Docs. Updated 2026-07-14. This page does not claim performance results beyond the local example check.