Skip to content

Scenes and prefabs

Layer: kernel (good)

A SceneStruct answers two questions: which prefabs can exist here, and what exists when it loads.

class Level1 extends SceneStruct {
  late final Player player;
  late final Enemy enemy;
  late final Eye eye;

  @override
  void describeScene(SceneDescriptor descriptor) {
    super.describeScene(descriptor);
    player = descriptor.has(Player.new);
    enemy = descriptor.has(Enemy.new);
    eye = descriptor.has(Eye.new);
  }

  @override
  void onSceneMounted(Scene scene) {
    final camera = scene.addEntity(eye);
    eye.view[camera] = (game as Game2D).defaultCamera;
    scene.addEntity(player);
    for (var i = 0; i < 10; i++) {
      scene.addEntity(enemy);
    }
  }
}

Declaration versus instance

This distinction runs through the whole engine and is worth stating plainly.

Declaration Instance
SceneStruct — one object, describes a scene Scene — an extension type over an int, one loaded copy
EntityStruct — one object, describes a row layout Entity — an extension type over an int, one row

A SceneStruct may back several loaded scenes at once. That is why it must not hold mutable per-instance state:

class Level1 extends SceneStruct {
  late final Player player;    // fine — a declaration handle
  int enemiesKilled = 0;       // WRONG — shared by every loaded copy
}

Per-instance state belongs in components and columns, which is what the rows are for, or on your GameState.

An Entity handle is per-instance too, and storing one here has the same problem. late Entity hubEntity on a SceneStruct looks like scene content and not configuration, but onSceneMounted runs once per loaded copy against the one declaration object, so the second load overwrites the first and every read afterwards gets the wrong row. Nothing raises. The demos in this repository do keep such a field, and they get away with it because they load their scene exactly once — that is their assumption, not a rule you can carry into a game that loads a level twice.

Declaring scenes up front

Declaring a scene on the Game registers its archetypes and its assets at boot — before the game isolate is spawned, and before any system's describeQuery runs:

class MyGame extends Game2D {
  late final Level1 level1;
  late final Level2 level2;
  late final HudScene hud;

  @override
  void describeScenes(GameSceneDescriptor descriptor) {
    super.describeScenes(descriptor);
    level1 = descriptor.has(Level1());
    level2 = descriptor.has(Level2());
    hud = descriptor.has(HudScene());
  }
}

loadScene(game.level1) then costs no registration at all — it allocates rows and mounts.

That matters because registration is the half of loading that cannot happen freely at runtime: archetype ids are process-global and never recycled, so a scene registered afresh on every load would leak ids and leave every unloaded scene's archetypes in the registry for queries to keep walking.

Passing an undeclared scene to loadScene still works and registers lazily, so describeScenes is additive, not an obligation — the scaffold's loadScene(MainScene()) is fine for a game with one scene.

Loading and unloading

class MyState extends GameState2D<MyGame> {
  @override
  void onMounted() {
    loadScene(game.level1);
  }

  Future<void> goToLevel2() async {
    for (final scene in loadedScenes) {
      unloadScene(scene);
    }
    await loadScene(game.level2);
  }
}
Call What it does
loadScene(struct) Allocates rows, mounts, resolves assets. Returns a Future<Scene>
unloadScene(scene) Unmounts one loaded instance and releases its pages
unloadAllScene(struct) Unloads every instance of that declaration
loadedScenes The live Scene handles
singleScene<S>() The declaration of type S, when exactly one scene is loaded. Throws otherwise

loadScene is asynchronous because assets are decoded on the Flutter isolate: the game isolate declares them but cannot decode them, so it asks and waits. Assets already resident from another loaded scene are not decoded twice, and unloading releases only what nothing else still declares.

Several scenes can be loaded at once, which is how a HUD scene, a world scene and a pause overlay coexist. Each camera view draws the scene its own camera is in, so two views can be looking at different scenes at the same instant.

Spawning entities

final entity = scene.addEntity(prefab);
final child  = scene.addEntity(limb, parent: entity);
entity.destroy();          // removes it and its whole subtree

addEntity allocates a row in the prefab's archetype, applies every declared default, fires the prefab's mount event — which is where onEntityMounted comes from, if the prefab mixes in EntityLifecycleListener — and returns the handle. That returned handle is the only time the engine offers you this particular entity, so keep it if you will want it later; there is no lookup by name or tag. See Events and listeners.

Spawn from the game isolate

scene.addEntity writes component storage, so it belongs on the simulation side: a system, a state hook, or a command handler. Spawning "from the UI" means sending a command that a game-side handler turns into an addEntity.

The framework ships 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.

Rate-limiting spawns

Spawning is cheap but not free, and a burst that allocates thousands of rows in one step shows up as a frame spike. The example demos cap per tick and converge over several:

final shortfall = targetPopulation - alive;
if (shortfall > 0) {
  final batch = shortfall < _maxSpawnPerTick ? shortfall : _maxSpawnPerTick;
  for (var i = 0; i < batch; i++) {
    spawnOne();
  }
}

Scene-level declarations

A SceneStruct carries the same describe* passes an entity does, and they apply to everything in the scene:

class Level1 extends SceneStruct {
  late final TextureAsset tileset;

  @override
  void describeAssets(AssetDescriptor descriptor) {
    super.describeAssets(descriptor);
    tileset = descriptor.has(Textures.worldTileset);
  }
}

Prefabs and their scene share one descriptor, and has is idempotent per identity — so a prefab declaring the same texture ends up with the identical handle: one address, one decode. Declare an asset wherever you use it; declaring it twice costs nothing and forgetting to costs a LateInitializationError on mount.

Scenes can also mix in SceneLifecycleListener, Tickable, FixedTickable, Coroutines and Animations — a scene is a GameListener, so it can hold per-scene logic without a system.

Composing scenes with mixins

A SceneStruct is an ordinary class, so shared scene behaviour composes as a mixin:

mixin FieldScene on SceneStruct {
  late final TextureAsset grass;

  @override
  void describeAssets(AssetDescriptor descriptor) {
    super.describeAssets(descriptor);
    grass = descriptor.has(Textures.worldGrass);
  }
}

class Level1() extends SceneStruct with FieldScene;
class Level2() extends SceneStruct with FieldScene;

Prefabs are declarations too

descriptor.has(Player.new) registers the archetype and runs the prefab's describe* passes. The returned handle is what you spawn from, and it is the same object every entity of that kind shares:

player = descriptor.has(Player.new);      // declare once
scene.addEntity(player);                  // spawn many
player.sprite.color[entity] = 0xFFFF0000; // per entity, through the handle

It takes the constructor rather than an instance because a prefab's fields declare their own columns as they are initialised (final hp = Field.int32(100)), and that happens while the object is being built - so the engine has to be the one building it. A prefab whose constructor takes arguments goes in a closure: descriptor.has(() => Bullet(speed: 5)).


Next

Systems and queries →