This is a condensed version of the full story, originally published on Medium.
TL;DR: I built a 3D game engine for Flutter in pure Dart — renderer, physics, collision, audio, level format, no bindings, no native code. It's open source now: twenty-three packages on pub.dev, MIT-licensed, with three finished games you can play in your browser. Below: the architecture, the four bugs that taught me the most, and a platformer in under 600 lines.
This is the fourth 3D engine I've written — x86 assembly, then Java, then C++. Every language I fall in love with gets the same test. I've been writing Flutter since 2018 and teaching it to over a thousand students, and one question kept coming back: this framework draws anything at 120 fps, so where are the 3D games? The honest answer was always a list of bindings to somebody else's engine. So I wrote one that's actually Flutter's own. It's called flutter3d.
One rule shaped everything
The renderer never talks to a graphics API. It talks to a hardware abstraction layer — flutter3d_hardware, a small package of interfaces with a written compatibility promise. A rendering backend is just a package that implements them. Three ship out of the box:
-
flutter3d_impeller— production, on Flutter GPU (Metal, Vulkan) -
flutter3d_webgl— WebGL2, what the browser demos run on -
flutter3d_cpu— a software rasterizer in pure Dart
Your game picks one with a single pubspec line. And because the HAL is a public contract, you can write your own backend without forking the engine.
A software rasterizer in Dart sounds like a joke. Hold that thought.
Four bugs that taught me more than the features did
1. The racing game ran at under 1 fps for weeks. Shrinking the frame changed nothing — which was the clue: the cost wasn't per-pixel. A cube shadow atlas was sized from the sun's shadow settings and weighed 402 MB on a platform that doesn't have that to give. One setting fixed it. If shrinking the frame changes nothing, the money isn't in the frame.
2. The shadow atlas was stored upside down — while every test passed. Every check happened to view the atlas through a full-screen pass that flips its input, so the picture came back twice-flipped and matched the reference pixel-for-pixel. The lit pass sampled the texture directly and read rows nobody had drawn. Lesson: an instrument that cancels the error it's pointed at agrees with everything.
3. Every held weapon drew almost black — identically on two backends. Identical wrongness across backends means the defect lives above them. The weapon's own two-light "studio" was being encoded with the world's light buffer, so its lights were never uploaded. Right design, missing binding, and the difference was the entire picture.
4. The "joke" rasterizer became the referee. All three backends render the same golden scenes, compared pixel by pixel. When WebGL disagreed with Impeller by 19% on a particle scene, the CPU backend judged who was lying (WebGL — a leaked vertexAttribDivisor). Today the worst divergence is 0.55%, and out of ~3000 tests only about thirty need a GPU — the whole golden suite runs headless in CI.
Three games, zero engine edits
The engine ships with a dungeon shooter, a platformer, and a racing game — and the claim I care about most: the second and third games didn't change a single line of the engine's packages. Genre mechanics (weapons, monsters, double jump with coyote time, a tire model with a grip peak) live in genre packages on top of a deterministic fixed-step game layer. All three run in your browser: flutter3d.pleion.dev.
A platformer in under 600 lines
The full walkthrough lives in a companion repo — github.com/pleiondev/coin_climb, one commit per step, diff any step against the previous one. The condensed version:
One pubspec. No graphics API in sight — flutter3d_app picks the backend per platform:
dependencies:
flutter3d: ^0.4.0 # renderer, scene, assets
flutter3d_game: ^0.4.0 # the fixed step, levels, actors
flutter3d_game_platformer: ^0.4.0 # the genre we're about to steal
flutter3d_bridge: ^0.4.0 # where levels become meshes
flutter3d_app: ^0.4.0 # input, settings, backend selection
flutter3d_audio: ^0.4.0
A level is a 36-line JSON document. Brushes become both the meshes you see and the colliders you stand on — one source, so they can't drift apart. Same format the level editor reads and writes:
"brushes": [
{ "at": [0.0, -0.5, 0.0], "size": [20.0, 1.0, 20.0], "material": "ground" }
],
"entities": [
{ "type": "player_spawn", "at": [0.0, 0.0, -7.0], "yaw": 0.0 },
{ "type": "collectible", "at": [0.0, 0.8, -3.5], "what": "coin",
"model": "assets/models/coin.glb" }
]
Steal a genre. This is the whole thesis. Load the level with the platformer's registry, and "collectible" suddenly means something — coin pickup, double jump, coyote time, the shrink-away animation, none of it your code:
final loaded = await LevelLoader().load(kLevel,
device: device,
registry: platformerRegistry(), // now "collectible" means something
rules: platformerRules(),
);
final staged = stage(loaded.level, loaded.collision,
input: _input, onFixture: fixtures.add);
// every fixed step:
staged.sim.step(dt);
The entire HUD is one Text widget:
Text('coins ${staged.runner.purse['coin']} / $kCoins')
Sound in three lines. Positional audio — the coin rings from where the coin was:
_speakers = await openSpeakers(bank: SoundBank(const [kCoinSound, kJumpSound]));
if (staged.runner.jumpedThisStep) audio.play(kJumpSound, staged.runner.position);
for (final Collectible taken in staged.sim.takenThisStep) {
audio.play(kCoinSound, taken.origin);
}
That's a playable platformer in under 600 lines of your own code — 36 of them the level, none of them engine code. And because the simulation is a deterministic fixed step, the repo's test suite plays the game headless in CI: holds forward, jumps, and asserts the purse actually gained a coin.
Three lessons, none about graphics
- Package boundaries are only real if a test enforces them. The boundaries without tests had quietly dissolved by the time I looked.
- A number in the docs that nothing verifies is already wrong. The README claimed 1,242 tests; the real number was more than double. Now a scan holds the README, the architecture doc, and the website to the same answer.
- Distrust any instrument that can't fail. See the upside-down atlas. My golden tests now include mutation checks — break the thing on purpose, confirm the test notices.
Play them
MIT-licensed. Packages on pub.dev under the pleion.dev publisher, docs at flutter3d.pleion.dev, source on GitHub. The war stories in full detail — the six-session upside-down atlas, the level format design, all of it — are in the Medium original.
But first: play the three games in your browser. They're the honest demo.
And to every student who ever asked me whether Flutter can do a real 3D game: it can now. I checked.




Top comments (0)