Archetype SoA storage (M1-ECS-03; PRD §9.1, AGENTS PERF-003/004,
CORE-001, G-R4). Public header:
src/laige-sim/include/laige/sim/archetype.h (constants, ArchetypeStats,
layout contract) plus the World member templates in
src/laige-sim/include/laige/sim/entity.h; implementation:
src/laige-sim/archetype.cpp (+ the slot tables in entity.cpp).
Unit suite: ctest -R archetype (tests/laige-sim/archetype_tests.cpp),
including the 10k-entity churn test whose machine-greppable stats line
lands in the ctest output on every run (CORE-001).
An archetype is a set of component types an entity can carry,
stored as Structure-of-Arrays: one contiguous T[] column per
component type, one row per entity in the set. The entity→archetype
map is two dense per-slot tables (archetypeOf_, rowOf_), so
get<T>(e) is O(1): slot → archetype → column index → row, no
hash, no search (the roadmap's "archetype lookup + column index").
per archetype (256 max, created on first sighting of a component set):
sig the component ids, strictly ascending (dense 1-based)
fingerprint FNV-1a 32-bit over sig (short-circuit only; a collision
is harmless — the lexicographic verify decides)
slotCol[r] the entity slot id of row r — rows are kept in
ascending slot-id order (a pure function of the world
state; M1-ECS-05 pins the convergence property)
columns[c] one byte[] block per component: rowCapacity * size bytes
(over-allocated 31 B), base aligned to 32 bytes
(kArchetypeColumnAlignment — covers every
trivially-copyable alignof on the P0 targets)
per entity slot (entity.h): archetypeOf_[slot] (0 = no archetype) +
rowOf_[slot] — the dual representation; the two always agree
(invariant I2, maintained inside attachSlot/removeRow).
Rows are packed (no gaps): the tail is memmoved on every
insert/remove, so contiguity per column is a stored-data property,
not an assumption — the suite pins it (the ArchetypeLayout
property tests check element spacing, 32-byte alignment, and the
slot-ordered addresses).
| Operation | Behavior | Complexity / allocation |
|---|---|---|
world.has<T>(e) |
Membership: is T in e's set? Stale handle → false (pure query, no warn); unregistered T → false |
O(1), no allocation |
world.get<T>(e) |
T* into the column row, or nullptr: stale handle (warn-once via check), unregistered T, or e lacks T (silent — the two silent cases are documented, never an error) |
O(1), no allocation |
world.addComponent<T>(e, v) |
Create-or-update: T present → overwrite in place (no move); absent → move e to the set current ∪ {T}, copying the shared components into the new row. Stale handle → InvalidArgument + warn-once; unregistered T → InvalidArgument + warn; set would exceed 32 components → BudgetExhausted + warn |
O(tail × row-stride) bytes moved (tail = rows at/above the insertion point); no heap allocation — growth is a pre-reserved, accounted, logged reserve (below) |
world.removeComponent<T>(e) |
Remove T: no-op ok if absent; else move e to current ∪ {T} \ {T}, copying the remaining components into the new row. Same error rows as add |
as add |
world.archetypeCount() |
Distinct component sets created so far (archetypes are never destroyed; empty sets stay) | O(1) |
world.archetypeStats() |
ArchetypeStats snapshot: archetypeCount, rowsLive, rowsReserved, bytesReserved, totalAdds, totalRemoves, totalArchetypeGrowth, totalReservations, totalRowShifts (the M1-PROF-01 / G-R4 feed) |
O(256 × 32) cold pass, no allocation |
The World::destroy(e) / World::clear() cost note now includes the
row detach: an entity with components leaves its archetype first —
O(tail × row-stride) bytes moved, still no allocation (entity.md).
Each column block is reserved, not sized, to the rows it holds:
- Initial:
min(kInitialArchetypeRows = 16, world capacity)rows, at archetype creation. - Growth: ×2, capped at the world capacity — one bounded
unique_ptrreallocation per column per growth, accounted intotalReservations/totalArchetypeGrowthand logged (ecs/archetype_grow). Bounded bylog2(capacity / 16) + 1growth events per archetype (≤ 10 for a 10k world). - Steady state: an add/remove that does not hit a full archetype
allocates nothing — the churn test proves it: zero reservation
delta over the window and zero allocations on the churn thread
(test-only
operator newcounter, non-sanitizer trees; the sanitizer trees prove the same property with a leak-free run of the same loop — M1-ALLOC-01 lands the standing assertion).
| Constant | Value | Meaning | Error when exceeded |
|---|---|---|---|
kMaxArchetypes |
256 | distinct component sets per world | BudgetExhausted + warn ecs/archetype_budget |
kMaxArchetypeComponents |
32 | components on one entity (one row) | BudgetExhausted + warn ecs/component_limit |
kMaxComponentTypes |
256 | registered types per world (M1-ECS-02) | BudgetExhausted |
The 256/32 bounds are M1 engine-level caps (documented here and in archetype.h); raising either is an ADR (the zone workloads — PRD §12.1: 200–2000 entities, small component palettes — are far inside them).
Stable subsystem ecs, stable events:
| Event | Severity | When |
|---|---|---|
archetype_created |
Info | a component set is seen for the first time (archetype_id, components) |
archetype_grow |
Info | a column reserve doubles (archetype_id, rows, components) |
archetype_budget |
Warn | 257th distinct set (rate-limited, archetype_count) |
component_limit |
Warn | 33rd component on one entity (rate-limited) |
component_unregistered |
Warn | add/remove of a type not registered in this world (rate-limited) |
stale_entity_access |
Warn | component op on a stale handle (rate-limited; inherited from M1-ECS-01) |
Repeats are rate-limited per (subsystem, event, severity) with the
suppressed-count summary on shutdown (LOG-004) — the suite pins the
warn-once + rate_limited drain.
-
Hot path:
has<T>/get<T>are O(1) pointer arithmetic (slot table → record → column binary search ≤ 32 → row). No allocation, no lock, no I/O, no logging on any success path (PERF-003; LOG-003). -
Move cost: add/remove between archetypes memmove the tail — O(tail × row-stride) bytes plus the O(tail)
rowOf_re-sync. This is the documented cost of the dense packed rows (PERF-004: contiguous over pointer-per-entity); it is a spawn/despawn-time cost, not a per-tick one (the per-tick path is the M1-ECS-04 iteration, query.md, which never moves rows). -
Measured baseline (CORE-001; g++ 16.2.1, 2026-09, single-threaded headless): 10k entities × 20k add/remove ops (seeded random order, full cost range), 3-component working set:
Build p50 p99 p99/p50 Debug ( -O0)0.123 ms 0.243 ms 1.98 Release ( -O2)0.0021 ms 0.0040 ms 1.94 The wall-clock numbers above are a recorded baseline, not a gate: they are machine-dependent, and the shared macOS CI runners' wall-clock tail alone reaches ~5× the median (2026-09-13 runs: p99/p50 = 4.9 on macos-14/macos-15 vs 1.98 on Linux), so a raw time-ratio assertion does not travel across P0 platforms (methodology §6). The suite gates the work instead, which is identical on every platform: the window's total row-shift count is a seed-independent deterministic constant — each op moving slot
sshifts exactly2 × (9999 − s)rows (s's rank in each archetype sums tos), so the window shifts exactly10000 × 9999 = 99,990,000rows for any seed — asserted as a KAT againsttotalRowShifts— plus a per-row wall-clock floor (≤ 200 ns/row; measured ~24 ns/row Linux Debug, ~7 ns/row macOS Debug, and VM preemption spreads across the window). The zero reservation delta and the zero-allocation window are asserted too. The machine-greppable lines (archetype-churn <stats>,archetype-churn work: rows_shifted=… ns_per_row=…) land in the ctest output on every run — the M1 baseline record for the G-R4 feed. -
Memory per entity (live, with components): one row per archetype column —
Σ component sizesbytes (8 B for a pos+vel pair) plus the 2 B slot column slot; per-slot bookkeeping is 11 B (entity.md). Reserved (not live) bytes are accounted inbytesReserved. -
Traps:
- The move cost is proportional to the row above the insertion point: adding a component to a high-slot entity in a large archetype is the expensive case (the churn window spans exactly this range). Batch spawn/despawn when possible (API-002); the per-tick iteration never moves rows.
- Archetypes are never destroyed: a workload that churns through many component sets fills the 256-set budget even though few entities are live — keep the per-world component-set count bounded by design (game palettes are small; the warn names it).
get<T>returns a raw pointer valid until the entity's next add/remove/destroy (the row can move) — never hold it across a mutating op.
- Single owner thread (CONC-001); not thread-safe (M1 is single-threaded simulation — PRD §10.2).
- Determinism (ARCH-010): the row order is a pure function of the world state (ascending slot id); the type→id lookup is a deterministic splitmix64 hash with linear probing that is only ever queried (never iterated), and the archetype scan is a lexicographic verify — no platform intrinsics, no floating point. The same operation sequence produces bit-identical layouts and address sequences on every platform; two worlds that reach the same state through different interleavings share the layout (the suite's convergence test).
// Setup (once): register every component type the world will carry.
ASSERT(world.registerComponent<PlayerPos>().ok());
ASSERT(world.registerComponent<PlayerVel>().ok());
auto e = world.create();
if (e.isError()) { /* BudgetExhausted: refuse the spawn (S-2) */ }
// Spawn: create-or-update (an existing component is overwritten, not duplicated).
world.addComponent<PlayerPos>(e.value(), PlayerPos{1, 2});
world.addComponent<PlayerVel>(e.value(), PlayerVel{9});
// Read one component (O(1)); the per-tick pattern is the M1-ECS-04
// query — world.each<PlayerPos, PlayerVel>(fn, Read{}, Write{}) —
// see query.md.
const PlayerPos* p = world.get<PlayerPos>(e.value()); // O(1), nullptr on absence
if (p != nullptr) { /* use p — valid until the entity's next mutation */ }
// Despawn:
world.removeComponent<PlayerVel>(e.value()); // back to {PlayerPos}
world.removeComponent<PlayerPos>(e.value()); // component-less, still aliveget<T>returningnullptris not always an error: unregisteredTand absentTare silent by contract (a stale handle is the one case that warns). Branch on it; don't log every miss.- Holding a
get<T>pointer acrossaddComponent/removeComponenton the same entity is dangling (the row moves). Re-fetch. addComponentis create-or-update: passing a new value for a present component replaces it — no duplication, no error.- Adding components one-by-one through many intermediate sets walks through (and leaves alive) intermediate archetypes; for a spawn with N new components, N−1 moves are the cost.
- The 256-set / 32-component caps are hard engine bounds: a
Warnecs/archetype_budget/ecs/component_limitmeans the game's component design outgrew M1 — fix the design or ADR the bound.
- M1-ECS-01 (done): the entity handle and slot storage — entity.md.
- M1-ECS-02 (done): the component registry that supplies the ids and sizes this storage consumes — component_registry.md.
- M1-ECS-03 (this step): the storage above.
- M1-ECS-04 (done): the query/iteration API over these columns —
World::eachwith per-componentRead/Writeaccess and the stack-scoped iteration-legality guard — see query.md. - M1-ECS-05 (done): the deterministic iteration contract over the stored row order (the slot-ordered scheme above is what the query iterates) — see iteration_order.md.
- M1-ECS-06: the G-R3 warn thresholds read the same slot tables.
- M1-PROF-01 / G-R4:
archetypeStats()feeds the profiler. - M1-ALLOC-01: the standing zero-allocation assertion over the churn property this step measured.