Emberfall on real phones: fixing an 88 ms frame, pixel-art coastlines, and readable fonts
Emberfall runs on its own small engine: canvas rendering, 16-pixel tile maps, tap-to-move with pathfinding, and front-view battles. Everything looked fine in a desktop browser. Then I played Chapter 2 on my phone.
An 88 ms frame
Chapter 2 is on the coast: a salt marsh, a smugglers' cove, water everywhere, and fog. On my phone it was visibly laggy. Measured headless at a phone's resolution, a frame took 88 ms, about 11 frames per second.
Two things were to blame:
- the water was a scaled pattern fill over the whole map, done twice per frame, at the device's full resolution of about 1080×2400,
- a full-resolution fog layer was composited on top.
The fix was to paint the world at art resolution (one canvas pixel per art pixel) and let CSS scale the canvas up to the screen. Pixel art doesn't gain anything from being drawn at 1080p. Battles keep full resolution for crisp text. Every map now renders in about 16.7 ms, a steady 60 fps.
The rule: never fill a scaled pattern or a full-screen layer at device resolution every frame. And the meta-rule, which I've since applied to every game: bots and screenshots can't see lag; only a frame-time measurement can.
A related one, found much later: even a scene at rest was redrawing at 60 fps, which on an idle field screen is pure battery waste. The loop now drops to a much lower rate when nothing moves (17–34 fps on an idle field or title screen) and runs at full speed only while something is animating. The battery post covers this across all my games.
Square coastlines
With performance fixed, the next complaint was aesthetic: square coasts. Where sand met water or grass met cliff, the tile grid showed as hard square edges.
blendGround() now gives every pair of natural materials a rounded, noisy edge. Each pixel near a border takes a Gaussian-weighted share of its neighbours' materials plus a little noise, and coasts get foam and a band of wet sand. A follow-up fixed stray specks of water that appeared where a material barely reached, by fading the noise out near the edge of its reach.
Ice, decks and floors keep their grid lines on purpose. The ice puzzles depend on players seeing the cells.
Fonts on a phone
The game originally used a pixel font everywhere. On a phone, at real reading sizes, it misread: f, 2, 5 and 8 were too easy to confuse. Body text is now Nunito, a rounded, very readable sans, and the pixel font is used only for titles and names, where it adds character without slowing reading.
Balancing with a battle simulator
npm run sim plays every battle in the game headlessly using the Auto AI. The targets: under one defeat per run, and at least an 85% boss win rate on Auto. The final measurement across all five chapters, 30 runs, was a 98% boss win rate, 88–93% on the final boss, and 0.47 defeats per run.
The averaged sim missed one thing, which a second tool caught. A per-encounter report showed one group of a spider and two rats winning 99% of fights against a level-1 Wren. Averaged across the whole route, it disappeared. Cut from the early game. A sim that averages a whole route hides one killer group.
There's also a bot that plays the actual story in a headless browser, chapter by chapter: 72 goals in Chapter 4, 90 in Chapter 5, with no console errors allowed. It skips most fights, so it arrives under-levelled. That's the bot being lazy, not the balance being wrong.
Audio and Bluetooth headphones
I test on a phone with Bluetooth headphones, which exposed problems I'd never hear on speakers:
- noise-based drums sounded harsh after Bluetooth compression, so they were replaced with tuned percussion,
- after a stall, the music scheduler would play every missed note at once, so missed notes are now skipped,
- Bluetooth links go to sleep in silence and clip the next sound when they wake, so a very quiet keep-alive signal (about -75 dB, inaudible) keeps the link up while playing,
- audio suspends entirely when the app goes to the background.
The music itself is 22 recorded CC0 tracks, about 50 minutes, replacing the 30-second synth loops of the first slice. There are playlists per place and story moment, with crossfades.
Ghost taps
A small but nasty one: tapping to dismiss a line of dialogue would sometimes also click the menu button that appeared underneath. Buttons now accept a press only if it started on them. The same family of bug came back later in a much bigger way: closing an ad tapped the game.