Skip to content

Talking to Flutter

Layer: kernel (good)

Your UI is ordinary Flutter — widgets, setState, whatever state management you like — laid over a GameView. What is not ordinary is that the simulation is on another isolate, so a button cannot simply call a method on your game.

Two lanes carry your traffic, and each exists because the other handles its case badly.

Lane Direction Shape Use it for
Commands both ways bulk, per-tick "Do this": spawn, damage, pause, save
State channels game → main one small value HUD numbers: score, health, timings

A third, describeBuffers, is renderer plumbing rather than game code: a ring of records in shared memory, which is how goo2d gets a frame's draw list to the painter. Nothing crosses outside these — component data in particular does not, and Reading the world says why.

Where your UI belongs

Build your UI in Flutter. That is the recommendation, and it is not a fallback — it is the reason the engine renders into a widget instead of owning the window.

Menus, HUDs, inventories, settings screens, dialogue boxes, pause overlays: all ordinary widgets, laid over the GameView. You get Flutter's layout, text rendering, accessibility, focus handling, animation, theming and hot reload for free, and none of it costs the simulation anything — it is on the other isolate.

Stack(
  children: <Widget>[
    GameView(camera: game.defaultCamera),
    Positioned(
      top: 16, left: 16,
      child: ValueListenableBuilder<int>(
        valueListenable: game.score,
        builder: (context, score, _) => Text('Score: $score'),
      ),
    ),
    Align(
      alignment: Alignment.bottomCenter,
      child: ElevatedButton(
        onPressed: () => game.pause(),
        child: const Text('Pause'),
      ),
    ),
  ],
)

The UI reads through state channels and acts through commands. That is the whole interface.

When to put UI in the game instead

One case, and it is a real one: when the element is as interactive as the game itself — when it lives in world space, moves with the world, or has to be hit tested against the same things the game is.

In the game (an entity) In Flutter (a widget)
A health bar pinned above an enemy The player's health bar in the corner
A world-space button on a machine the player walks up to The pause menu
Drag-and-drop that lands on game objects An inventory grid
A damage number that flies off a hit A settings screen
A speech bubble tracking a character A dialogue box at the bottom of the screen
A minimap marker in a second camera view The minimap frame around it

The test: does it need the camera? If it has to move when the world moves, be occluded by the world, or be picked in world coordinates, make it an entity — a Renderable2D with PointerReceiver, and NineSliceBorder for panels and bars. If it sits in screen space and never has to know where the camera is, it is a widget, and making it an entity buys nothing but work.

There is one thing a widget cannot do, and it is depth. The painter replays the whole batch in a single call, so a widget is above or below the entire GameView and never between two of its sprites. Art that has to sit inside the world's depth order while staying put against the view — a backdrop behind everything, a vignette or a frame over everything — is ScreenTransform2D on an entity. It has no text, no layout and no accessibility, so it is the answer for a picture and not for a score.

Text splits the same way. A damage number, a name over a character and a sign in the scene are Text2D labels on entities, drawn from a grid font you supply (see Text). A score in the corner is a Text widget, and the engine offers no screen-space text to talk you out of it.

You can mix them freely

A world-space marker as an entity and the panel it opens as a Flutter dialog is a perfectly ordinary combination. They are not competing systems — one is drawn by the camera and one is drawn over it.

Commands

A command is a declared message with typed parameters, carried on a shared ring buffer — not a SendPort message per call.

class SetPopulation extends SinkCommand<int> {
  final count = Param.uint16();

  @override
  void bufferFromParams(ParamBuffer call, int params) => count[call] = params;

  @override
  int paramsFromBuffer(ParamBuffer call) => count[call];
}

Declare it on the Game, and handle it on the GameState:

class MyGame extends Game2D {
  late final SetPopulation setPopulation;

  @override
  void describeCommands(CommandDescriptor descriptor) {
    super.describeCommands(descriptor);
    setPopulation = descriptor.has(SetPopulation.new);
  }
}

class MyState extends GameState2D<MyGame> {
  int targetPopulation = 0;
  int score = 0;

  @override
  void describeCommands(CommandDescriptor descriptor) {
    super.describeCommands(descriptor);
    descriptor.hasSink(game.setPopulation, _onSetPopulation);
  }

  void _onSetPopulation(int target) => targetPopulation = target;
}

Send it from anywhere on the Flutter side:

onPressed: () => game.setPopulation(400),

Every command is declared on the Game, whichever side handles it

describeCommands runs on both copies in the same order, which is what makes a command's index mean the same thing on both sides. GameState's pass may only handle what the Game's pass declared — a command declared there would have an index on the game isolate and none on the Flutter one, which is the same as having none.

The four shapes

Class Signature For
SignalCommand () "Pause". No data either way
SinkCommand<P> (P) "Spawn 5 enemies". Data in, nothing back
SupplierCommand<R> () → R "How many are alive?" Nothing in, data back
GameCommand<P, R> (P) → R Both

All four are awaitable — await game.saveGame() completes when the far side has handled it.

More than one parameter: use a record

P is a single type, so a command that carries several values carries a Dart record — the same answer coroutines give:

typedef Blow = ({int amount, bool crit});

class Damage extends GameCommand<Blow, int> {
  final amount = Param.uint16();
  final crit = Param.boolean();    // (1)!
  final dealt = Param.uint16();    // (2)!

  @override
  void bufferFromParams(ParamBuffer call, Blow params) {
    amount[call] = params.amount;
    crit[call] = params.crit;
  }

  @override
  Blow paramsFromBuffer(ParamBuffer call) =>
      (amount: amount[call], crit: crit[call]);

  @override
  void bufferFromResult(ParamBuffer call, int result) => dealt[call] = result;

  @override
  int resultFromBuffer(ParamBuffer call) => dealt[call];
}
  1. One bit, not a byte, and typed bool — the same storage Param.uint1() takes, with the type saying what it holds. It goes on the wire as its own field kind, so a build that changed one to the other is caught at the handshake.
  2. The result shares the same record as the parameters — one layout, one reservation, both directions.

Call it with a record literal, and the field names are checked at the call site:

final dealt = await game.damage((amount: 25, crit: true));

The typedef is worth writing. The record type appears in four signatures, and naming it once means a new field is one edit instead of four.

Why a record, not a parameter list

A command has to be one value to be reserved, marshalled, queued in a batch and handed to a handler. A record gives that one value named, type-checked fields with no wrapper class to declare and no positional argument order to get wrong — and it exists only at the call site, never in the record that crosses the wire.

The four marshalling methods are the whole contract, and call/execute are provided in terms of them instead of being things you override.

Handlers take the record

descriptor.hasHandler(game.damage, (Blow params) {
  return params.amount * (params.crit ? 2 : 1);
});

hasHandler is R Function(P) — parameters in, result out. hasSink is void Function(P), hasSupplier is R Function(), and hasSignal is void Function().

The field schema

Param mirrors Field, with the same widths and the same packing — a command parameter is declared on the field that holds it, exactly like a component column:

final x = Param.float32();
final y = Param.float32();
final kind = Param.uint4();          // 16 kinds in half a byte
final target = Param.entity();       // a handle, not a bare int64
final name = Param.string();         // any length, kept in the tail
final code = Param.fixedString(2);   // reserved inline, because 2 is real

A record has a fixed head — every numeric field, and the offset and length of every variable-length one — and, if it declares any variable-length field, a tail behind the head holding their bytes. Three things follow:

  • A string does not need a maximum. Param.string() and Param.bytes() size themselves from what you write. Param.fixedString(n) and Param.fixedBytes(n) reserve n bytes in every record whether they are used or not, so reach for them when the bound is real — a two-letter country code, a 16-byte digest — and use the length-free kind for everything else.
  • The carrier is what bounds a record, not the declaration. A batch grows to hold whatever is written into it, but it still has to fit in one ring-buffer record on the way across. A batch too big for that ring is refused at send(), naming the bound and Game.commandBufferBytes. It is never truncated.
  • Field widths are bandwidth. Param.boolean() for a flag and Param.uint4() for a small enum are not micro-optimisation here — a batch of a few hundred commands per frame pays for every byte.

The schema is separate from P: P is what your code passes, and the schema is what crosses the wire. bufferFromParams and paramsFromBuffer are the two places that translate between them, and they are the only places that mention a ParamPointer at all.

Handling on the Flutter side

Some commands belong on main — writing a save file, opening a URL. Register the handler in the Game's own pass:

@override
void describeCommands(CommandDescriptor descriptor) {
  super.describeCommands(descriptor);
  save = descriptor.has(SaveGame.new);
  descriptor.hasSink(save, _writeSaveFile);   // handled here, not on the game isolate
}

Asking a game that is paused

A handler runs inside the fixed tick window, which means it runs only if the tick runs. Pause the game — pause(), or a time scale of zero — and a command sent to it waits in the queue until the game resumes. Nothing is lost, and for anything that changes the world that is the behaviour you want. It is the wrong answer for a pause menu or a debug overlay asking the simulation a question, which is the case the game is usually paused for.

hasReadOnlySupplier and hasReadOnlyHandler put the handler on a second lane, drained once per frame instead of once per tick:

descriptor.hasReadOnlySupplier(game.whoIsPlayer, findPlayer);

The call site is unchanged — await game.whoIsPlayer() — and so is the reply: the batch crosses on the same ring and the future completes when the handler has run and its answer is back. What changes is that a frame still happens while the game is paused, so the answer arrives instead of waiting out the pause.

The two lanes have no ordering between them. A read-only command and an ordinary one sent in that order can run in either, because one waits for a tick and the other for a frame. Order within each lane is kept.

Read-only is checked, and it throws

A handler here runs with no tick open, so there is no write slot behind it. Writing a component field, adding or destroying an entity, loading or unloading a scene and writing a StateChannel all throw a StateError naming the lane — in every build, not only where asserts are on. Use hasHandler for anything that writes; it is delivered inside the tick and it replies the same way.

hasReadOnlySink and hasReadOnlySignal exist only to throw. A handler that promises not to write and returns nothing has no effect left to have.

A game that is hidden rather than paused is not covered: pauseWhenHidden stops the frame timer, so there is no frame to drain on.

Counting the world

WorldCensus is what the read-only lane was built to carry: the loaded scenes, the registered archetypes and how many entities are in each, and the declared systems with their enabled bits. All of it is already known on the game isolate and reachable from nowhere else, so the census is taken there and crosses as one blob.

Declare the command with a hasBytes parameter, and copy the bytes out of the buffer on the way back:

class TakeCensus extends SupplierCommand<Uint8List> {
  final blob = Param.bytes();

  @override
  void bufferFromResult(ParamBuffer call, Uint8List result) =>
      blob[call] = result;

  // A copy, not the view: reading a `hasBytes` field hands back a window onto
  // the batch's own buffer, and the transport reuses those bytes.
  @override
  Uint8List resultFromBuffer(ParamBuffer call) => Uint8List.fromList(blob[call]);
}

Handle it on the read-only lane, and read it back on main:

@override
void describeCommands(CommandDescriptor descriptor) {
  super.describeCommands(descriptor);
  descriptor.hasReadOnlySupplier(census, () => WorldCensus.of(this).encode());
}
final world = WorldCensus.decode(await census());
for (final archetype in world.archetypes) {
  print('${archetype.typeName}: ${archetype.entityCount}');
}

Because it is on that lane it answers while the game is paused, which is when a world is usually worth looking at. WorldCensus.tick says which tick was counted, so two censuses reporting the same tick describe the same world.

What it costs, and what it does not

Nothing takes a census on its own. There is no per-frame hook and no buffer that fills whether or not anyone reads it, so a running game that never asks pays nothing at all.

One census is O(archetypes + pages + scene slots + systems), not O(entities) — a page reports its rows as arithmetic over its bump cursor and its free set rather than by walking them. Measured under flutter test, so JIT: 0.6 µs on an empty world, 0.9 µs at 100,000 entities, and 4.9 µs for four scenes, six archetypes, 24 pages and 120,000 entities. The blob for that last one is 345 bytes, against the 64 KiB commandBufferBytes gives a command ring.

Every name in a census — ArchetypeCensus.typeName and its two counterparts — is a runtimeType string carried so a person can tell one entry from another. Nothing resolves by one: an archetype is named by archetypeId, a scene by slot and a system by index. A component type's name does not exist at runtime at all, which is why a census counts entities per archetype and does not list an archetype's components by name.

Taking one on main's copy of the GameState throws rather than answering. That copy runs the same declaration passes and never ticks — it registers no archetypes, loads no scenes and holds no systems — so a census from there would report an empty world for a reason that has nothing to do with the world.

Batching

Several commands in one round trip:

final batch = game.createCommandBatch();
game.spawnEnemy.execute(1, batch);
game.spawnEnemy.execute(2, batch);
game.setPopulation.execute(400, batch);
await batch.send();

A UI frame that issues a few hundred orders at once must not overflow between two drains 16 ms apart, which is what commandBufferBytes (64 KiB by default) sizes.

There is no built-in spawn command

One would have to name a prefab by archetypeId, which is a game-isolate identifier the Flutter isolate has no way to see. Declare your own, in terms that mean something on both sides — an enum of spawnable kinds, say.

State channels

For "one number the UI shows", a command per read would be absurd. A state channel is a small fixed-width value the game isolate writes and the Flutter isolate reads straight out of shared memory, coherent per tick.

class MyGame extends Game2D {
  final score = Channel.int32();
  final health = Channel.float32(100);
  final paused = Channel.boolean();
}

Channel.* runs while the game is being constructed, which is why Game.start takes a constructor rather than an instance: Game.start(MyGame.new). The describeState hook still works and still composes with the fields — a game may use both, and the fields are numbered first.

Write from the game isolate:

class ScoreSystem extends GameSystem with Tickable {
  @override
  void onTick(Duration delta) {
    getGame<MyGame>().score.value = getState<MyState>().score;
  }
}

Read from Flutter — a StateChannel is a ValueListenable, so it drops straight into a ValueListenableBuilder:

ValueListenableBuilder<int>(
  valueListenable: game.score,
  builder: (context, score, _) => Text('Score: $score'),
)

Only the Game can declare one

Not arbitrary: a channel's storage is allocated on the main isolate before the spawn, and its identity across the boundary is its index in that one declaration pass. A Game is the only thing main builds and describes, so it is the only place an index can come from.

That rules out four hosts, each for its own reason:

  • GameState is built on the game isolate, after the allocation it would have to be part of.
  • GameSystem goes with the systems to the game isolate for the same reason.
  • SceneStruct is loaded after boot, possibly several times, so it could never hold a stable index.
  • Component comes and goes with the scene, for the same reason.

So a scene-derived value is declared on the Game and written from wherever the per-tick work is — usually a GameSystem, through game.score, as ScoreSystem above does.

Publish from Tickable, not FixedTickable

Phase totals are only complete once the fixed step has returned. A system publishing from inside onFixedUpdate reports a half-accumulated figure — numbers that are wrong in a way that looks plausible.

Reading the world

You cannot, from Flutter. The main-isolate copy registers no archetypes and holds no component pages, so entity<T>() there throws and tells you to publish the value through a channel instead. Every read of a column happens on the game isolate.

That is not a gap waiting to be filled. Resolving handles on main costs a second page list to keep in step with the game isolate's, and a handshake before any page can be freed, since main might still be reading it — and use-after-free on shared memory does not report, it returns plausible numbers.

An Entity still crosses perfectly well as a value, which is what Param.entity() is for:

class WhoIsPlayer extends SupplierCommand<Entity> {
  final entity = Param.entity();

  @override
  void bufferFromResult(ParamBuffer call, Entity result) =>
      entity[call] = result;

  @override
  Entity resultFromBuffer(ParamBuffer call) => entity[call];
}

It is Param.int64() with the handle type on it — same eight bytes on the wire, same cost — but a command declaring one cannot be handed a score by mistake, and a mix-up that did cross would look like eight perfectly ordinary bytes on the other side. What main can do with the handle it gets back is name that entity in a later command. To watch one, publish its fields: see debugging without an inspector.

The widget surface

Game.buildView is the game's whole Flutter surface. There is no second event lane into the main isolate, by design: every event in the engine happens on the game isolate, and traffic the other way is a command or a channel.

class MyGame extends Game2D {
  @override
  Widget? buildView(BuildContext context, CameraView? camera) {
    // optional: a custom surface. `Game2D` supplies the default painter.
    return null;
  }

  @override
  void onViewAttached() { }
  @override
  void onViewDetached() { }
}

Lifecycle in a widget

class _GameSurfaceState extends State<GameSurface> {
  /// The in-flight start, and the only thing there is to hold until it
  /// completes: `Game.start` is what **builds** the game.
  late final Future<MyGame> _starting;

  MyGame? _game;

  @override
  void initState() {
    super.initState();
    _starting = _start();
  }

  Future<MyGame> _start() async {
    await ensureGameReady();          // check assets before anything decodes
    final game = await Game.start(MyGame.new);
    if (mounted) setState(() => _game = game);
    return game;                      // disposed mid-start; dispose stops it
  }

  @override
  void dispose() {
    // `dispose` cannot await, so the teardown is hung off the start future.
    // Already-complete: stops now. Still in flight: stops the moment it boots.
    _starting.then((game) => game.stop());
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final game = _game;
    if (game == null) return const Center(child: CircularProgressIndicator());
    return GameView(camera: game.defaultCamera);
  }
}

Do not stop through the nullable field alone

This is the shape to avoid, and it leaks silently:

MyGame? _game;

Future<void> _start() async {
  final game = await Game.start(MyGame.new);  // (1) can be disposed here
  if (mounted) setState(() => _game = game);
}

@override
void dispose() {
  _game?.stop();                  // (2) still null — does nothing
  super.dispose();
}

If the widget is disposed while Game.start is in flight, _game is still null at (2), so ?. swallows the call. The start then completes, mounted is false so the field is never assigned, and the game keeps running — its isolate alive and its native memory held — attached to a widget that no longer exists. Nothing reports it.

There is no way to fix it by holding the game earlier, because the game does not exist until the start completes — Game.start(MyGame.new) is what builds it. Hold the future and hand the teardown to that: it stops a booted run now and a booting one the moment it comes up.

stop() is not optional. The game owns native memory and an isolate, and neither is reclaimed by the widget going away.

One Game instance backs one run

A Game that has been started cannot be started again — its declarations are sealed. Call Game.start(MyGame.new) again for a fresh run; each call builds its own.


Next

Coroutines and animation →