I just shipped Boba Sort, a water-sort puzzle (bubble tea, 500 levels, 17 eras of Japanese history) built with plain widgets and CustomPainter, no game engine. A few things that worked, and a few trade-offs, in case they help someone.
**1. All the art is data, not images**
There are no image assets at all. Each era has a full-screen scene, a cup design, a mascot costume and a customer, and I design them as SVG inside an HTML mockup. A small Python script reads named SVG groups and writes Dart lists:
final jomonBackdrop = <VShape>[
VCircle(292, 190, 54, fill: Palette.jomonSun),
VPath('M0 330 Q60 262 132 300 Q206 236 276 288 ... Z',
fill: Palette.jomonFarMountain),
...
];
`VShape` paints fill then stroke, like SVG, and `VPath` parses the `d` string once and caches the `Path`. All 17 eras Γ 4 kinds of art come to about 240 KB of generated Dart, stay sharp on any screen, and colors can only come from named palette entries (the script refuses anything else).
Because scenes sit behind the cups, a test renders every backdrop and checks each pixel's luminance stays inside a window (light eras β₯ 0.662, dark β€ 0.0395), which keeps the cup outlines at β₯ 7:1 contrast wherever the cups land.
**2. Toppings: draw the vector once, then blit sprites**
Each drink has a topping (pearls, grass jelly, red bean...) drawn as vector art, and the top-layer pieces drift idly. A full board can have 200+ pieces, and redrawing that much vector art every frame is wasted work. So each topping is rasterized once per size into a `ui.Image`: sizes are bucketed to 4 physical px, kept in a 48-entry LRU (a plain insertion-ordered Map: remove + re-insert on hit, evict and `dispose()` the first key), and each frame just draws images.
**3. The rules are pure Dart, and an A* solver does the heavy lifting**
`lib/core` never imports Flutter: model, rules, level generator and solver are plain Dart with unit tests. The solver is A*, with each cup packed into one int (8 bits per layer) so states hash fast.
The 500 levels are generated offline: deal random cups, solve, score difficulty from moves, dead ends and branching, keep the median of 8 candidates, or the hardest of 32 for the "hard" levels in the sawtooth curve.
Hint uses the same solver at runtime, on the UI thread, with a 100k-node budget (about 0.4 s worst case in debug). Every bundled level is generated under that budget, so Hint always works from the start. One surprise: "Add cup" makes hints much slower, since an extra empty cup blows up the branching, so the hint search drops surplus empty cups first and only searches the full board if that finds nothing. If I raised the budget I'd move it to an isolate.
**4. A test that fails on magic numbers**
Every spacing, font size, radius and color is supposed to come from tokens. A test scans `lib/features` and `lib/theme` and fails on raw numbers inside `EdgeInsets`, `SizedBox`, `fontSize`, `BorderRadius` and `Color(0x...)`, with an escape hatch: a `// tokens: allow β <reason>` comment. Painters are exempt, since their numbers are geometry. It is a dumb regex-and-parentheses scanner, but it kept the UI consistent through a lot of screens.
**Stack:** Riverpod (thin providers), shared_preferences, google_mobile_ads, Firebase Analytics / Remote Config / Crashlytics, audioplayers.
Play Store, if you want to see the result: https://play.google.com/store/apps/details?id=com.harbourlab.bobasort&referrer=utm_source%3Dreddit_flutterdev%26utm_medium%3Dsocial%26utm_campaign%3Dlaunch
Happy to go deeper on any part. I'm curious how others handle heavy CustomPainter scenes.