A little while ago, I had two completely different itches I wanted to scratch at the same time.

First, I wanted to build a 2D game—something heavy on simulation, with swarms of units moving across hex grids and heat spreading from tile to tile. For that, I needed a solid cross-platform Entity-Component-System (ECS) engine. But every engine I tried had some annoying quirk: either the cross-platform build setup was fragile, or things behaved slightly differently between desktop and mobile, or the engine fought me on how I wanted to structure the simulation.

Second—back before Google announced the Jules shutdown—I wanted a genuinely complex project to learn how to orchestrate autonomous agents properly. Not just asking an AI to fix a CSS bug, but coordinating a multi-part architecture from scratch without it turning into spaghetti.

While looking at my options for the engine, I had a thought that initially sounded a bit stupid: What about Flutter?

If you’ve ever shipped a Flutter app, you know why its toolchain is so tempting. You write Dart once, it compiles straight to native binaries for Windows, macOS, Linux, iOS, and Android, and because Flutter ships its own rendering engine (Impeller), every pixel looks identical on every screen. No platform-specific rendering bugs, no fighting CMake or Gradle for weeks.

Of course, Flutter itself is built for app UI, not games. Everything in standard Flutter wants to live inside a giant widget tree with layout passes and state reconciliation—which is great for a settings screen and terrible when thousands of game entities are updating 60 times a second.

Even if you look at Flame—the go-to 2D game engine in the Flutter ecosystem—it still lives inside a GameWidget and builds everything around an object-oriented component tree (PositionComponent, SpriteComponent) where every entity is a heap-allocated Dart object. In fact, Flame used to have an official ECS bridge (flame_oxygen), and the team eventually deprecated it in favor of their standard object tree. Meanwhile, older standalone ECS packages on pub.dev (like entitas_ff or fast_ecs) still box components as regular Dart objects on the heap.

So I started digging a layer deeper into how Flutter actually draws to the screen.

It turns out you don’t have to use the widget tree at all. Underneath all the buttons, layouts, and RenderObjects sits dart:ui—the low-level layer that talks directly to Impeller’s GPU canvas. Until now, almost the only people touching raw dart:ui without widgets were engineers writing 200-line Bunnymark benchmarks to prove Impeller could push 50,000 sprites at 60 FPS.

You can literally keep Flutter’s widget system completely out of your game loop (or save it strictly for a simple HUD menu later), hook straight into the hardware canvas, and still get Flutter’s one-command native builds for every platform.

Once I realized that, Sting was born.

Building a game engine from scratch—even a focused 2D one—involves a ton of foundational code: entity storage, spatial grids, camera math, and batch rendering. That made it the perfect playground to test multi-agent orchestration in Jules.

Instead of prompting blindly, I set up a central master plan file in the repository and split Jules into distinct roles across iterations:

  • The Orchestrator: One role was responsible for owning the main plan file—breaking the engine down into small, well-defined tasks, keeping the architecture consistent, and checking off milestones.
  • Specialized Builders: Separate runs took individual tasks from the plan—writing the ECS storage, the hex math, or the renderer—along with unit tests and benchmarks to prove the code actually worked.

Having that single plan file as the source of truth kept the agents from drifting. And because we added a benchmark regression suite right alongside the test suite (which eventually grew to 844 automated tests), any code that quietly slowed down the frame loop got caught immediately.

If you write a simulation-heavy game in a managed language like Dart, you only really have one enemy: the garbage collector.

If your game creates thousands of tiny objects (new Position(), new Offset()) on every frame, the garbage collector will eventually pause your game to clean up memory, and your frame rate will stutter. That’s where Dart 3’s newer language features made a huge difference:

1. Flat Number Lists Disguised as Clean Objects

Instead of storing every entity’s position or velocity as a separate Dart object on the heap like older Dart ECS libraries, Sting stores component data inside flat lists of numbers (Float32List and Int32List), indexed by a fast Sparse-Set.

Normally, working with raw number arrays in game code looks ugly (buffer[id * 4 + 1]). But Dart 3 extension types let you wrap those array slices in normal-looking getters and setters like pos.x and pos.y that compile away completely at runtime. And for math functions—like converting screen clicks to hex-grid coordinates—Sting uses Dart 3 Records (double, double) to return pairs of numbers without creating throwaway objects.

2. Sending Sprites to the GPU in One Batch

Because there’s no widget tree, Sting’s renderer hooks directly into dart:ui’s Canvas.

Instead of drawing tiles and sprites one by one in a loop, the render systems pack all the sprite positions and texture coordinates into pre-allocated buffers and hand the whole batch to Impeller in a single call using Canvas.drawRawAtlas (with support for custom fragment shaders when a material needs special effects).

3. Audio and Assets That Don't Care About Plugins

Everything outside the core simulation—like sound—is tucked behind a simple pluggable interface (PluggableAudioManager). That way, the engine itself never depends on bloated third-party Flutter plugins just to compile and run tests.

If you poke around the Sting repo on GitHub, you’ll probably notice something funny about what’s inside.

Some obvious features you’d expect in a general-purpose game engine aren't there yet. Meanwhile, it has weirdly specific, battle-tested modules for hex-grid heat diffusion and cooling, Barnes-Hut spatial trees, and potential-field swarm AI.

That’s because Sting isn't trying to be a general-purpose Unity replacement right now—it’s built specifically to power the game I’m making. Whenever my game needs a new simulation mechanic, that’s what gets built into the engine next.

Right now, it does the exact job I hoped for: it runs my core gameplay loop smoothly and compiles cleanly across platforms without me fighting the toolchain. I have a feeling that if I eventually try to ship a Web build, I’ll run into some fun browser quirks with low-level buffers or shaders—but for now, I’m focused on making the game itself fun, and I’ll deal with Web problems when they show up.

If you’ve ever been curious what happens when you strip the widgets out of Flutter and use Dart 3 + dart:ui as a real data-oriented 2D engine, you can check out the source at github.com/Evgenii-Zinner/Sting.