flutter3d
Showcase Changelog 38 packages API reference

The ECS world

since 0.7.0 Simulation, audio and XR

Every simulation in the engine keeps its state as entities and components in one place: EcsWorld. An entity is nothing but a number; a component is whatever data a game hangs off it. Keeping everything in one store is what lets a save file, a network packet and a determinism check all write the whole world down without a hand-written list of what to include.

Step 1: Register a component and save two entities #

A component type has to be registered with an encoder and a decoder before it can be saved. spawn hands back a fresh entity, and set attaches a component to it.

final world = EcsWorld()
  ..register<_Position>(
    'position',
    encode: (_Position p) => p.x,
    decode: (Object? data) => _Position((data! as num).toDouble()),
  );
final goblin = world.spawn();
final troll = world.spawn();
world
  ..set(goblin, const _Position(3.0))
  ..set(troll, const _Position(9.0));
final saved = world.save();

save() writes every registered component of every living entity into one document, keyed by the entity's own index.

Step 2: Carry the save across an edited level #

An entity's index is an allocation, not an identity: reload the level with a monster inserted ahead of an old one, and the indices no longer point at the same actors. remapEntitySave rewrites a save from the indices it was written at to the indices a reloaded level hands out, matching by name instead.

// The level is reloaded with the goblin's old slot now empty and a third
// monster ahead of the troll, so the indices EcsWorld would otherwise
// reuse no longer mean what they meant.
final remap = remapEntitySave(
  saved,
  oldNames: <String?>['goblin', 'troll'],
  newNames: <String?>['goblin', 'ogre', 'troll'],
  newGenerations: <int>[0, 0, 0],
  newFree: <int>[],
);
final reloaded = EcsWorld()
  ..register<_Position>(
    'position',
    encode: (_Position p) => p.x,
    decode: (Object? data) => _Position((data! as num).toDouble()),
  );
// Three spawns to give the world the same three slots `newNames` describes.
final ids = <Entity>[for (var i = 0; i < 3; i++) reloaded.spawn()];
reloaded.restore(remap.save);

The goblin and the troll both keep their own position, even though the troll's index moved from 1 to 2 to make room for the ogre. remap.dropped lists any name the new level does not have.

Step 3: Read the result #

The page prints which position ended up on which name, reading each entity by its new index in the reloaded world.

final goblinX = reloaded.get<_Position>(ids[0])?.x;
final trollX = reloaded.get<_Position>(ids[2])?.x;

Nothing here compares old and new indices directly, because that comparison is exactly what a level edit breaks. It only asks each name for its own value.

Step 4: Reload it a different way #

The page holds the saved world, two entities, and reloads it into whichever level you choose. Each pedestal is a slot of the reloaded world, and what stands on it is what the remap found there: the green one is the goblin's saved position, the red one the troll's, and the bar beside it is the number itself. Change the order, drop the troll's neighbour or add a second ogre ahead of everything, and each entity still finds its own name; a slot no name claims stays empty.

// The same remap as above, for whichever level is chosen: each saved
// entity finds its own name in the new layout, wherever that now is.
final remap = remapEntitySave(
  _saved,
  oldNames: <String?>['goblin', 'troll'],
  newNames: names,
  newGenerations: List<int>.filled(names.length, 0),
  newFree: <int>[],
);
final EcsWorld reloaded = _newWorld();
final List<Entity> ids = <Entity>[
  for (var i = 0; i < names.length; i++) reloaded.spawn(),
];
reloaded.restore(remap.save);
return <double?>[
  for (final Entity id in ids) reloaded.get<_Position>(id)?.x,
];