Entities and components¶
Layer: kernel (good)
This is the page to read slowly. good's storage model is the thing most unlike an object-oriented engine, and everything else follows from it.
A column has to hold something, so the examples build 2D things. Transform2D,
Renderable2D and Collider2D come from goo2d; the storage model underneath
them is the kernel's and is the same either way. For the 3D components see
Transforms and hierarchy (3D).
An EntityStruct is a layout, not an object¶
class Bullet extends EntityStruct with Transform2D, Renderable2D {
final velocityX = Field.float64();
final velocityY = Field.float64();
final life = Field.float64(3.0); // default for every new row
}
There is one Bullet instance in your whole game, no matter how many
bullets exist. It is the declaration: it says that a bullet's row has three
doubles beyond what its mixins contribute, and it hands you a handle to each
column.
velocityX is a column. Entity is the row index:
velocityX[entity] = 12.0; // write row `entity`, column velocityX
final v = velocityX[entity]; // read it back (published snapshot)
Never store per-entity state as a Dart field
The prefab is shared. A plain field is a global. Per-entity state is always aDataPointer.
A plain field is fine for declare-time constants and for scratch state a
system owns — the example demos use int _spawned = 0 on a prefab as a
spawn counter, which is per-prefab and intentional.
What the layout buys you¶
Rows of the same archetype are contiguous in one native page, so a system
walking every bullet's velocityX walks memory in order — the cache behaviour
struct-of-arrays exists for. And because a column is an int address plus an
offset, not a dart:ffi Pointer object, indexing it allocates nothing.
Entity¶
An extension type over int, packing three things:
extension type const Entity(int value) implements int {
int get archetypeId;
int get pageIndex;
// + row offset
}
Passing one around costs an int. It is also self-describing — given only the
integer, the engine finds the archetype, the page, and the row.
final transform = entity.get<Transform2D>(); // throws if absent
final child = entity.tryGet<Child>(); // null if absent
entity.destroy(); // removes it and its subtree
get<T>() resolves the prefab that declared the archetype, which is why it
returns the component with all its columns bound to that archetype's layout.
Resolve components per group, not per entity
entity.get<T>() is a registry lookup. In a loop over thousands of
entities, resolve once per group and index by entity inside:
Components¶
A Component is a mixin on an EntityStruct. It contributes two things: a
queryable type and some columns.
mixin Health on Component {
final hp = Field.int32(100);
final maxHp = Field.int32(100);
@override
void describeType(ComponentDescriptor component) {
super.describeType(component);
component.has<Health>(); // (1)!
}
}
- This is what makes
withAll(Health)match. A mixin that declares columns but not its type contributes storage without being queryable.
Mix it in, and the archetype gains those columns:
describeType must call super — each mixin in the chain contributes, and
skipping super silently drops everything below it. The same goes for
describeAssets and describeStruct. Columns declared as fields need no such
discipline: Dart runs every initialiser in the chain itself.
On your own EntityStruct subclass the analyzer enforces this, because the
hook it overrides carries @mustCallSuper. Inside a component mixin it cannot:
that annotation reports only where there is a concrete implementation
underneath, and Component declares all three hooks with no body. So
good generate checks it instead, and stops:
A mixin that never overrides a hook is fine and is not mentioned. Only an override that leaves the call out is the defect.
Field names collide across mixins
A component is a mixin, so two of them declaring a field called speed is
not an error in Dart. It is an override. The later mixin in the with
clause wins, the row grows by both columns, and the earlier column can no
longer be reached under that name. Anything you write against the hidden
one reads and writes its neighbour.
The engine cannot catch this for you. Field.float64() declares a column
against the descriptor the framework has open, and nothing tells it which
Dart field holds the result — so two mixins declaring speed reach the
engine as two anonymous columns, which is exactly what two columns that
genuinely differ look like. The name is in your source and nowhere else.
good generate reads it there, and stops:
It compares your lib/ against the engine packages your project resolves,
so a field you call parent colliding with Child.parent is caught as
well. A component from some other package is one it cannot read. Those
parts are listed by good generate --verbose, so you can tell a clean run
from an incomplete one.
Pick names that will not collide. Transform2D calls its position columns
transformOffsetX and transformOffsetY, and a component you publish for
other people to mix in wants the same kind of prefix.
Multi-components¶
Some components are declared through their own descriptor instead of by
contributing fields directly — Renderable2D (sprites) and Collider2D
(shapes) are on MultiComponent, which lets one entity declare several sprites
or several collider shapes. You use them the same way; the difference is the
extra describeSprites/describeCollider pass.
Field kinds¶
Field offers a wide set because packing matters — a flag that takes one bit
instead of eight is 7 bits per entity per frame of bandwidth saved.
| Call | Column type | Notes |
|---|---|---|
Field.boolean() |
bool |
one bit |
Field.uint1/2/4(), Field.int1/2/4() |
int |
sub-byte, for small counters and packed bits |
Field.uint8/16/32/64(), Field.int8/16/32/64() |
int |
|
Field.entity() |
Entity |
a handle to another entity, 64 bits — see storing a handle |
Field.optEntity([default]) |
Entity? |
the same handle, or null for no target — a presence bit ahead of the 64 |
Field.enumOf(values, [default]) |
E |
the member's index, in the narrowest column its values fit — two bits for four members |
Field.float32(), Field.float64() |
double |
Transform2D uses float64 |
Field.packed<T>(table, [default]) |
T |
a value with an int representation |
Field.optPacked<T>(table, [default]) |
T? |
nullable packed — how Sprite.texture and Camera.view are stored |
Field.heapObject<T>() / Field.optHeapObject<T>() |
T / T? |
an arbitrary Dart object, by registry address |
Field.*Array(length, [default]) |
DataArrayPointer<T> |
fixed-length inline array |
Field.boolean and not Field.bool, and Field and not Column: bool is a
type and Column is a Flutter widget, and neither name can be reused without
breaking the file that uses it.
Every one takes an optional default, which is what a freshly allocated row starts at:
Choose the default carefully
Transform2D defaults scale to 1 and offset/rotation to 0, because 0
is the identity for the latter two and is a collapse-to-nothing for the
first. A wrong default shows up as an entity that is invisible or at the
origin with nothing anywhere saying why.
Heap objects¶
Field.heapObject<T>() stores an arbitrary Dart object by address in a
registry. It is the unconstrained escape hatch, and it does not cross the
isolate boundary meaningfully — the address means something only in the isolate
that registered it. Prefer Field.packed for anything the other side has to
read.
Helpers go on an accessor¶
Health gives you hp and maxHp and nothing that does anything with them.
Methods like damage and isDead do not go on the mixin. They go on an
extension of Accessor<Health>:
extension HealthAccessor on Accessor<Health> {
void damage(int amount) {
final health = component;
health.hp[this] = health.hp[this] - amount;
}
bool get isDead => component.hp[this] <= 0;
}
You reach them through the entity:
That is a call and not an index, because an operator cannot be generic:
enemy[Health] could only ever be typed Null.
The convention is the whole of it — name the extension <Component>Accessor
and hang it on Accessor<YourComponent>.
Inside the extension, this is the entity¶
Accessor<T> implements Entity, so this is the row index and a column takes
it directly. component is the Health the receiver's archetype declared,
sugar for get<Health>() — an inlined list index and a type check, not a map,
so reaching for it twice is a style question and not a cost.
The rest of Entity is there too:
and an accessor goes anywhere an entity is wanted:
A helper that takes a second entity resolves that one's component itself:
extension HealthAccessor on Accessor<Health> {
void drain(Entity other, int amount) {
final mine = component;
final theirs = other.get<Health>();
theirs.hp[other] -= amount;
mine.hp[this] += amount;
}
}
other may be a different prefab with a different row layout, and only the
receiver is guaranteed to be this one. mine.hp[other] compiles, and reads
this archetype's storage at the other entity's page and row — some unrelated
entity's health, or nothing at all.
Two components can want the same method name¶
Accessor<Health> and Accessor<Transform3D> are different types, so both can
declare distanceTo and a file importing both still compiles. Two mixins
declaring it collide.
A method on the mixin also has to take the entity as an argument, because the
Health it would run on is the prefab, one for the whole archetype, and belongs
to no row in particular. Once two entities are in the signature, nothing in it
says which one the receiver is about.
None of this costs anything. Accessor<T> is an extension type over Entity,
which is an extension type over int, so reaching a helper allocates nothing and
identical(enemy<Health>().entity, enemy) is true.
An archetype never changes¶
There is no addComponent and no removeComponent. A prefab's shape is
fixed when it is declared, and every entity of that prefab keeps that shape for
its whole life.
Instead, declare everything the entity could ever need and toggle the parts that come and go:
player.sprite.visible[entity] = false; // stop drawing it
player.hitbox.enable[entity] = false; // stop colliding
player.shielded[entity] = true; // your own bool field
If you are arriving from Unity, Godot or Flame, read Coming from Unity, Godot or Flutter — it is the full translation table and explains why the engine is strict about this.
Archetypes¶
The set of components an EntityStruct mixes in is its archetype. Two prefabs
with the same components are still two archetypes — identity comes from the
declaration, not from the component set — and each gets its own contiguous
storage.
Archetype ids are process-global and assigned in first-registration order, which is why declaration order must be stable and why scenes are declared once and loaded many times instead of registered per load.
Lifecycle hooks¶
Mix in EntityLifecycleListener to run code when a row is created:
class Bullet extends EntityStruct
with Transform2D, Renderable2D, EntityLifecycleListener {
@override
void onEntityMounted(Entity entity) {
super.onEntityMounted(entity);
life[entity] = 3.0;
velocityX[entity] = 400;
}
}
An entity must be finished when it is mounted
Not finished by the first system pass that happens to see it. If your update loop derives a transform from an angle, write that transform at mount too, with the same expression. Leaving it at the default for one frame is exactly how an entity appears to snap or flash at the origin on its second frame.
The mixin is what makes it work, and not as a formality. onEntityMounted is
an event: EntityStruct declares a dispatcher for it, and the boot pass
collects your prefab into that dispatcher because it is an
EntityLifecycleListener. Leave the mixin off and the override compiles, is
never collected, and never runs. Call super for the same reason you do in
describeType — another mixin on the same prefab may override the same hook.
Related mixins: EntitySpawnListener (a broad "something spawned" signal, which
is what the physics system listens to), SceneLifecycleListener,
SceneLoadListener, GameLifecycleListener, GameSystemLifecycleListener.
Events and listeners covers how all of them are delivered, and how
to declare one of your own.
Same-tick reads¶
An ordinary read returns the last published snapshot, not what you wrote a moment ago in the same tick. That is correct and deliberate — see the tick phases — but it has one sharp consequence worth knowing:
// Three children added in one tick.
for (var i = 0; i < 3; i++) {
scene.addEntity(limb, parent: body);
}
addChild reads the tail of the child chain to append to it. Three
addChild calls in one tick would each read the same stale tail, orphaning all
but the last. The hierarchy code reads the pending slot for exactly this reason.
If you write a linked structure across component rows yourself, you will meet
the same problem — and the fix is placement (a later phase), not a second read
method.