Cluster storage
In one line: N same-archetype entities packed contiguously in batched SoA — sequential array scans instead of per-entity hashmap + page-fetch lookups. Every archetype gets it; there is nothing to switch on.
Per-entity storage pays a hash-map lookup plus a scattered page fetch for every component of every entity, every tick — at 100K+ entities that indirection, not your logic, dominates the cost. Cluster storage (the engine calls it Entity Clusters) packs N entities (8–64, auto-sized per archetype to fill a page) into one contiguous chunk, each component laid out as its own packed array — Position[N], Velocity[N], … . Bulk iteration becomes a linear scan the hardware prefetcher loves; random Open/OpenMut resolves through the EntityMap to the same cluster slot.
📌 Every archetype is cluster-backed, and that is not something you configure — no opt-in, no opt-out, and nothing in your component mix can put an archetype on a different path. What a component's storage mode decides is not whether you get a cluster but where that component's array lives inside it:
| Component's storage mode | Where its array lives | What that gives you |
|---|---|---|
SingleVersion |
in the cluster chunk | packed SoA, read and written straight from the span |
Versioned |
HEAD in the cluster chunk, revision chain kept separate | MVCC snapshot isolation; reading any version but the HEAD leaves the packed array |
Transient |
a parallel segment with the same SoA layout | the same iteration pattern, with no page-cache backing and no persistence |
Reads behave the same whichever way you iterate — MVCC visibility, B+Tree and spatial indexes. Writes differ in one way: direct span writes (GetSpan / Get) bypass dirty tracking, so you must call MarkDirty(comp) (or MarkCurrentDirty()) or the write never reaches the WAL/checkpoint. A SingleVersion or Transient component with an [Index] field cannot be written through a span at all — GetSpan / Get throw on it, because the index moves an entity's entry from the key recorded at its write and a span records nothing. Read it with GetReadOnlySpan and write it per entity with ClusterRef.Set(comp, slot, value) — or per cluster with ClusterRef.Set(comp, slotMask, values) — which does the bookkeeping (old keys, dirty mark) in the same call — or with OpenMut(...).Set(...), the only path for a component that defaults to the Commit discipline or a transaction running under it, whose writes are staged.
How it relates
- Archetype — the unit that clusters; its cluster size is auto-chosen once at registration to fill a page.
- Storage mode — decides where each component's array sits inside the cluster, not whether there is one.
- Page cache & paged store — clusters are the layout within the paged store's pages, not a separate store.
- Spatial index — an archetype with a
[SpatialIndex]field additionally packs clusters by grid cell (spatially-coherent clustering).
In the API
- Bulk path:
GetClusterEnumerator()→ClusterRef<TArch>, withGetSpan<T>/GetReadOnlySpan<T>andOccupancyBitsfor branch-free per-slot access; flag writes withMarkCurrentDirty()orMarkSlotDirty(slot). - Random path:
Open/OpenMut— transparently cluster-backed; it resolves to a cluster slot without any work on your side. - The layout itself is an internal subsystem — you interact with it only through these accessors, never by allocating or sizing a cluster.
Learn & use
- Feature detail: Entity Clusters (Batched SoA Storage) — sizing, layout, dirty semantics, per-operation benchmarks
- Narrative: Guide ch.2 — modeling (choosing a storage mode) · ch.5 — systems (bulk iteration over clusters)