Blog · For developers
60 fps physics in Flutter: how Upsling runs on a $100 Android phone
How a Flutter and Flame game holds 60 fps and a one-frame restart on a Galaxy A06: a pure-Dart simulation, pooled components, and the batching that hurt.
Upsling is a one-touch game: hold, and a comet orbits the nearest anchor; let go, and it flies off along the tangent. That is the whole input. Everything the player feels comes from timing, so the game has one non-negotiable requirement: the frame you let go on has to be the frame the comet leaves. On a flagship that is easy. On the phones most of the world plays on, it is the project.
This is what it took to hit the budget below on a Samsung Galaxy A06, a phone that sells for about a hundred dollars, with Flutter and Flame.
The budget
| What | Target |
|---|---|
| Frame rate | The display’s rate: 60 Hz, or 90 and 120 Hz where the phone has it |
| Slow frames, mid-range and up | at most 5% of frames per run |
| Slow frames, low-end Android | at most 10% per run at 60 Hz |
| Frozen frames (700 ms or more) | none after the first run |
| Restart, from fail to the next run | one frame or less |
| First touch after launch | 3 seconds or less |
A slow frame is one where either the build phase (Dart, on the UI thread) or the raster phase (the GPU thread) takes longer than one display frame. We count the two separately, because they fail for different reasons.
The simulation never touches Flutter
The first decision was architectural, and it is the one I would make again on any Flutter game. The simulation is pure Dart: physics, difficulty, course generation, rewards, run state. No Flutter imports, no Flame imports. It runs on a fixed timestep of 1/120 s, is seeded, and is deterministic. Flame is a thin layer that reads the simulation state every frame and draws it.
The loop is the textbook accumulator with one addition: after a long stall the backlog is dropped instead of fast-forwarded, so a phone that just came back from the lock screen does not spend a frame simulating physics nobody saw.
class FixedStepLoop {
FixedStepLoop(this.simulation, {required this.step, required this.maxSteps});
final Simulation simulation;
final double step; // 1 / 120
final int maxSteps;
/// 0.1 on impact, 0.5 in the first release window, 1 otherwise.
double timeScale = 1;
double _accumulator = 0;
/// Fraction of a step left over, for render interpolation.
double get alpha => _accumulator / step;
int advance(double realSeconds) {
_accumulator += realSeconds * timeScale;
var steps = 0;
while (_accumulator >= step) {
if (steps == maxSteps) {
_accumulator = 0; // a stall: drop the backlog, don't fast-forward
break;
}
simulation.tick(step);
_accumulator -= step;
steps++;
}
return steps;
}
}
Three things fall out of this for free:
- Slow motion is identical everywhere. The impact freeze (time scale 0.1) and the first-release window (0.5) live in the simulation, so a unit test and a 120 Hz phone see exactly the same frames.
- Courses are checked before you see them. Each new anchor is validated by simulating releases from the previous anchor’s orbit at 120 samples per second, in both spin directions, with gravity, edge bounces and drifting dust. If the longest landing window is under 60 ms, the generator removes dust, then widens the ring, then moves the anchor closer. The check costs under 2 ms per anchor.
- The engine is swappable. If Flame ever stood in the way, only the render folder would change.
Restart in one frame
A fail in Upsling is a doorway, not a wall. The design says the next run starts the moment you touch the screen, and “starts” means the comet is already orbiting the first anchor, with your finger counting as the first hold.
That rules out scene changes and allocation. Run state is a struct that resets. Anchors, dust and particles come from pools. Flame components are created once and reused: shown, hidden and repositioned, never added or removed per run. And the next course is not generated at restart; it is built during the current run, one anchor per frame, into a second pooled course. Restart swaps the two.
Measured restart cost on real phones in Firebase Test Lab:
| Device | Restart |
|---|---|
| Galaxy S24, 120 Hz | 11 to 206 µs |
| Galaxy A15, 90 Hz | 17 to 37 µs |
| Galaxy A10 (2019), 60 Hz | 45 to 140 µs |
A frame at 60 Hz is 16,700 µs. The only expensive restart is the very first one, which builds its course while the app is still loading.
The frames were GPU-bound
With the simulation at 2 to 4 ms of build time even on the 2019 phone, the slow frames were all raster. On budget Android the GPU decides everything, and the question becomes: how many draw calls, and of what kind?
The first win was the starfield. It was 120 small circles per frame, each its own draw. Replacing them with one point draw per layer and size cut raster time by about 3 ms on the Galaxy A10 and took its slow frames from 19 to 29% down to 8 to 17%. On the Galaxy A15 they went from 9 to 48% down to 5 to 20%.
Encouraged, I tried the same idea on everything else: dust, anchor fills, the trail and the comet as one drawRawAtlas call per layer, a prerendered white disc tinted per instance with BlendMode.modulate, plus a three-frame warm-up of every effect at launch. Side by side with the unchanged build, in the same Test Lab session:
| Device | Unchanged | Batched |
|---|---|---|
| Galaxy A10, Vulkan | 2.7 to 33% slow frames (average about 15%) | 6 to 16% (average about 11%) |
| Galaxy A15, OpenGL ES, 90 Hz | 2 to 16% (average about 9%), raster 6 to 7 ms | 13 to 60% (average about 28%), raster up to 21 ms |
Impeller draws tinted atlases slowly on OpenGL ES, and the warm-up made the first run worse (worst frame 652 ms against 301 ms). A change that helped the oldest phone hurt the phone that actually sells. Reverted. The lesson is not “don’t batch”. It is that Impeller’s cost model differs by backend, and a mid-range phone on OpenGL ES is a different machine from a low-end one on Vulkan.
Where the long frames really come from
Every device showed a few frames of 200 to 400 ms in the first run. The timeline said they were the app’s first frames at launch, before anything is on screen, while the GPU compiles its pipelines: 287 ms at 107 ms after launch on a realme C53, 202 ms and 132 ms before the first frame at 765 ms on the Galaxy A06. They happen behind the launch screen, before the player can touch. Not a gameplay hitch, and not worth a fix.
During play, the Galaxy A06 showed one frame of 60 to 70 ms twice: early in the first run, and when the fail screen first appears, which is the first use of its fonts. The budget says zero frozen frames after the first run, and that holds.
How we measure
The app measures every run itself: frames, slow frames split by build and raster, frozen frames, worst frame, restart cost. On a profile build the debug panel shows the last run. With the player’s consent, release builds send the same numbers as Firebase Performance Monitoring traces.
For device coverage, an integration test lets a timing bot play for 75 seconds on Firebase Test Lab’s real phones, each run a little higher so restarts and later difficulty are covered, and prints one line per run to logcat. Five minutes of device time per phone, a few cents to a dollar per run.
Two lessons from Test Lab. Results vary a lot run to run, so compare variants only side by side in the same session, never against yesterday’s numbers. And pick your low end honestly: the 2019 Galaxy A10 is below today’s floor, and the 2024 Galaxy A06 is the reference now.
Where it landed
| Device (2024, about $100) | Slow frames, runs 2 to 6 | Raster, average |
|---|---|---|
| Samsung Galaxy A06 | 0 to 0.7% | 4.3 to 5.7 ms |
| realme C53 | 0 to 0.2% | about 4 ms |
Both inside the 10% budget with room to spare. The 120 Hz Galaxy S24 shows 0% slow frames; the iPhone 14 Pro is smooth at 120 Hz. The 2019 Galaxy A10 sits at 8 to 17%, GPU-bound at its floor, and stays a legacy check.
If you are building a Flutter game
- Keep the simulation engine-free and on a fixed step. Determinism buys you tests, replays, course validation and identical slow motion.
- Treat restart as a feature with a budget: pools, a reset struct, and a next course built during the run it replaces.
- Measure build and raster separately, on real low-end phones, in the same session as the baseline.
- Batch the obvious things, like a starfield, then measure every further batch on each backend. Impeller on OpenGL ES is not Impeller on Vulkan.
- Don’t chase the launch-time frames. They are pipeline compilation behind your launch screen.
Upsling is free on Google Play, and the world ranking all those frames feed lives at upsling.space/rankings.