Coming from Unity, Godot or Flutter¶
Layer: kernel (good)
good will feel familiar in its vocabulary — entities, components, systems, scenes, prefabs, coroutines — and then surprise you in one specific way:
Everything is known at declaration time. Nothing is added or removed at run time.
There is no AddComponent, no RemoveComponent, no GetComponent<T>() that
might return null because someone attached one this frame. A prefab's shape is
fixed the moment it is declared, and every entity of that prefab has exactly
that shape for its whole life.
Instead of adding and removing, you declare everything up front and toggle it on and off.
This page is the translation table: the call you would have written, and the one to write instead. For the layer underneath it — what happens to the habits an entity-component developer has built, and worked answers to state machines, cross-entity references and the rest — read Thinking in ECS.
The translation table¶
| What you would do elsewhere | What you do in good |
|---|---|
gameObject.AddComponent<Shield>() |
Declare the shield's fields on the prefab; toggle player.shielded[entity] = true |
Destroy(GetComponent<Collider>()) |
collider.enable[entity] = false |
renderer.enabled = false |
sprite.visible[entity] = false |
gameObject.SetActive(false) |
Turn off its parts, or entity.destroy() if it is really gone |
rigidbody.isKinematic = true |
body.bodyType[entity] = BodyType2D.kinematicBody |
| Attach a script at run time | Declare the system once; state.disableSystem<S>() when it should not run |
FindObjectsOfType<Enemy>() |
A Query declared once in describeQuery |
| A tag component added to mark state | A bool/uint1 field on the entity, tested in the loop |
Instantiate(prefab) |
scene.addEntity(prefab) |
Destroy(gameObject) |
entity.destroy() — takes its whole subtree with it |
Dictionary<string, Thing> lookups |
A late final handle returned by a describe* pass |
Why an entity cannot change shape¶
Three reasons, and they compound.
Archetypes are storage. An entity's components determine which native page
its row lives in and at what offset every field sits. Adding a component at run
time means moving the row to a different archetype — copying it, invalidating
every Entity handle that pointed at it, and forcing every compiled query to be
re-evaluated. Engines that offer it pay for it, usually as a structural-change
sync point. good does not offer it, so it does not pay.
Both isolates must agree. Archetype ids are assigned in first-registration
order and the two copies of your Game
each run the same declarations to arrive at the same ids. A component added at
run time on one side would exist on one side only, and every Entity handle
crossing the boundary would resolve to a different layout.
Toggling is free; restructuring is not. visible[entity] = false is a
one-bit write in a row you were walking anyway. It costs nothing, it cannot
fail, and it cannot invalidate a handle.
What toggles look like in practice¶
Every part of the engine that could plausibly be "attached and detached" exposes an enable flag on the row instead:
// Rendering — the sprite still exists, it is just not drawn.
player.sprite.visible[entity] = false;
// Collision — the shape stays declared; the solver ignores it.
player.hitbox.enable[entity] = false;
// Trigger versus solid, per entity, at run time.
player.hitbox.isTrigger[entity] = true;
// Physics authority — a body can become static without being rebuilt.
player.bodyType[entity] = BodyType2D.staticBody;
// Whole systems, for a pause menu.
state.disableSystem<AiSystem>();
Invisible sprites are dropped before they ever become a draw record, and disabled colliders never reach the solver — so an off toggle really is off, not "processed and then skipped at the end".
Your own optional behaviour¶
Do the same thing for gameplay. Declare the field, branch on it:
class Player extends EntityStruct with Transform2D, Renderable2D {
final shielded = Field.boolean(false); // one bit
final shieldEnergy = Field.float64(0);
}
const double drainPerSecond = 12;
for (final entity in group) {
if (!player.shielded[entity]) continue;
final left = player.shieldEnergy[entity] - drainPerSecond * dt;
player.shieldEnergy[entity] = left;
if (left <= 0) player.shielded[entity] = false; // toggle off, not remove
}
Isn't that wasteful — every player carrying shield fields?
Usually not, and measurably so. shielded is one bit; shieldEnergy is
eight bytes in a row you are already walking. The alternative — a separate
archetype, or a side table keyed by entity — costs a lookup per access and a
second structure to keep in step. When a variant is genuinely large and
genuinely rare, that is what a separate prefab is for.
When the difference is big: separate prefabs¶
Bullet and HomingBullet do not have to be one prefab with a flag. Two
prefabs are two archetypes, each with exactly the fields it needs, and a query
can match either or both:
homing = descriptor.query().withAll(Transform2D, HomingBullet).build();
allBullets = descriptor.query().withAny(Bullet, HomingBullet).build();
The rule of thumb: a flag for a state an entity moves in and out of; a prefab for a kind of thing it simply is.
Other expectations worth resetting¶
Components are not objects with methods on instances¶
A final speed = Field.float64() is a column, and speed[entity] is
the row. There is one Player object in your game, not one per player. A plain
Dart field on a prefab is shared by every entity of that kind — see
Entities and components.
There is no Update() per entity¶
Behaviour lives in systems that walk many entities at once, not in a method on
each entity. A prefab can carry lifecycle hooks for setup — onEntityMounted,
which it hears by mixing in EntityLifecycleListener — but the per-frame loop
belongs in a GameSystem. Those hooks are events, not virtual methods the
engine calls on your class; see Events and listeners.
Start()/Awake() ordering is explicit¶
Systems run in declaration order, adjusted by compareTo. There is no implicit
script execution order to discover.
Gameplay does not run on the Flutter isolate¶
A button press does not call into your simulation. It sends a command, which the game isolate handles on its own tick. Numbers come back through state channels, not through shared mutable objects.
Coroutines are sync*, not async¶
yield returns control to the fixed step. await would resume on a microtask,
outside the tick window, and every component write after it would be silently
discarded. See Coroutines.
The engine avoids allocation, and asks you to as well¶
.map, .where and .forEach allocate a closure per call. In a method that
runs sixty times a second over thousands of entities, that is the whole budget.
Write the indexed for — see Hot-path rules.
What you keep¶
Plenty carries over unchanged:
- Prefabs and scenes mean what you expect them to mean.
- The hierarchy is real: parent an entity and its world transform is composed, not copied.
- Coroutines read like Unity's
IEnumerator— because they are the same idea, for the same reason. - Fixed timestep is
FixedUpdate, and physics runs in it. - Flutter is your UI layer, in full, and that is the recommended way to build one: your HUD, menus and overlays are ordinary widgets over the game surface. Put UI in the game only when it is as interactive as the game — see Where your UI belongs.
Next¶
Thinking in ECS → is the layer under the table: why the engine is shaped this way, and what happens to the habits you built.