DOTS//CORE local · not synced

m01 · Entities Core · reading · 9 min

Tags and shared components — carrying meaning without carrying bytes

A component with no data still costs a set change

Some components have no fields at all:

public struct Enemy : IComponentData { }   // a tag

Enemy stores zero bytes per entity, so it doesn’t consume chunk capacity the way Health does. But it is still a type in the archetype’s set — and from the previous lessons you already know what that means: an entity with {LocalTransform, Enemy} has a different archetype from one with just {LocalTransform}, and therefore lives in a different chunk. A tag carries no bytes but it carries membership, and membership is the query key.

That is exactly what you want a tag for. WithAll<Enemy>() on a query doesn’t scan a boolean field on every entity and branch — it selects only the chunks whose archetype includes Enemy and skips the rest wholesale, at the chunk level, before touching any entity data. Filtering by tag is filtering by archetype, which is nearly free because the runtime already keeps entities partitioned that way. The tag turns a per-entity question into a per-chunk one.

The cost is the flip side of the same coin: because adding or removing a tag is a set change, it is a structural change — the entity moves chunks. So a tag is the right tool for a property that is stable over an entity’s life (this thing is an enemy; that thing is projectile debris), and the wrong tool for something that flips frequently (is this enemy currently stunned?). Flipping a tag every frame is paying a memcpy per flip. That specific pain is what enableable components exist to remove — the next module’s territory — but you can already see the dividing line: tag for stable membership, something-else for volatile state.

Shared components: one value for a whole chunk

A different problem: suppose many entities share a value that is identical across large groups — a render mesh, a team ID, a spatial-grid cell, a material. Storing it per-entity as an ordinary component would duplicate the same bytes thousands of times and waste chunk capacity. A shared component stores the value once per distinct value, and partitions entities into chunks by which value they hold:

public struct RenderMesh : ISharedComponentData { public int MeshId; }

The mechanism is precise and worth stating exactly: entities are now grouped into chunks not only by their component set but also by their shared-component value. All entities with MeshId == 3 occupy chunks distinct from those with MeshId == 7, even though both groups have the same archetype otherwise. The shared value isn’t stored in the chunk’s per-entity spans at all; it’s stored once and referenced by the chunk. Per-entity byte cost: effectively zero. Per-entity capacity hit: none.

cache line · 64 B fetched 64 B useful · 100% of the bandwidth you paid for within one shared-value chunk, iteration is still fully dense — the shared value lives once, off to the side, costing no per-entity bytes

Why does this shape exist? Because it makes “process all entities that share value X” a chunk-level selection — the same trick as tags, but keyed on a value rather than mere presence. Rendering iterates per mesh; physics iterates per collision layer; a grid system iterates per cell. Shared components make each of those a clean sweep over exactly the chunks that match, with the shared datum available once per chunk instead of fetched per entity.

Where shared components become a trap

The danger is a direct consequence of the mechanism. If chunks are partitioned by shared value, then the number of distinct values is the number of chunk partitions — and each partition can’t share a chunk with any other, so each rounds up to at least one chunk. Give a shared component high-cardinality values (a unique-ish float, a per-entity ID, a position) and you get one partition per value: thousands of near-empty chunks, each holding a handful of entities, wasting almost all of their 16 KB and destroying the density the whole system exists to provide.

Choosing, from the math alone

You now have four ways to attach meaning to an entity, and you can pick between them from cost, not taste:

  • Ordinary IComponentData — per-entity data that varies per entity and gets read in loops. Costs its size in chunk capacity. Default choice for real data.
  • Tag (IComponentData, no fields) — stable presence/absence you query on. Zero bytes, but add/remove is a structural change. For properties fixed over an entity’s life.
  • Shared component (ISharedComponentData) — a value identical across many entities, used to group them. Zero per-entity bytes, partitions chunks by value. Only for low-cardinality grouping.
  • Enableable component (next module) — per-entity on/off state that flips often, without a structural change. The tool for the volatile case tags handle badly.

What this buys you

You can now reach for the right kind of component by reasoning about two axes: how often the property changes (structural cost) and how many distinct values it takes (partition cost). That single decision — made correctly — is most of what keeps an archetype dense and its queries cheap. The lab puts a number on it: you’ll take one archetype, move a field between ordinary, tag, and shared representations, and measure the chunk count and iteration cost each choice produces on your own machine.

m01.l04