Neon in canvas 2D without shadowBlur: 0.1ms/frame
I'll be honest: growordie started as an excuse to learn multiplayer netcode, and I assumed the drawing part would be trivial. It's a snake. You draw a line. How hard can glowing neon snakes on a canvas be? The thing I didn't see coming is how much of making it feel smooth turned out to be about what you don't do. And the one that surprised me most: there's no shadowBlur anywhere in the client. That thing will wreck your framerate. The glow you're looking at is just stacked, low-opacity strokes (plain canvas 2D, no WebGL, no shaders) and the day that clicked, half my render got cheaper.
So this is me walking you through the client render on growordie.io: the shadowBlur mistake I made first, the stacked-stroke trick I'm a little too proud of, and the small pile of "just don't" decisions around it that keep a phone at 60fps in a crowded arena. Everything below is the real shipping code, I'm not cleaning it up for the blog.
- 01 The trap: why
shadowBlurcollapses under many glows - 02 The fake: a neon halo from stacked flat strokes
- 03 The context:
alpha:false,desynchronized, a DPR cap - 04 The frame: culling, a 7 Hz minimap, capped particles
- 05 The sneak: reflow-free HUD writes
- 06 The result & the honest tradeoffs
01 · The trap: why shadowBlur collapses
Of course I reached for shadowBlur first, everybody does. It's right there and it's magic: set ctx.shadowBlur = 20, pick a shadowColor, and every shape you draw comes out with a soft neon halo. My first snake looked incredible. Then I spawned a hundred of them and watched the frame rate fall off a cliff, and I genuinely didn't understand why for an embarrassingly long time.
Here's what I eventually learned. A canvas shadow isn't a little decoration bolted onto your shape, it's a gaussian blur the browser recomputes from scratch on every single draw call. "Gaussian blur" just means the soft, feathered smear you'd get from an out-of-focus photo: for every shape, the browser quietly re-renders it to a scratch buffer and smears every pixel it touches, and the smear gets more expensive with the square of how soft you asked for. Most 2D canvas engines do that on the CPU. So it's not "a bit of glow", it's roughly objects × pixels × softness², paid again every frame, on the main thread, in the middle of the ~16 ms you get to render at 60fps.
One snake, fine. A screenful of long, several-times-stroked snakes turns a sub-millisecond paint into a slideshow. And the cruel part is the timing: it gets worse precisely when the game gets good, a crowded arena, a pile-up, an explosion. That's the exact moment you can't afford it. Here's that cliff.
the cost is per-draw, per-pixel, per-frame· it climbs when the game gets busy
Every glowing object under shadowBlur pays a fresh gaussian blur, cost scaling with the pixels it covers and the square of the blur radius, on the CPU, every frame. As the arena fills, the red gauge climbs faster than linearly and blows the 16.6 ms budget; the frame drops. The stacked-stroke technique (right) is just more flat fills, cost grows gently and linearly, and never comes near the line.
02 · The fake: a halo from stacked flat strokes
Okay, this is the part I'm weirdly proud of, so let me actually geek out. Stare at a real gaussian glow for a second and ask what it's doing: it's brightest in the center and it fades out toward the edges. That's it. That's the whole effect, a brightness falloff. And you can fake a falloff with zero blur, just by drawing the same line a few times: a wide, barely-there stroke underneath, then narrower and brighter strokes stacked on top. Where the see-through strokes overlap, the middle of the line, their opacity piles up toward white. At the edges, only the widest, faintest one is left. Squint and it's a halo. But mechanically it's nothing but ordinary stroke() calls, the boring kind the GPU-backed compositor eats for breakfast.
I draw each snake's body as one path, then paint over it a few times. This is the real drawSnake, I trimmed the eyes and name-tag out, but the glow is exactly what ships:
// Glow WITHOUT shadowBlur (the #1 canvas rasterization killer): a wide // low-alpha stroke under the body reads as neon at a fraction of the cost. const sat = s.bot ? 30 : 65; if (!s.bot) { c.strokeStyle = s.buff ? 'rgba(255, 200, 60, 0.20)' : `hsla(${hue}, 90%, 60%, ${s.boost ? 0.22 : 0.13})`; c.lineWidth = r * (s.boost || s.buff ? 4.4 : 3.4); // wide, faint HALO c.stroke(); } // Cosmetic skin: an extra wide low-alpha halo in the skin's accent colour, // layered under the body. Same glow language as the buff aura (no shadowBlur). if (!s.bot && s.skin > 0) { const acc = skinAccent(s.skin, s.boost, s.buff); if (acc) { c.strokeStyle = acc; c.lineWidth = r * (s.boost || s.buff ? 5.4 : 4.4); c.stroke(); } } c.strokeStyle = `hsl(${hue}, ${sat}%, ${s.boost ? 58 : s.bot ? 40 : 48}%)`; c.lineWidth = r * 2; // solid BODY c.stroke(); // Inner highlight c.strokeStyle = `hsla(${hue}, ${sat + 10}%, 68%, ${s.bot ? 0.3 : 0.5})`; c.lineWidth = r * 1.1; // bright CORE c.stroke();
It reads like a recipe once you see it. Every width is a multiple of the snake's on-screen radius r, which I love because it means the glow just grows with the snake for free, no special-casing big worms. Bottom to top: a wide faint halo (r·3.4 at 13% opacity), an optional skin-colored halo tucked under it, the solid body (r·2), then a thin bright core (r·1.1 at 50%). Four passes over a path I already built. When you hit boost, I just make the halo fatter and hotter (r·4.4, 22%), same trick, turned up. And bots get no glow at all, which happens to be cheaper and is a deliberate tell: real players glow, bots look flat, so you always know who you're chasing. I didn't plan that dual-purpose, I just noticed it worked and kept it.
wide + faint under, thin + bright on top· drawSnake · four stroke() passes
The same path is stroked four times, back to front: a wide skin-accent halo, a neon halo, the solid body, then a thin bright core. Where the translucent passes overlap, the center of the stroke, their alpha stacks toward white, which is exactly the brightness falloff a gaussian blur would have produced. The difference is that these are ordinary fills the compositor can hardware-accelerate, so a snake's entire glow is a handful of cheap draws with zero allocation on the hot path.
The world border and the buff aura use the identical idiom, a fat low-alpha stroke under a crisp one. Once you stop reaching for shadowBlur, "glow" just becomes "draw it again, wider and fainter."
03 · The context: opaque, desynchronized, DPR-capped
Before I draw a single stroke, there are three little decisions at the moment I grab the canvas. Each is basically one word, and each quietly sets the ceiling for everything after, I only understood two of them properly after the fact.
// Cap the backing-store scale: full Retina (x2) quadruples canvas raster // memory for no visible gain in a fast-moving game. const DPR = Math.min(1.5, devicePixelRatio || 1); const canvas = document.getElementById('game'); // alpha:false lets the compositor treat us as opaque; desynchronized bypasses // the DOM compositing queue for lower latency where supported. const ctx = canvas.getContext('2d', { alpha: false, desynchronized: true });
alpha: false is me promising the browser the canvas is fully solid, nothing shows through it. By default a canvas is a see-through layer, so the compositor has to blend it against whatever's behind it, every pixel, every frame. Tell it "I'm opaque" and that whole blend step just vanishes; it can copy my pixels straight across. My canvas is full-screen and never reveals anything behind it, so this was free.
desynchronized: true asks the browser to let my drawing skip the usual queue the rest of the page waits in, so a frame I paint reaches the actual glass a hair sooner. On a game you steer by feel, shaving a few milliseconds off input-to-pixel is worth one boolean. Honest caveat, though: I spent an evening convinced it was doing absolutely nothing on my machine, and it more or less wasn't, it's a hint, not a promise, and how much it helps depends on your browser and GPU. I left it in because it's free where it works and harmless where it doesn't.
The DPR cap is the sneaky-important one. "Retina" screens report devicePixelRatio = 2 or even 3, meaning the browser wants to render at 2× resolution for crispness, but the pixel count goes up with the square of that number, so 2× DPR is 4× the pixels to fill every frame. I clamp it to 1.5. In a game where the camera never stops moving and everything's sliding around, I genuinely cannot see the difference between 1.5× and 2×, but the GPU sure can feel it. The one thing to watch is that DPR has to flow through your actual draw math too (lineWidth = 3 * DPR, font = 900 ${13 * DPR}px), otherwise your lines and text come out fuzzy at the scale you picked. Learned that one by shipping fuzzy text.
04 · The frame: draw only what's visible
The cheapest stroke is the one I never draw. So drawSnake starts by checking all four screen edges and just leaving early if the snake is entirely off-camera:
// Visibility culling: skip snakes whose whole body is off-screen. const [hx, hy] = p.w2s(s.x, s.y); const span = s.len * SPACING * p.zoom + 100; if (hx < -span || hy < -span || hx > p.w + span || hy > p.h + span) return;
The fiddly bit is that span padding, it's the snake's own on-screen length. I need it because a 40-meter giant can have its head way off the left edge while its body still snakes across my whole screen, and I really don't want to skip drawing that. Got it wrong the first time and had giants popping out of existence at the edges. With the padding right, the payoff is lovely: on a packed server I might be surrounded by dozens of snakes but only ever actually paint the few overlapping my camera. Drawing cost tracks what you can see, not what exists, which is the client-side echo of the same area-of-interest idea I use on the server, if you want the netcode side of it in the architecture post.
Two more "don't overdo it" caps, and then I'll stop listing:
// Minimap at ~7 Hz, nobody reads it at 60. if (performance.now() - mmLast > 150) { mmLast = performance.now(); drawMinimap(me, map); } // Particles: hard cap, oldest evicted first, the array can't grow unbounded. if (particles.length > 250) particles.splice(0, particles.length - 250); // …and per explosion: n = Math.min(60, 12 + Math.sqrt(lenSeg) * 4);
The minimap redraws a little silhouette of every snake in the whole arena, which is not cheap, so I only let it run about 7 times a second. Nobody reads a minimap faster than that; redrawing it 60 times a second would just be me setting money on fire. And the particles from explosions are capped twice over: each boom spawns at most 60, and the whole pool is hard-limited to 250, oldest thrown out first. This one matters emotionally as much as technically, the worst possible time to run out of frame budget is a big multi-snake pile-up, which is exactly when a naive particle system would spawn thousands of them and choke. A fixed-size pool also means I'm not constantly allocating, so the garbage collector barely wakes up and I don't get those random little GC stutters. (And yes, the particles are drawn with a bright core dot and no shadow either. Same stubbornness.)
05 · The sneak: HUD writes that don't touch the DOM
This is the one that actually got me, and it's a little embarrassing because it isn't even in the canvas. The score, the leaderboard, the online count, that live growth rate, all of that is just HTML sitting on top of the game. And touching the DOM is expensive in a way that's invisible to a canvas profiler: the moment you set innerHTML or textContent, the browser might have to re-measure the page layout, a "reflow", right then, blocking the frame you were about to ship. I had a butter-smooth canvas loop being quietly murdered by a leaderboard I'd stopped thinking about. Took me a while to even suspect it.
I fixed it two ways. First, the whole HUD update just bails unless 250 ms have gone by, so it runs about 4 times a second instead of 60, a leaderboard doesn't need to update every frame. Second, and this is the bit that actually saved me, I only write the leaderboard to the page when the markup actually changed:
function updateHud(me) { if (performance.now() - lbTimer < 250) return; // ~4 Hz, not 60 lbTimer = performance.now(); // …build the leaderboard HTML string… if (html !== lastLbHtml) { lbEl.innerHTML = html; lastLbHtml = html; } // diff-guard }
Building the string is cheap; it's the writing it into the page that can cost you a reflow. So I build it every time but only touch the DOM when it's genuinely different from last time. A leaderboard that hasn't moved now costs one string comparison and zero layout work. Between that and the throttle, the whole HUD basically disappears from the frame, and the canvas keeps the 60fps it was quietly leaking to an overlay I'd forgotten was even there.
06 · The result, and the honest tradeoffs
shadowBlur, a fixed-size particle pool, and a GC that barely wakes up. Even in a crowded arena, the frame stays whole.Looking back, none of this was clever. It's just a list of things I decided not to do: don't blur, don't render at 2× when 1.5× looks the same, don't draw what's off-screen, don't redraw the minimap every frame, don't touch the DOM unless something changed. Each one is a line or two of code. Together they're the whole difference between a canvas that holds 60fps in a crowd and one that hitches the second things get fun.
And I don't want to oversell it, so here's what it actually costs me. The DPR cap really is slightly softer, if you freeze a screenshot on a Retina display and go looking, you'll find the fuzzier edge. I made my peace with that; I optimized for the game in motion, not the paused screenshot, and in motion I honestly can't tell. The stacked-stroke glow is also cruder than a real gaussian, it's four fat layers pretending to be a smooth falloff, and if you press your nose to the screen you can see the seams. At the distance and speed you actually play at, it just reads as neon, for a rounding error of the cost. And desynchronized, as I already admitted, is more of a "sure, why not" than a load-bearing win. I'm at peace with all three.
If there's a single thing I took away from building this, it's that "make it feel smooth" turned out to mostly mean "find the expensive thing you're doing 60 times a second and just... stop." A halo that's secretly four strokes. A minimap that's secretly 7 Hz. A HUD that mostly doesn't write. You see a glowing, busy arena; my profiler sees a nearly empty frame. That gap is the whole trick.
Thanks for reading. Now go grow or die. The growordie team