Advanced Particle Systems
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.
Interactive Effects Lab
Click the effect buttons to switch modes. Move your mouse over the canvas to control emitter position.
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.
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.
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:
// 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
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.
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
// 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
// Sub-pixel particles are invisible but still cost draw calls
if (p.size * p.alpha < 0.5) continue; // Skip invisible particles