The journey from HTML Canvas to PixiJS, and why sometimes you need to throw out your first implementation (or three)
The journey from HTML Canvas to PixiJS, and why sometimes you need to throw out your first implementation (or three)
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.
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.
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:
Mobile Safari was basically having a panic attack.
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?
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.
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);
One of the best decisions I made was implementing comprehensive performance monitoring from the start. Not just FPS, but:
This let me make informed decisions about when to scale back effects and when the device could handle more.
I should have tested on mobile from day one. Building for the most powerful device first and then trying to scale down is backwards.
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.
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.
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 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.
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.
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.
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:
Mobile Safari was basically having a panic attack.
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?
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.
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);
One of the best decisions I made was implementing comprehensive performance monitoring from the start. Not just FPS, but:
This let me make informed decisions about when to scale back effects and when the device could handle more.
I should have tested on mobile from day one. Building for the most powerful device first and then trying to scale down is backwards.
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.
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.
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 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.
Have you built any unnecessarily complex 404 pages? I'd love to hear about them. Drop me a message on LinkedIn.