m07 · Advanced Patterns · reading · 10 min
Enableable components — the fourth option, finally
The problem Module 1 set up and left open
Module 1 gave you four ways to attach meaning to an entity and priced three of
them. The fourth was named and deferred: “per-entity on/off state that flips
often, without a
Recall the dilemma precisely. You have a property that turns on and off during gameplay — stunned, burning, submerged, targeting. Two options, both bad:
Make it a tag. Zero bytes, and queries select on it beautifully — the
runtime skips whole
Make it a bool field. No structural change — writing a bool is just a write. But now the query can’t filter on it, so every system that cares must iterate all entities and branch per entity. You’ve traded structural cost for a branch in the hot loop and lost the chunk-skipping that made queries fast. Worse, the bool costs a byte in every chunk whether or not it’s ever true.
Both options fail, and they fail in opposite directions. That’s the shape of a problem where the answer is a third mechanism rather than a better choice between two.
Deriving the answer
State the requirements the mechanism must satisfy, and it designs itself.
- Flipping must not be structural. The entity must stay in its chunk. So the state cannot be encoded in the archetype.
- Queries must still filter on it. Otherwise you’re back to per-entity branching, and you’ve gained nothing over the bool.
- The filtering must be cheap — ideally still able to skip work in bulk, the way archetype matching skips whole chunks.
Requirement 1 says: not in the archetype. Requirement 2 says: the query system must be able to see it. Requirement 3 says: it should be readable in bulk, not per entity scattered across memory.
Those three together point at exactly one place — a bitmask in the chunk header, one bit per entity, per enableable component type. The archetype is unchanged (requirement 1). The query system reads the mask when it processes the chunk (requirement 2). And the mask is a handful of contiguous words at a known offset, so testing 128 entities’ state is a few loads, not 128 scattered reads (requirement 3).
Module 1 mentioned this in passing — the chunk header holds “per-type offsets, entity count, change-version numbers, and enableable bitmasks.” At the time that last item was a name with no meaning attached. Now it’s the mechanism.
Flipping the bit is a write to the chunk header. No archetype change, no chunk move, no sync point, no structural change at all. And crucially, it is safe inside a job — the thing that makes structural changes require a command buffer is that they invalidate chunk layout mid-iteration. A bit flip doesn’t.
The cost, stated honestly
Here is the part most explanations skip, and it’s the part that determines when to use this.
An enableable component occupies its full size in every chunk, always, whether enabled or not. Disabling doesn’t reclaim the bytes. The component data sits there; only the bit changes.
This follows necessarily from requirement 1. The whole point was that the entity doesn’t move and the archetype doesn’t change — but the archetype is what determines chunk layout, so if the layout stayed the same, the space stayed allocated. You cannot both keep the entity in place and reclaim its component’s bytes. The space is the price of the stability.
So the comparison, in full:
| | Tag | Bool field | Enableable | |---|---|---|---| | Bytes per entity | 0 | 1 (+padding) | full component size, always | | Flip cost | structural change | a write | a bit write | | Safe in a job | no (needs ECB) | yes | yes | | Query can filter | yes, skips chunks | no | yes, skips entities and empty chunks | | Best when | fixed for entity’s life | almost never | flips often, data attached |
That last row is the decision rule and it’s derivable from the table above it. Tags win when the property never changes, because zero bytes beats full size and you never pay the structural cost. Enableable wins when it flips, because the permanent byte cost is cheaper than repeated chunk moves. The crossover is about flip frequency, not about which feels tidier.
What the query actually does
One detail matters for reasoning about performance, and it’s a genuine subtlety.
When a query matches an archetype containing an enableable component, it processes the chunk and consults the mask to decide which entities to include. So a chunk where every entity has the component disabled is skipped efficiently — the mask is all zeros, nothing to do. But a chunk where half the entities are disabled is still visited, and the loop steps over the disabled ones.
This means enableable components do not restore full chunk-skipping. They restore entity-level filtering with cheap bulk tests, which is much better than a per-entity branch on a bool but not as good as an archetype that simply doesn’t match.
The practical consequence: if a property partitions your entities into stable groups that are processed separately and rarely change membership, a tag (or a shared component) still wins, because non-matching chunks are never touched at all. Enableable is for the volatile case where the churn cost of maintaining that partition would exceed the iteration savings.
What this buys you
You can now explain why the fourth option had to be a bit in the chunk header rather than a smarter tag or a cheaper bool, why disabling cannot reclaim memory, and why flip frequency rather than tidiness is the criterion. Next: determinism — the property that makes a simulation reproducible, why it is harder than it looks in a parallel system, and why Netcode depends on it absolutely.