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<Transform2D>().component; // fails if absent
if (entity.has<Child>()) entity<Child>().detach(); // only if it has one
entity.destroy(); // it and its subtree
entity<T>() resolves the prefab that declared the archetype, so the
component it hands back has all its columns bound to that archetype's layout.
Naming a component is a claim that the archetype carries it. When it does not,
.component fails: an assertion naming the archetype and the component in
debug, and the cast under it in release, which throws either way. Ask
entity.has<T>() first when absence is a case you handle — it is the same
lookup, answering true or false instead of a component.
There is no nullable spelling. Accessor<Transform2D?> would be the
supertype of Accessor<Transform2D>, because extension types are covariant
in their type parameter, so an extension written on Accessor<Transform2D>
would not apply to it: it would reach .component and not one generated
property, addChild, or setEuler.
The accessor itself is never null. It is an extension type over Entity and
erases to an int, and a nullable one would not.
Resolve components per group, not per entity
entity<T>().component 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 healthHp = Field.int32(100);
final healthMaxHp = 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.
Prefix a component's columns with the component¶
Health above spells its columns healthHp and healthMaxHp, not hp and
maxHp, and every component in the engine does the same: Transform2D has
transformOffsetX, Child has childParent, Camera has cameraZoom. Use a
readable stem rather than the full type name, and drop a dimension suffix —
Transform2D and Transform3D are never on the same entity, and
transform2dOffsetX reads badly.
The reason is that an entity's columns share one namespace. 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, and nothing anywhere says so.
Child.parent was the sharpest case before the rule: parent is exactly what
your own component would call a field, and mixing yours in beside Child would
have silently taken the column over. It is childParent now, and yours can be
whatever you like.
The engine cannot catch a collision at run time
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 of yours colliding with Child.childParent 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.
The prefix is what makes that report rare. The checker catches the ones that happen anyway — including in a package whose author never ran it.
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.cameraView are stored |
Field.heapObject<T>() / Field.optHeapObject<T>() |
T / T? |
an arbitrary Dart object, by registry address |
Field.array(element, length, [initial]) |
DataArrayPointer<T> |
fixed-length inline array — the element is an argument, so Field.array(.uint16, 8) and Field.array(table, 4, first) are the same method |
Field.arrayOf(element, length, initials) |
DataArrayPointer<T> |
the same, with each element starting at its own value |
Field.optArray(element, length, [initial]) |
DataArrayPointer<T?> |
every element a value or null, independently |
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 initial value, which is what a freshly allocated row starts at:
It is stamped once and never consulted again: seal builds one prototype row
holding every column's initial value and memcpy's it into each row allocated
afterwards. So writing null into a nullable column reads back null and does
not restore anything.
Choose it carefully
Transform2D starts scale at 1 and offset/rotation at 0, because 0
is the identity for the latter two and is a collapse-to-nothing for the
first. A wrong one 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 healthHp and healthMaxHp 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.healthHp[this] = health.healthHp[this] - amount;
}
bool get isDead => component.healthHp[this] <= 0;
}
You reach them through the entity:
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 —
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:
extension HealthAccessor on Accessor<Health> {
void kill() {
component.healthHp[this] = 0;
destroy();
}
}
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<Health>().component;
theirs.healthHp[other] -= amount;
mine.healthHp[this] += amount;
}
}
other may be a different prefab with a different row layout, and only the
receiver is guaranteed to be this one. mine.healthHp[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¶
Override onEntityMounted to run code when a row is created:
class Bullet extends EntityStruct with Transform2D, Renderable2D {
@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 engine calls it about this prefab's own entities and no others, so you
never have to ask whether the entity was one of yours. 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), SceneLoadListener and
GameLifecycleListener. Those go on a GameSystem, not on a prefab.
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 what makes system order irrelevant — 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.
DataPointer.readPending is what the hierarchy reads through. Three column
kinds implement it — float64, optInt64 and optEntity — and every other
throws, so it is not a general escape from the published read.