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)).