
The bottleneck wasn't the game. It was the scoreboard.
I profiled my tower-defense expecting the towers and enemies to be the expensive part. They weren't. The frame budget was going to the HUD redrawing itself sixty times a second — and one Phaser method that re-renders even when nothing changed.
Overserved is a tower defense with a lot going on per frame — dozens of enemies walking a path, ten or fifteen towers picking targets and firing, projectiles, auras, particle pops. It ships as a phone app, so eventually I had to stop hand-waving and actually look at where the frame time went.
My money was on the obvious suspects: the targeting loops. Every tower, every frame, scanning every enemy to find the one nearest the exit. That’s the textbook N×M cost in a TD, and it’s the first place anyone looks.
It was fine. Targeting already bailed out the instant a tower was still on cooldown, so the scan only ran when a tower was actually about to shoot. Enemy counts are capped per wave. The gameplay math was cheap.
The expensive part was the scoreboard.
The HUD was redrawing itself, frantically, forever
The game loop ends every frame with one call: refresh the HUD. Reasonable. Except “refresh the HUD” had quietly grown into rebuild the entire HUD from scratch — the resource bar, the wave-preview panel, every card in the build palette, the play and fast-forward buttons, the tooltip — all torn down and redrawn, sixty times a second, whether or not a single pixel of it had changed.
None of that is the game. It’s the numbers around the edge of the screen, re-rendering in a panic while nobody’s looking at them changing, because mostly they aren’t changing.
Rounded rectangles are the worst offenders. A game engine can’t just remember a rounded rectangle — every frame it re-computes the corner arcs and triangulates the shape from scratch. I had somewhere north of twenty-five of them being rebuilt every frame for panels that update maybe twice a minute.
But the real prize was hiding in the text.
setText is polite. setColor is not.
Here’s the gotcha, and it’s the kind that hides for months because nothing about it looks wrong.
Phaser has two methods I was calling on every HUD label, every frame: one to set the text, one to set its color. The text one is polite — if you hand it the same string it already has, it shrugs and does nothing. So I assumed the color one behaved the same way.
It does not. setColor re-rasterizes the entire text — repaints it to a canvas, re-uploads that canvas to the GPU as a fresh texture — every single time you call it, even when the color is identical to what’s already there. Uploading a texture to the GPU is one of the most expensive things you can do in a frame, and I was doing it a dozen times over, sixty times a second, to set colors that hadn’t moved since the level loaded.
Chaining the two reads so innocently: set the text, set the color. The text half smartly skips the work. The color half throws it all away and does it anyway.
The fix is one boring idea, applied everywhere
There’s no clever algorithm here. The fix is a sticky note that says only redraw what actually changed.
Every panel now computes a tiny signature of its own state — the wave number, the selected tower, whether you can afford this card — and compares it to last frame’s. Same signature, skip the rebuild entirely. Every color change is guarded by remembering the last color and only calling setColor when it’s genuinely different. The satisfaction bar over each enemy’s head only redraws when its value moves, not when the enemy takes a step. Same for the heat gauge on each tower.
While I was in there I found the smaller cousins of the same disease. The hotbar was re-reading and re-parsing the entire save file — off the disk — about a hundred and eighty times a second, just to display how many of each item you’re carrying. Numbers that change when you click a button. That’s cached now. Distance checks that only needed to know “is this within range” were computing exact distances with a square root; comparing squared distances skips the root and gives the identical answer. The little text pops on every conversion were being created and thrown away one at a time; now they come from a pool.
Then the asset diet — the art was shipping at up to eight times the resolution it’s ever displayed at, and the cutscene panels were PNGs where WebP is visually identical at a tenth the size. The whole build dropped by about a quarter, with not one visible change on screen.
None of it touched how the game plays. I verified that the hard way: driving real levels start to finish, including a boss fight, and checking the numbers came out the same.
The lesson I keep re-learning: profile the thing that runs unconditionally. The costly code usually isn’t the clever code you’re proud of. It’s the boring maintenance work you set up to run every frame and then forgot was running at all.