A devlog by Neeraj Kotwani, who builds every game on Elipar.
Pool Master used to be a mint-green rectangle with one-pixel lines for cushions and flat circles for balls. I gave it a real cabinet: wooden rails with drawn grain, sight diamonds, felt lit from overhead, pocket wells with brass rims, balls with a highlight and a soft shadow.
It went from 60.3 frames per second to 5.7. A game that was smooth became a slideshow, for a picture that looks identical.
What was actually costing it
Not the detail. The detail was fine. The problem was when I was building it.
Each ball got three radial gradients — body, highlight, shadow — plus a ctx.filter = "blur(2px)" pass for the soft shadow underneath it. Written inside the draw loop, that is not three gradients. Across a full rack it is roughly 48 gradient allocations and 16 full-surface filter passes, every single frame, sixty times a second, to produce a ball that never changes.
A gradient object is not free to construct, and ctx.filter is worse than it looks: a blur is a whole-surface pass, so asking for one per ball is asking the browser to reprocess the canvas sixteen times a frame.
Why I did not blame the environment
This is the part I want to record, because the instinct is wrong and it is a comfortable wrong.
I develop against a software rasteriser — no GPU, everything drawn on the CPU. It is genuinely slow, and it is genuinely not representative of a real phone. So when a number collapses, the available explanation is always "this environment is not predictive." I have written that sentence honestly about other games and meant it.
The discipline that saves you is one command: stand the previous build up on its own port and measure it with the same harness. The old file measured 60.3 in the same environment on the same afternoon. That single number turns "this machine is slow" into "I did this," and there is no arguing with it.
Without a baseline you cannot tell a regression from a platform limitation, and you will reliably pick whichever answer requires less work.
The fix is one idea
Anything that does not change does not belong in the draw loop.
The table — cabinet, grain, diamonds, felt, pockets — is painted once into an off-screen canvas and copied in as a single image each frame. Every ball is baked into a small sprite, keyed by its number, because a ball's appearance never changes; only its position does. The soft shadow became a baked radial gradient instead of a live blur pass.
Result: 60.3 fps restored, with per-frame gradient allocations down from 46 to 1 — and that last one is the cue stick, which only exists while you are aiming.
Nothing was cut. The picture is the one I wanted.
The same mistake, three more times
Once I had a way to measure it, I went looking, and this turned out to be the most common performance bug in the whole catalogue. The instrument is embarrassingly simple: monkeypatch createLinearGradient and createRadialGradient to count calls, then play the game for a few seconds.
Treasure Trek was allocating 122 gradients a second — its sky and the player's body, rebuilt every frame, neither of which changes during play. Cached both; the count went to zero.
Neon Dash 3D rebuilt its entire backdrop each frame — sky, sun, ground, the static perspective rays — at four to six gradients a frame for a picture where nothing moves. Baked into one layer: zero.
Forest Dash was the sneakiest, because it allocated nothing at all. It had a full-screen red radial-gradient overlay for the flash when you take a hit — permanently in the page, composited on every frame of the run, in order to be visible for 0.42 seconds after an impact. Setting it to display: none when idle was worth about three frames per second on its own.
A cache is a correctness risk, so test it as one
Mini Golf caches its whole course layer, keyed by hole. That is a much bigger win than the table — turf stripes, sand grain, water ripples, all painted once per hole — and it introduces a genuinely dangerous failure mode. If the key is wrong, you render the previous hole's sand and water while the physics uses the new hole's. The ball would bounce off nothing and sail through walls you can see.
No screenshot of a single hole catches that, because the picture is always a plausible golf hole. So the test probes a known pixel: turf at hole 1, sand at the same point on hole 5, turf again on returning to hole 1, water on hole 7. That third one is the assertion that matters — it is the only one that proves the cache actually invalidates rather than merely being built once.
The part worth taking away
Two habits, both cheap. Count your per-frame allocations rather than reasoning about them — I have now been wrong about which line was expensive in four separate games, and the counter has been right every time. And before you accept that a slow number is your hardware, run the previous build through the same harness. It takes one command, and it is the difference between a fix and a shrug.
Every game named here opens in a browser tab with no download and no account: Pool Master, Mini Golf, Treasure Trek, Forest Dash and Neon Dash 3D.