Skip to content

Architecture

Layer: kernel (good)

Everything on this page comes from the kernel and applies unchanged under any renderer.

good runs your game on two isolates. Understanding how they relate is the one genuinely unusual thing about the engine, and almost every API shape downstream follows from it.

Two copies of one object

Game.start(MyGame.new) builds your game and hands that instance itself to Isolate.spawn as the spawn message. Dart deep-copies a plain object graph across that boundary, so what runs on the game isolate is a second instance of your class — same type, same overrides, different identity, different heap.

flowchart LR
    subgraph FL["Flutter isolate"]
        A["<b>MyGame</b> (handle)<br/>builds widgets<br/>sends commands<br/>reads channels"]
    end
    subgraph GA["game isolate"]
        B["<b>MyGame</b> (the real one)<br/>owns MemoryPool<br/>ticks systems<br/>writes components<br/>loads scenes"]
    end
    A -->|"Isolate.spawn"| B
    A <-->|"ring buffers"| B
    A -.-> M(["shared native memory<br/>state channels, draw buffers"])
    B -.-> M
    B -.-> P(["shared native memory<br/>component pages"])

From that moment the two copies do completely different jobs.

The game-isolate copy is the real one. Its GameState owns the MemoryPool, the live scenes and the fixed-tick loop, and it is the only writer of component data and state channels anywhere in the process.

The main-isolate copy is an inert handle. It has no systems at all, it registers no archetypes and it holds no component pages. It exists to send and handle commands, receive tick notifications and state-channel updates, and build widgets. Numbers it shows arrive through a state channel or a buffer; an Entity handed to it will not resolve, and says so.

Calling a gameplay method on the handle copy does nothing

The game that await Game.start(MyGame.new) hands back is the handle. It does not reach the simulation. Anything that must cross goes through one of the channels below.

How both copies agree without negotiating

Main runs createState(), describeState, describeBuffers, describeCameras and describeCommands before the spawn, and the game isolate inherits the result in the deep copy rather than re-deriving it. That is how the two sides agree on every id without negotiating one:

  • a command's index is its position in the declaration pass;
  • a state channel's identity is its index in that one pass;
  • so is a buffer's, and a camera view's.

This is why those passes must be pure and order-stable. A pass that branches on Platform.isWindows, or registers in a Set's iteration order, breaks the agreement, and the two sides then disagree about what index 3 means with nothing to catch it. It is also why a declaration on a field — final score = Channel.int32() — is an eager final and never a late final: a late initialiser runs on first read, so the order would be whatever order something happened to touch the fields in.

Two passes are deliberately not on that list. describeScenes registers archetypes and component bits into statics, which do not survive Isolate.spawn — so it runs on the game isolate only, and there is one registrar rather than two numberings to keep level. Systems follow it there: the pass that collects them off the state runs on the copy that ticks them and nowhere else, so the main-isolate copy declares no system, resolves no query and builds no network transport.

The four lanes across the boundary

Traffic is split by volume and shape, and each lane exists because the others handle its case badly.

Lane Direction Volume Use it for
Component data game writes, both read per-entity The world itself. Read from Flutter via Entity
Commands both ways bulk, per-tick "Do this": spawn, damage, save. Ring buffers
State channels game → main one small value HUD numbers: score, health, timings
Control messages both ways rare Boot, shutdown, enabling systems, asset decode requests

There is no second event lane for the main isolate. Every event in the engine happens on the game isolate. Game.buildView is Flutter's whole surface, and traffic the other way goes through a command or a state channel.

Isolate affinity is a type

GameListener means "lives on the game isolate" — GameState, SceneStruct, EntityStruct, GameSystem are all one. Game is not, so:

class MyGame extends Game with FixedTickable { }   // compile error

fails to compile instead of silently never ticking. Put the tick on the GameState, a scene, or a system. Those four types are also the only ones that can declare an event — see Events and listeners.

The fixed tick

The simulation advances in whole steps of fixedTimeStep (16667 µs — 60 Hz — by default). Wall-clock time accumulates and is spent in whole multiples, so systems always see exactly that much elapsed time, never a variable frame delta.

class MyGame extends Game2D {
  @override
  Duration get fixedTimeStep => const Duration(microseconds: 8333);  // 120 Hz

  @override
  int get maxFixedStepsPerAdvance => 5;
}

maxFixedStepsPerAdvance is the spiral-of-death guard: a machine that cannot simulate a step in less than fixedTimeStep would otherwise fall further behind every frame. Dropping time is the only stable answer — the simulation runs slower than wall clock instead of locking up.

Phases within one advance

flowchart TD
    A["<b>advance()</b>"] --> C["<b>fixed step × N</b><br/><i>N = elapsed / fixedTimeStep, capped</i>"]
    C --> C0["resolve inputs<br/><i>raw device state becomes your Input handles</i>"]
    C0 --> C1["beginTick<br/><i>copy the last published snapshot into the write slot</i>"]
    C1 --> C2["drain commands<br/><i>so an entity a command spawns is visible this step</i>"]
    C2 --> C3["coroutines<br/><i>resumed here, so their writes land in-window</i>"]
    C3 --> C4["FixedTickable systems<br/><i>your gameplay, in declared order</i>"]
    C4 --> C5["commitTick<br/><i>publish</i>"]
    C5 -.->|"repeat × N"| C0
    C -.-> D["<b>presentation</b> — once per frame<br/><i>Tickable systems, then the renderer</i>"]

Inputs, commands and coroutines all land before the first system runs, so every system in a step sees one settled picture: the same input snapshot, the same newly-spawned entities, the same coroutine writes. Presentation runs once per advance however many steps it just ran, including zero.

Two consequences worth internalising:

Component writes must land inside the tick window. beginTick copies the last published snapshot over the write slot, so anything written outside the window is silently discarded. This is why coroutines are sync* generators instead of async* — an async* body resumes on a microtask, after commitTick, and every write after the first yield would be thrown away.

A read sees the last published snapshot. If you must read a value after writing it, the answer is not a second read path — it is to move the reader to a later phase. WorldTransformSystem writes during the fixed tick and its consumers run after it commits.

Storage: pages and triple buffers

Component data lives in a MemoryPool of native pages, one pool per Game. Each page is a lock-free round-robin triple buffer: one slot being written, one published, one in reserve. That is what lets the Flutter isolate read a coherent snapshot while the game isolate writes the next one, with no lock and no copy.

class MyGame extends Game2D {
  @override
  int get pageSize => 1 << 20;   // 1 MiB — costs 3 MiB resident (three slots)

  @override
  int get maxPages => 128;       // exhausting it throws rather than growing
}

The 64 MiB default is generous for a real game and wasteful for a test or a small scene; override it down freely.

Inline mode

Game.startInline(MyGame.new) runs the simulation on the calling isolate with no spawn and no ring buffers. It exists for tests and headless tools, where a second isolate makes assertions awkward and buys nothing:

final game = await Game.startInline(MyGame.new); // autoTick: false by default
game.runFixedStep();                             // drive it yourself

game.state and game.runFixedStep() are available only on an inline run; on a spawned run they throw, because there is no local state to reach.

Game.start also boots inline automatically on the web (kIsWeb), since there is no isolate to spawn there — but the web is not otherwise a supported target.

Component data does not cross

Every read of a column happens on the game isolate. Main gets a value by having the game isolate publish it — a state channel for a number, a buffer for bulk per-frame data such as the 2D draw list.

Sharing the memory is not the hard part. Keeping a second page list on main in step with the game isolate's is, and so is freeing a page main might still be reading. entity<T>() on this copy throws instead of reading a page list that will always be empty.


Next

Entities and components →