I spent eight days building a small physics game in Flutter and put it online. Then, before telling anyone about it, I opened the live link and played it like someone who had never seen it.

The first level took one arrow. The tower came down. The win panel slid up and said Level Complete, and under it, where the star rating goes, were three empty rectangles.

It had been like that for two days.

The line

Text(
  '★' * s.stars + '☆' * (3 - s.stars),
  style: const TextStyle(
    color: Cfg.targetColor,
    fontSize: 34,
    letterSpacing: 4,
  ),
),

Enter fullscreen mode Exit fullscreen mode

Three filled stars for a clean clear, fewer as you spend more arrows. It reads fine. It runs fine. On macOS in the simulator it looked exactly like I wanted.

On the web build it was three tofu boxes, because the star is U+2605 and the font the page was drawing with does not have that glyph.

The mute button had the same problem. It was a Text widget holding two speaker emoji. Same result: a small round button with a broken square inside it.

Why this does not show up where you are looking

Flutter web draws text through CanvasKit, using the fonts you bundle plus a fallback set it fetches at runtime. That fallback covers a lot, but it is not a promise about every symbol in Unicode, and when it misses, you get the box. Locally you often never see it, because your desktop or simulator has a system font sitting there with the glyph in it.

Two more reasons it survived:

Tests do not catch it. In a Flutter widget test the default font draws every character as a box already, so a golden test cannot tell a working star from a broken one. There was nothing to fail.

I stopped reading my own UI. By day three the win panel was furniture. I was watching the physics, not the panel.

The fix is boring

Material icons are an icon font that Flutter bundles. They always resolve, on every platform, offline.

Row(
  mainAxisSize: MainAxisSize.min,
  children: [
    for (var i = 0; i < 3; i++)
      Padding(
        padding: const EdgeInsets.symmetric(horizontal: 3),
        child: Icon(
          i < s.stars ? Icons.star_rounded : Icons.star_border_rounded,
          color: Cfg.targetColor,
          size: 38,
        ),
      ),
  ],
),

Enter fullscreen mode Exit fullscreen mode

And the mute button:

Icon(
  s.muted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
  color: Colors.white,
  size: 20,
),

Enter fullscreen mode Exit fullscreen mode

The funny part is that the level select screen in the same game was already doing it the right way, with Icons.star_rounded. Two screens, two authors, eight days apart. Both of them me.

The rule I use now

Never draw UI with a decorative Unicode character or an emoji inside a Text widget. Either use an Icon, or bundle a font you have checked yourself and set fontFamily on that text explicitly. If a character is not in ASCII and it is load bearing for the interface, treat it as an asset, not as text.

A quick way to find them all in your own project:

grep -rn "[^\x00-\x7F]" lib/ --include="*.dart"

Enter fullscreen mode Exit fullscreen mode

That printed seven lines for me. Two of them were bugs, one was an em dash that was fine, and the rest were comments.

The part that is not about fonts

While I was in there playing properly, I also found that the heads up display still said BALLS 3. Two days earlier the ball had become an arrow, with a shaft and a fletching and a bow to fire it from. The counter never got the message. Neither did the title screen, which was still telling people to fling something.

None of this was a physics bug. The Box2D world was correct the whole time. Every one of these lived in the first thirty seconds of the game, which is the only part most people ever see.

I think that is the actual distance between code that works and a product that ships. The engine was right. The experience was not, and no test I could reasonably have written would have told me. The only thing that told me was opening the deployed URL and playing it with my hands.

The game

It is called Topple. A bow and arrow knock down physics game in Flutter, using Flame and flame_forge2d for real Box2D physics. Thirteen levels, three materials: wood, stone that works as a wall, and glass that shatters into shards and lets the arrow through. Nothing about a collapse is animated. Every block has its own mass and friction and finds its own way down.

Play it: https://saqrelfirgany.github.io/topple/

Code and a devlog with one entry per day: https://github.com/saqrelfirgany/topple

If you have a Flutter web build up somewhere, go and open it on a machine that is not yours. I would like to know what you find.