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:
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];
}
- One bit, not a byte, and typed
bool— the same storageParam.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. - 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:
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()andParam.bytes()size themselves from what you write.Param.fixedString(n)andParam.fixedBytes(n)reservenbytes 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 andGame.commandBufferBytes. It is never truncated. - Field widths are bandwidth.
Param.boolean()for a flag andParam.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:
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:
GameStateis built on the game isolate, after the allocation it would have to be part of.GameSystemgoes with the systems to the game isolate for the same reason.SceneStructis loaded after boot, possibly several times, so it could never hold a stable index.Componentcomes 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.