Engine design rules¶
These are for working on the engine itself. They are not about the per-frame path — see Hot-path rules for that — they are about keeping the codebase from acquiring the kind of structure that rots. Each one is here because it was violated first and cost something.
Never print to report a framework problem¶
Including through assert(() { print(...); return true; }()). It is swallowed
in release and invisible in test output. Use assert(false, 'message').
Do not add a specialised variant to escape a constraint¶
If operator[] reads the published snapshot and you want the uncommitted one,
the answer is not a readPending() beside it. A second read path means
every later reader has to know which one is right here, and the first one stops
guaranteeing anything.
Fix the placement instead: a value that must be read after it is written
belongs in a later phase. Unity DOTS does this by running
PresentationSystemGroup after SimulationSystemGroup over the same
LocalToWorld. Same here — WorldTransformSystem writes during the fixed tick,
and consumers run after it commits.
One fact, one place¶
If two structures have to agree and only your memory keeps them agreeing, they will drift. The analyzer checks types, not "these stay in step".
The tell: you cannot change one place without hunting for the others. Adding to a list means adding to a second list. Sorting one means permuting another. Setting a flag means also updating a count, a cache, or a mirror. When the correct edit is "and don't forget to…", fix the structure.
Remove the second copy, preferring in this order:
- Move the fact onto the object it describes — a property of the things in a list belongs on those things, not in a second array beside them.
- Derive it instead of storing it, when that is cheap enough.
- Fuse the structures, when they only ever get used together.
- Last resort: one collection of one small object with named fields.
The last one comes to mind first and is the weakest, because it makes the
coupling safe instead of removing it. Collapsing properly usually turns up dead
weight: _EventDescriptor held three lists in lockstep, and fusing them showed
two were the same decision and the third was never read.
The one exception
Struct-of-arrays for cache locality, which the storage layer is built on. That needs a benchmark and a comment saying so, and it never applies to boot-time structures.
A method that acts on one entity belongs on that component's accessor¶
A component mixin is one instance for the whole archetype. It describes the layout; it is not any particular entity. So a method on it that does something to an entity has to be handed the entity, and both spellings of that are wrong:
// no - the prefab is the receiver and the subject is an argument
transform.distanceTo(a, b);
parent.addChild(self, child);
body.applyImpulse(entity, ix, iy);
// also no - component-specific behaviour on bare Entity
extension Transform2DEntity on Entity {
double distanceTo(Entity other) { ... }
}
The tell: the signature needs an Entity parameter to say which entity
this is about. If removing that parameter would leave the method unable to name
its subject, the subject should have been the receiver.
The fix is an extension on Accessor<T>, named <Component>Accessor:
// yes
extension Transform2DAccessor on Accessor<Transform2D> {
double distanceTo(Entity other) {
final t = component;
...
}
}
a<Transform2D>().distanceTo(b);
parent<Parent>().addChild(child);
Accessor<T> implements Entity, so inside the extension this is the
entity: component.hp[this] indexes a column directly, and the accessor can be
passed anywhere an Entity is wanted. It costs nothing — Accessor<T> erases
to Entity, which erases to int.
Two things this buys that the first spelling cannot:
- A helper can no longer be called through the wrong prefab. A component
instance is bound to one archetype's row layout.
crate.setPivot(orcEntity, …)type-checks, and either reads the wrong storage or is silently repaired by a defensive re-resolve. With the entity as receiver there is nothing to mismatch. - Two components can want the same name.
Accessor<Transform2D>andAccessor<Collider2D>are different types, so both can declaredistanceTo— centre to centre and surface to surface — and a file importing both compiles. The bare-Entityextension givesambiguous_extension_member_accessinstead, and "don't import both" is no answer when they are in one package.
What stays where it is
- Declaration hooks.
describeType,describeStruct,describeSpritesanddescribeCollideract on the archetype, take no entity, and belong on the mixin. That is the same test, answered the other way, and it is whyEntityStruct.ofreturns a prefab rather than something per-entity. - Listener callbacks.
onEntityMounted,onEntitySpawnedand their unmount halves take anEntityas the event's payload, not as the receiver's subject. A broadcast listener is asking to be told about entities that are not its own. - Additional entities. Only the subject moves to the receiver.
distanceTo(Entity other)andlookAtEntity(Entity target)keep their argument, and must resolve that one's component themselves — it may be a different archetype with a different layout. - Private helpers inside a system.
_composeRoot(entity, …)is a system's own working code, not a component's API.
A property is for one entity, a column is for many¶
A property per Field on the accessor — transform.offsetX = 10.0 on
Accessor<Transform2D> — is the shape someone arriving from Unity already has:
entity.transform, then fields on it. Easing that move is a goal here, so code
that touches one entity gets to write it that way.
Engine code does not, and neither does a system walking many entities. Both resolve the component once per group and index the column:
// no - a property per entity, inside a loop over a group
for (final entity in group) {
entity<Transform2D>().offsetX += 1;
}
// yes - resolve once per group, and the write is an index
for (final group in query.groups()) {
final transform = group.get<Transform2D>();
for (final entity in group) {
transform.transformOffsetX[entity] += 1;
}
}
Unity DOTS puts the line in the same place, between
EntityManager.GetComponentData<T> and ComponentLookup with chunk iteration,
and states which is meant for which. State it here too, or the property turns up
in a loop over 20,000 entities with nothing to point at.
Hoisting the accessor does not hoist the lookup. Accessor<T> carries only
the entity and erases to an int, so component resolves again on every
access: three property lines are three resolutions. Each one is a bounds-checked
list index behind @pragma('vm:prefer-inline') and an is check. For one
entity that is noise. Run it per entity in a loop and it is what Resolve
components per group, not per entity in
Entities and components is about.
A property performs one read. column[entity] reads the published snapshot
and column.readPending(entity) reads the write slot, so a property can only be
the first of those. It never answers "what did I write earlier in this tick" —
addChild needs the pending lastChild, and a prefab's declared children need
the pending slot on the tick they mount. Those cases index the column, and
Same-tick reads covers
why the fix is usually to move the reader into a later phase instead.
The names come out better. A column carries its component's prefix,
transformOffsetX, because two mixins both declaring x on one prefab is a
silent two-column bug. Accessor<Transform2D> is its own type with nothing to
collide with, so the property is offsetX: shorter and safer at once.
An operation still beats a run of properties. setEuler(yaw: 0.5) is one
resolve and four writes, offset = (10, 20, 30) is one idea instead of three,
and upZ names something no column holds:
// yes
entity<Transform3D>().setEuler(yaw: 0.5); // one lookup, four writes
entity<Transform3D>().distanceTo(target);
entity<Transform3D>().upZ;
Nobody writes the properties by hand
They come out of the same analyzer pass that computes the row layout, off
the Field declarations it already reads. Transform3D alone is ten
columns and so twenty members, and the tree holds 53 Field declarations —
a hundred-odd members held in step by memory is One fact, one place
undone. That pass is not written yet, so for now every tier spells the
column.
Never dispatch on is to work out what the receiver is¶
// no — the type system reimplemented by hand, badly
final Object self = this;
if (self is GameState) return self.coroutines;
if (self is GameSystem) return self.state.coroutines;
throw StateError('...');
It compiles for a host it does not handle and fails at run time, it is invisible to "find implementations", and every new host is an edit to a method in another file. The tell: you are asking "what am I?" rather than "what can I do?".
The fix is to make the mixin state its requirement and let the hosts meet it. An unimplemented mixin member becomes a requirement on the applying class, which is exactly the constraint, checked by the compiler:
// yes
mixin Coroutines {
@protected
GameState get simulationState; // hosts must supply it
CoroutineScheduler get _scheduler => simulationState.coroutines;
}
on does not work here and neither does a marker interface: an on bound is
checked against the applying class's superclass, and none of the four hosts has
one that supplies a GameState — so every user would have had to write
with Coroutines by hand.
A pleasant side effect: applying a mixin with a private member twice is a compile
error, so a user who also writes with Coroutines is told, rather than
silently getting a second scheduler.
Legitimate is
Narrowing a value whose type genuinely varies at run time (yielded is num,
listener is EventBus), and tryGet<T>-style lookups that return null. The
rule is about dispatching on the receiver's own type.
Nothing is resolved by name¶
A key is a reference: a field, a tear-off, a type, a handle. Never a String,
and never a name that has to match something declared somewhere else.
Whatever a describe* pass produces comes back typed, and the caller keeps it in
a field. No Map<String, ...> the framework searches at use time, and no integer
index into a table you keep in step by hand. That covers buffers, state channels,
inputs, coroutines and colliders — every describe*.
A name used as a key is unanalyzable. 'chsae' compiles, passes review, and
fails at run time with an error naming the string instead of the mistake. A
reference makes the compiler reject the typo. It makes rename work, it makes
go-to-definition work, and it turns a dead entry into something the analyzer
finds before a player does.
Here is the shape, from Transitions in #115 — real, unbuilt, and drafted with
string keys during the months this rule was not written down:
// no
final transitions = Transitions.from({
'patrol': ['chase', 'flee'],
});
// yes — the nodes are already fields
final transitions = Transitions.from({
patrol: [chase, flee],
});
The distinction that keeps this usable is between resolving and displaying. Resolving by name is what the rule forbids: any map keyed by a name that has to match a declaration elsewhere. Carrying a name for display is fine — an inspector label, a debug-draw caption, a log line. So a state node may hold a label. Nothing may look a node up by that label. When you are unsure which side you are on, ask whether some Dart declaration has to be spelled the same way for the code to work.
A label cannot be inferred
Nothing on the declaration path sees a Dart field name. The runtime watches
an anonymous object register itself against the open descriptor, and the
identifier patrol never reaches it — which is why a label has to be passed
if it is wanted at all. That is the same fact that moved #58's shadowed-field
check out of the runtime and into the analyzer, so a check that a label
matches the field holding it belongs beside it, in
packages/good_cli/lib/src/generate/struct_scan.dart.
AOT-compile any benchmark that would change code¶
flutter test runs the JIT. Two write-path costs measured that way came out
wrong by roughly 100x, and what was tuned against them was tuned for a compiler
nobody ships.
Prefer a real device over a desktop for anything the frame budget depends on.
A benchmark must be able to fail¶
Two benches here reported "flat" — no effect — because their setup had made the effect unobservable. A negative result is worth something only if the same harness could have produced a positive one, so turn the optimisation off and watch the number move before you believe it did not.
The same trap wearing a different hat: whole-step totals reset once per
advance and accumulate over every fixed step inside it, while one system's
timing is a single step. The moment an advance costs more than one step, those
two stop sharing a denominator. A recording here read as a catastrophic
super-linear blowup and was entirely this.
A change to what a command or an API does updates Unreleased with it¶
In the same commit, under ## Unreleased in the CHANGELOG.md of the package
it affects, and saying what someone upgrading has to do about it.
Doing it at release time means reading every commit since the last tag and
working out user-visible effect from a diff that also touches tests and docs.
That is how a breaking change gets missed: not because anyone decided to leave
it out, but because it did not look like one in the diff. Six changes to what
good_cli's commands do went unrecorded between 0.1.1 and the pass that
reconstructed them, and every one of the six turns a build that worked into a
build that fails.
Three weeks later nobody remembers which commits were user-visible. The commit making the change is the only place that knowledge exists while it is still reliable.
Every commit names its issue¶
The subject ends with a trailing (#N).
Give each loaded scene its own Box2D world (#106)
Refuse to strip an asset compaction cannot rebuild (#136)
Of the sixty commits before this rule, twelve referenced an issue somewhere in
the body and not one did in a subject. So git log --oneline — the view anyone
actually reads — could not tell you what a single one of them was for. It gives
you the what and never the why, and the why sits in a tracker the log offers no
route to.
Use a bare (#N). No Closes, no Fixes: those cross-link and close, and
closing is not the commit's to do. An issue here is closed deliberately, with a
comment carrying the evidence and the test counts, and GitHub's auto-close fires
on push — which on this repo is rare and batched. A keyword would shut a stack of
issues at once, silently, on the schedule of whoever happened to push, and throw
that record away.
If a change has no issue, file one before you commit. The exception is pure mechanics with no design content — a formatting sweep, a revert — which should say so plainly instead of inventing an issue to point at.