In September, I spent about ten days, with Claude, optimizing the fight player. On big fights (a 30-leek battle royale, a fight full of effects), the animation wasn't smooth, especially on modest machines. And dulph had reported a more specific problem on the forum: in Firefox, the animation froze for about a second at every explosion (bazooka, grenade launcher, meteorite, trebuchet), then resumed several actions behind.
Here is what we found, what worked, and what didn't.
| | Before | After | |---|---|---| | 8-leek fight with 71 effects, CPU slowed down ×6 | 12 to 19 fps | 55 to 60 fps | | Same fight, CPU slowed down ×12 | 7 to 10 fps | close to 40 fps | | 30-leek battle royale, CPU slowed down ×4 | 20 fps | 53 fps | | Same fight, stutters (p99) | 244 ms | 31 ms | | Explosions in Firefox | freeze of about one second | no more freeze |
"CPU slowdown" is an option of Chrome's developer tools that simulates a slower machine: it lets you see on a recent PC what a player experiences on an old laptop. The p99 is the duration under which 99 frames out of 100 fall. It measures the stutters you actually feel, while the average hides them.
A good part of the work went into building reliable measurements:
And a few rules, learned by getting things wrong:
The first finding, and the most important one: with the CPU slowed down ×6, drawing the map, the leeks and the effects took only 1.4 ms per frame. The rest of the time went into Vue, the interface framework.
The fight player's engine was stored in a Vue reactive object (a ref()). Every property read went through a proxy, and every write (position, life, animation counter, thousands per frame) scheduled a new render of the interface.
The engine is now outside of reactivity (shallowRef(markRaw(...))). The interface updates on a counter that the engine increments, with automatic throttling: we measure how much each interface render costs, and space them out so they only take a small share of the time. Without this throttling, a fixed refresh at 20 per second did worse than before on a very slow machine (7 fps instead of 10).
Two pitfalls along the way:
The animation loop changed as well. It was a 16.7 ms setTimeout started after each frame's work, which gave about 50 frames per second on a 60 Hz screen, without being synced to the screen. It now uses requestAnimationFrame, with a limit of 80 frames per second so that it doesn't draw 144 times per second on fast screens. The threshold has to stay under two refreshes of a 144 Hz screen (13.9 ms): at 70, two frames out of three were skipped and we dropped to 48. In a hidden tab, the fight now pauses.
On the 8-leek fight: 47 → 54 fps at full speed, from 12-19 to 40-50 fps with the CPU slowed down ×6, from 7-10 to 23-29 fps at ×12, and stutters divided by 4 to 5.
On the battle royale, the player still froze four times per second. In each long frame, 74 ms went into rendering the interface: the 30 turn order thumbnails and the 17 life bars, with their tooltips, were rebuilt as a whole, while only one leek's life changes between two frames. Each entry is now memoized (v-memo) on the values it displays.
The most instructive bug was in the fight log. In Vue, when a v-for loop is placed on a `, its key has to be on the itself. It was on its children, so Vue compared the list position by position. Since the log window slides by one line at each action, every position changed content, and every displayed line was rebuilt at each new line, several times per second. Moving the key was enough: p99 79.8 → 44.7 ms. The same bug existed in four other lists of the site (workshop, item help, statistics, market). The ESLint rule meant to catch it ignores children carrying a v-if`, which is exactly this case.
The interface render also ran at the end of the same task as the map drawing, so a frame paid for both. It now runs in a separate task: the map frame is displayed on time, and the interface updates right after.
We also introduced a regression, quickly fixed. To reduce the cost of the log, we had first limited its window to 80 lines instead of 250. It treated the symptom (the real cause was the misplaced key) and broke scrolling: scrolling up the log, you reached the top of the window in five mouse wheel notches, the window slid, the scrollbar repositioned itself, and it started over endlessly. The window is back to 250 lines, and measurements confirm they no longer cost anything (58.1 vs 57.9 fps).
On the battle royale, with the CPU slowed down ×4: from 20 to 53 fps, and a p99 from 244 to 31 ms.
Once Vue was put back in its place, drawing became the largest share of the frame time. To find out where it went, we freeze a frame and redraw it hundreds of times, removing one element at a time: what disappears from the drawing time is what the element cost. On the 71-effect fight, effect icons took 39% of the drawing, their texts 20%, shadows 9% and name plates 4%. A single frame had 267 drawImage, 160 fillText and 151 measureText calls.
I had a few rough ideas to speed up drawing, including this one: merge the effects with their texts. An effect badge (chip or weapon icon, value, duration) only changes when an effect is added, stacked or removed, but it was entirely repainted every frame: a 250-pixel image scaled down to 18, two backgrounds, two measured texts. It is now painted only once, at the exact size at which it is displayed, and kept as an ImageBitmap: each frame only does one copy per effect. With the CPU slowed down ×6, the 71 badges go from 3.2 to 0.7 ms per frame, and the full drawing from 5.8 to 3.3 ms.
The first version came out blurrier, with 13% less sharpness. The cause: two half-pixel offsets. An image copied at scale 1 but straddling two pixels gets entirely blended with its neighbors. Once the badges were aligned on the canvas pixel grid, sharpness went back to the level of direct drawing (74.6 vs 75.8). To measure it, we used "gradient energy", the sum of differences between neighboring pixels, which collapses when an image is blurry. A simple pixel difference can't tell "shifted by one pixel" from "blurry".
Badges zoomed ×5. Top: the first version; bottom: the version aligned on pixels.
Two more pitfalls: an image that is still loading passes the "can we draw it?" test without painting anything, and the badge prepared at that moment stayed empty for the whole fight; and checking that the font was loaded, for each of the 71 badges, cancelled the whole gain on its own.
The same principle was then applied to name plates: background, name, life bar and badges are prepared as a single image. The text cost almost nothing (about 5 µs per plate). What cost was the number of calls: a dozen per plate and per frame. In Chrome slowed down ×4, with 20 plates, drawing goes from 4.4 to 2.9 ms per frame. A plate is only frozen after two identical frames: while HP scroll after a hit or during a move, it is drawn directly, as before.
Firefox was much slower than Chrome on the fight player: at equal CPU quota, it produced about half as many frames, and four times fewer when the quota was very low. To understand why, we measured the cost of a drawImage in the page:
| Operation | Firefox | Chrome | |---|---|---| | Rotated image (a hand, a weapon) | 60 to 85 µs | 8 µs | | Resized image | 10 to 27 µs | 2 µs | | 1:1 copy aligned on pixels | 2.5 µs | 1 to 2 µs |
In Firefox, a rotated image costs tens of times more than an aligned copy. And counting every drawing in one frame of the battle royale, we found 199 aligned copies, 70 resized images and 86 rotated images, including 41 shadows, 30 hands and 15 weapons.
Hands. A leek's hand is a disc: rotating it with the weapon doesn't change a single pixel. Yet it was drawn in the weapon's rotated frame. It is now placed outside of that frame, aligned, at its exact size. We couldn't simply undo the rotation with an inverse rotation: a leek facing left is drawn mirrored, and a mirror combined with a rotation can't be undone by the inverse rotation. The hand's position is therefore read directly from the transformation matrix.
The weapon and its hands. Second idea on my list: bake the hands onto the weapon. The aiming angle only changes during an attack: over 15 seconds of fight, we counted 13,573 rotated weapon drawings for only 195 different silhouettes. So the weapon and its two hands are drawn once, already rotated, into an image that is then copied in one go, aligned. Three rotated drawings are replaced by one copy.
The first version measured slower and blurrier, and we almost gave up on the idea. Both problems came from the implementation:
willReadFrequently), which puts it in CPU memory with a different filtering than the game canvas. Without it, the weapon is as sharp as when drawn directly;Result: +2% frames per second in Chrome slowed down ×6, +5% at ×10, and +4% in Firefox. In a heavily throttled Firefox (below about 15 fps), the gain didn't show at all: at that level, the frame is dominated by something else. You have to measure in the range that matches real machines.
Third idea on my list: merge the leek and its hat. For performance, it wouldn't have changed anything. A leek's body is already a pre-rendered image (the game converts SVGs once and for all), and only a few small, aligned hat drawings were left, below what we can measure.
It did, however, point to a visual flaw. The body's shadow and the hat's shadow were two successive semi-transparent drawings: where the hat covers the head, they overlapped and the shadow got darker, 80% opacity instead of 55%. Both shadows are now merged into a single silhouette before being drawn.
The shadow of a leek wearing a hat, before and after.
Shadows were also drawn rotated, one per leek and per frame. Preparing them already rotated meant stopping their slight "breathing" (they grew and shrank by 2.5% with the leek). I first wanted to keep that movement, then chose performance once the gain was measured: shadows no longer breathe, and in Firefox drawing a frame of the battle royale goes from 3.35 to 2.55 ms. Same treatment for the weapon's shadow (2.65 → 2.45 ms) and for the team color diamonds under entities (2.70 → 2.35 ms).
The map background isn't redrawn every frame, only at launch, when the window is resized and when the mode changes (night, tactical). But a redraw cost 28 to 45 ms in Chrome slowed down ×4: a loop that cuts the edges of zones (water, grass, ice) ran up to the canvas width in pixels instead of the number of cells, and drew about 46,000 diamonds outside the map. Once the loop was bounded, it takes 9 to 32 ms depending on the map, with a pixel-identical result. In Firefox, it changed nothing: it rejects what is drawn outside the canvas almost for free, while Chrome pays for every call. On this player, cost is counted in number of calls in Chrome, and in drawn pixels in Firefox.
Obstacles were placed at fractional positions, straddling two pixels, and therefore resampled every frame. Aligned on the pixel grid, they are sharper, and in Firefox drawing them goes from 2.5 to 1.6 ms per frame (a straddling copy cost 40 µs, versus 3 µs aligned).
Obstacles zoomed ×5, before and after alignment on pixels.
Looking closely at the background, we also found why the grid took the color of the zones: cyan on the ice field, blue on the beach. To paint a zone, the game paints its texture over the whole pattern, then removes the cells that aren't part of it one by one. On the shared edge of two removed cells, the anti-aliasing of each removal only clears part of the pixels, and up to a quarter of the texture remained. The cells are now removed in a single path.
The grid on the ice field, before and after.
Along the way, a small obstacle could be drawn on top of a large 2×2 obstacle standing in front of it. That's fixed too.
dulph's problem remained. An explosion drew about 345 circles per frame for one second, each with its own color. A meteorite shower stacks five explosions and their trails, which adds up to more than 1,700 circles and 200 flames in the same frame. In Firefox, a circle costs about 5 µs, and an enlarged flame 15 to 27 µs.
Fireballs, flames and gas are now prepared as images and copied aligned. In Firefox, drawing these animations costs about 70% less for explosions, and 75 to 80% less for flames and gas. This change only applies to Firefox: in Chrome, whose canvas goes through the graphics card, these copies doubled the explosion's time, so it keeps the previous drawing.
dulph tested again: "It's visibly better, even if I still see some slowdowns", with a Firefox profile recorded during the freeze. That profile was decisive. The freeze only remained at the first explosion of the fight, where the browser was waiting for the graphics card: as long as the fireball images weren't ready, the game drew them the old way and prepared them at the same time, in the middle of the fight. Everything is now prepared when the fight launches (about 20 ms), and nothing is created during animations anymore. On his fight, the first meteorite shower goes from 481 to 131 ms of total drawing time.
dulph's answer: "It's perfect, no slowdown visible so far". Thanks to him for his feedback, his profile and his patience.
To follow all this in the game, administrators now have a frame counter in the player's bar: current value, a curve of the last 30 seconds, minimum, average, maximum and p99. It counts the frames of each half second instead of averaging "1 / frame duration", because that average is flattering: a 10 ms frame and a 50 ms frame really make 33 frames per second, but 60 if you average the inverses.
An "unlocked" mode runs the loop without waiting for the screen, to see how much headroom is left above 60 frames per second: on a two-leek fight, the engine produces 429.
On the 71-effect fight, with a CPU slowed down six times, the player now holds 55 to 60 frames per second, compared to 12 to 19 before. Most of the gain didn't come from drawing but from the interface, and we wouldn't have found it without measuring first. Everything has been live since September 27.
If you still see slowdowns in the fight player, feel free to report them on the forum, with your browser and your machine: that's how dulph's problem could be found.
Impossible de charger les données du jeu.
Vérifiez votre connexion et réessayez.