Barnes Family Logo
HomeAboutProjectsBlogContact

© 2025 Chandler Barnes. All rights reserved.

GitHubLinkedInTwitter
Barnes Family Logo
HomeAboutProjectsBlogContact

© 2025 Chandler Barnes. All rights reserved.

GitHubLinkedInTwitter
Building a 404 Particle Game: When Canvas Hits Its Limits
engineering8 min readSeptember 4, 2025

Building a 404 Particle Game: When Canvas Hits Its Limits

The journey from HTML Canvas to PixiJS, and why sometimes you need to throw out your first implementation (or three)

ByChandler Barnes
#webgl#game-development#performance#pixi-js#react
Share this post:
TwitterLinkedIn
← Back to blog

Related Posts

Sparkling particles and glitter on a dark background
tutorial•Sep 5, 2025•4 min read

I Wrote This Blog Post in Invisible Ink

How I (tried to) recreated the iOS Messages invisible ink effect using React, Canvas, and a particle system

#react#canvas#animation+1 more

On this page

  • The Canvas Approach: Starting Simple
  • Mobile Reality Check
  • Optimization Attempt #1: Reduce Everything
  • Enter PixiJS: The WebGL Savior
  • The Respawn Bug That Almost Broke Me
  • Performance Monitoring: Know Your Enemy
  • Lessons Learned
  • 1. Start with the Constraint, Not the Ideal
  • 2. Canvas Has Its Limits
  • 3. State Management in Games is Tricky
  • 4. Players Don't Care About Your Technical Achievements
  • The Final Result
  • Technical Stack
  • Performance Metrics
Building a 404 Particle Game: When Canvas Hits Its Limits
engineering8 min readSeptember 4, 2025

Building a 404 Particle Game: When Canvas Hits Its Limits

The journey from HTML Canvas to PixiJS, and why sometimes you need to throw out your first implementation (or three)

ByChandler Barnes
#webgl#game-development#performance#pixi-js#react
Share this post:
TwitterLinkedIn
← Back to blog

Related Posts

Sparkling particles and glitter on a dark background
tutorial•Sep 5, 2025•4 min read

I Wrote This Blog Post in Invisible Ink

How I (tried to) recreated the iOS Messages invisible ink effect using React, Canvas, and a particle system

#react#canvas#animation+1 more

On this page

  • The Canvas Approach: Starting Simple
  • Mobile Reality Check
  • Optimization Attempt #1: Reduce Everything
  • Enter PixiJS: The WebGL Savior
  • The Respawn Bug That Almost Broke Me
  • Performance Monitoring: Know Your Enemy
  • Lessons Learned
  • 1. Start with the Constraint, Not the Ideal
  • 2. Canvas Has Its Limits
  • 3. State Management in Games is Tricky
  • 4. Players Don't Care About Your Technical Achievements
  • The Final Result
  • Technical Stack
  • Performance Metrics

Let's be honest: nobody wants to see a 404 page. But if someone's going to land there, why not give them something fun to play with while they figure out where they actually wanted to go?

That was the thinking behind adding an interactive particle game to my site's 404 page. What I didn't anticipate was the rabbit hole of performance optimization I was about to tumble down.

The Canvas Approach: Starting Simple

Like any reasonable developer, I started with the simplest solution: HTML Canvas. The initial implementation was straightforward: track the mouse position, spawn some particles, apply some basic physics, and render everything at 60fps.

const drawParticle = (ctx, particle) => {
  ctx.beginPath();
  ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
  ctx.fillStyle = particle.color;
  ctx.fill();
};

On my M1 MacBook Pro, it was buttery smooth. Ship it, right?

Wrong.

Mobile Reality Check

The first sign of trouble came when I pulled up the page on my iPhone. What was a smooth 60fps on desktop turned into a slideshow that would make PowerPoint jealous. We're talking 15-20fps on a good day.

The culprit? Canvas 2D rendering just isn't optimized for this kind of workload on mobile devices. Every frame, we were:

  • Clearing the entire canvas
  • Recalculating positions for 50+ particles
  • Drawing each particle individually
  • Applying glow effects and gradients

Mobile Safari was basically having a panic attack.

Optimization Attempt #1: Reduce Everything

My first instinct was to scale back. Fewer particles on mobile, smaller canvas size, simplified effects. I implemented a performance monitor that would dynamically adjust quality:

if (averageFPS < 30) {
  particleCount = Math.floor(particleCount * 0.8);
  disableGlowEffects();
}

This helped, but it felt like putting a bandaid on a broken leg. The mobile experience was "functional" but not fun. And what's the point of a game that isn't fun?

Enter PixiJS: The WebGL Savior

After fighting with Canvas optimizations for longer than I care to admit, I decided to bite the bullet and switch to PixiJS. For those unfamiliar, PixiJS is a 2D rendering engine that leverages WebGL for hardware-accelerated graphics.

The migration wasn't trivial. PixiJS has its own way of doing things:

// Canvas way
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
 
// PixiJS way
const sprite = new PIXI.Sprite(texture);
sprite.position.set(x, y);
container.addChild(sprite);

But the performance difference was night and day. Suddenly, my iPhone was pushing 50+ fps with twice as many particles as before.

The Respawn Bug That Almost Broke Me

Just when I thought I was in the clear, I hit a bug that had me questioning my sanity. Particles would capture correctly, the score would increase, but they wouldn't respawn. Or worse, they'd respawn in the wrong position, creating a weird particle graveyards in the corners of the screen.

The issue turned out to be a classic state management problem. I was mixing refs and state in React, and the cleanup wasn't happening properly:

// The broken way
particles[i].respawning = true;
setTimeout(() => {
  particles[i] = createNewParticle(); // But particles might be stale!
}, 1000);
 
// The fixed way
particlesRef.current[i].respawning = true;
const particleId = particlesRef.current[i].id;
setTimeout(() => {
  const index = particlesRef.current.findIndex(p => p.id === particleId);
  if (index !== -1) {
    particlesRef.current[index] = createNewParticle();
  }
}, 1000);

Performance Monitoring: Know Your Enemy

One of the best decisions I made was implementing comprehensive performance monitoring from the start. Not just FPS, but:

  • Average FPS over the last 30 frames
  • Particle count vs. performance correlation
  • Device capability detection
  • Dynamic quality adjustment

This let me make informed decisions about when to scale back effects and when the device could handle more.

Lessons Learned

1. Start with the Constraint, Not the Ideal

I should have tested on mobile from day one. Building for the most powerful device first and then trying to scale down is backwards.

2. Canvas Has Its Limits

HTML Canvas is great for simple graphics, but once you need consistent performance across devices with complex animations, WebGL (via something like PixiJS) is the way to go.

3. State Management in Games is Tricky

Mixing React's declarative model with a game loop's imperative updates requires careful thought. Refs are your friend, but know when you're accessing them.

4. Players Don't Care About Your Technical Achievements

Nobody's going to appreciate that you implemented your own physics engine if the game runs at 10fps. Sometimes using a library is the right call.

The Final Result

The 404 page now features a smooth, responsive particle game that works across all devices. Players can capture particles for points, watch their score grow, and, most importantly, have a bit of fun while they figure out where they meant to go.

Was it overkill for a 404 page? Absolutely.

Was it worth it? Also absolutely.

Because sometimes the best projects are the ones where you learn the most, even if it's just making sure people enjoy being lost for a few extra seconds.


Want to try it out? Just head to any non-existent page on my site, like /definitely-not-a-real-page. Fair warning: it's surprisingly addictive.

Technical Stack

  • PixiJS for WebGL-accelerated rendering
  • React for component management
  • TypeScript for type safety (and sanity)
  • Dynamic performance scaling for consistent experience across devices

Performance Metrics

  • Desktop: 60fps with 100+ particles
  • Mobile: 45-60fps with 50+ particles
  • Low-end devices: 30fps minimum with automatic quality scaling

Have you built any unnecessarily complex 404 pages? I'd love to hear about them. Drop me a message on LinkedIn.

Let's be honest: nobody wants to see a 404 page. But if someone's going to land there, why not give them something fun to play with while they figure out where they actually wanted to go?

That was the thinking behind adding an interactive particle game to my site's 404 page. What I didn't anticipate was the rabbit hole of performance optimization I was about to tumble down.

The Canvas Approach: Starting Simple

Like any reasonable developer, I started with the simplest solution: HTML Canvas. The initial implementation was straightforward: track the mouse position, spawn some particles, apply some basic physics, and render everything at 60fps.

const drawParticle = (ctx, particle) => {
  ctx.beginPath();
  ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
  ctx.fillStyle = particle.color;
  ctx.fill();
};

On my M1 MacBook Pro, it was buttery smooth. Ship it, right?

Wrong.

Mobile Reality Check

The first sign of trouble came when I pulled up the page on my iPhone. What was a smooth 60fps on desktop turned into a slideshow that would make PowerPoint jealous. We're talking 15-20fps on a good day.

The culprit? Canvas 2D rendering just isn't optimized for this kind of workload on mobile devices. Every frame, we were:

  • Clearing the entire canvas
  • Recalculating positions for 50+ particles
  • Drawing each particle individually
  • Applying glow effects and gradients

Mobile Safari was basically having a panic attack.

Optimization Attempt #1: Reduce Everything

My first instinct was to scale back. Fewer particles on mobile, smaller canvas size, simplified effects. I implemented a performance monitor that would dynamically adjust quality:

if (averageFPS < 30) {
  particleCount = Math.floor(particleCount * 0.8);
  disableGlowEffects();
}

This helped, but it felt like putting a bandaid on a broken leg. The mobile experience was "functional" but not fun. And what's the point of a game that isn't fun?

Enter PixiJS: The WebGL Savior

After fighting with Canvas optimizations for longer than I care to admit, I decided to bite the bullet and switch to PixiJS. For those unfamiliar, PixiJS is a 2D rendering engine that leverages WebGL for hardware-accelerated graphics.

The migration wasn't trivial. PixiJS has its own way of doing things:

// Canvas way
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
 
// PixiJS way
const sprite = new PIXI.Sprite(texture);
sprite.position.set(x, y);
container.addChild(sprite);

But the performance difference was night and day. Suddenly, my iPhone was pushing 50+ fps with twice as many particles as before.

The Respawn Bug That Almost Broke Me

Just when I thought I was in the clear, I hit a bug that had me questioning my sanity. Particles would capture correctly, the score would increase, but they wouldn't respawn. Or worse, they'd respawn in the wrong position, creating a weird particle graveyards in the corners of the screen.

The issue turned out to be a classic state management problem. I was mixing refs and state in React, and the cleanup wasn't happening properly:

// The broken way
particles[i].respawning = true;
setTimeout(() => {
  particles[i] = createNewParticle(); // But particles might be stale!
}, 1000);
 
// The fixed way
particlesRef.current[i].respawning = true;
const particleId = particlesRef.current[i].id;
setTimeout(() => {
  const index = particlesRef.current.findIndex(p => p.id === particleId);
  if (index !== -1) {
    particlesRef.current[index] = createNewParticle();
  }
}, 1000);

Performance Monitoring: Know Your Enemy

One of the best decisions I made was implementing comprehensive performance monitoring from the start. Not just FPS, but:

  • Average FPS over the last 30 frames
  • Particle count vs. performance correlation
  • Device capability detection
  • Dynamic quality adjustment

This let me make informed decisions about when to scale back effects and when the device could handle more.

Lessons Learned

1. Start with the Constraint, Not the Ideal

I should have tested on mobile from day one. Building for the most powerful device first and then trying to scale down is backwards.

2. Canvas Has Its Limits

HTML Canvas is great for simple graphics, but once you need consistent performance across devices with complex animations, WebGL (via something like PixiJS) is the way to go.

3. State Management in Games is Tricky

Mixing React's declarative model with a game loop's imperative updates requires careful thought. Refs are your friend, but know when you're accessing them.

4. Players Don't Care About Your Technical Achievements

Nobody's going to appreciate that you implemented your own physics engine if the game runs at 10fps. Sometimes using a library is the right call.

The Final Result

The 404 page now features a smooth, responsive particle game that works across all devices. Players can capture particles for points, watch their score grow, and, most importantly, have a bit of fun while they figure out where they meant to go.

Was it overkill for a 404 page? Absolutely.

Was it worth it? Also absolutely.

Because sometimes the best projects are the ones where you learn the most, even if it's just making sure people enjoy being lost for a few extra seconds.


Want to try it out? Just head to any non-existent page on my site, like /definitely-not-a-real-page. Fair warning: it's surprisingly addictive.

Technical Stack

  • PixiJS for WebGL-accelerated rendering
  • React for component management
  • TypeScript for type safety (and sanity)
  • Dynamic performance scaling for consistent experience across devices

Performance Metrics

  • Desktop: 60fps with 100+ particles
  • Mobile: 45-60fps with 50+ particles
  • Low-end devices: 30fps minimum with automatic quality scaling

Have you built any unnecessarily complex 404 pages? I'd love to hear about them. Drop me a message on LinkedIn.