Entity Lifecycle & CRUD API
EntityRef / EntityRefMut handles for Spawn, Open, Read, Set, Destroy, Enable/Disable — the sole entity manipulation API, safe for the life of their transaction.
Status: ✅ Implemented · Visibility: Public · Level: 🟢 Start Here · Category: Ecs
🎯 What it solves
Every entity touchpoint — create, read, mutate, delete, toggle a component — needs to resolve "where is this entity's data" before doing anything useful. A naive API re-resolves that on every call; for code that reads or writes several components on the same entity, that's redundant hashmap work per component.
⚙️ How it works (in brief)
tx.Open(id) / tx.OpenMut(id) probe the entity's per-archetype LinearHash once, check MVCC visibility at the
transaction's TSN, and return a handle — a ref struct caching the per-slot component locations. The handle's
type carries the access: Open / TryOpen return a read-only EntityRef, OpenMut / TryOpenMut an
EntityRefMut that also writes (and converts implicitly to EntityRef). Writing through a read-only open does not
compile. From there, Read<T>(Comp<T>) returns a copy of the component and Set<T>(Comp<T>, in T) stores one, each
resolving the slot in O(1): Versioned sets copy-on-write into a new revision, SingleVersion/Transient sets write in
place. Values go in and out by copy, never as a reference into the page, so a handle and everything it returned stay
valid whatever its transaction does next; zero-copy access is cluster iteration's
(Entity Clusters). At Spawn, an omitted Versioned component is absent — no chunk and no revision
chain are allocated for it — while an omitted SingleVersion/Transient component is zero-initialized and
disabled. The entity is staged invisibly until commit; Destroy tombstones it
(cascade-deleting configured children) — data is freed later by deferred GC, except when the entity was
also spawned in the same transaction, in which case the commit frees its storage immediately since it was
never published.
💻 Usage
[Component("Game.Position", 1, StorageMode = StorageMode.SingleVersion)]
struct Position { public float X, Y, Z; }
[Component("Game.UnitStats", 1, StorageMode = StorageMode.Versioned)]
struct UnitStats { public int Health, MaxHealth; }
[Archetype]
partial class Unit : Archetype<Unit>
{
public static readonly Comp<Position> Pos = Register<Position>();
public static readonly Comp<UnitStats> Stats = Register<UnitStats>();
}
// ─── Spawn — supply any subset; omitted Versioned components are absent (not zero-init) ───
using var tx = dbe.CreateQuickTransaction();
EntityId id = tx.Spawn<Unit>(
Unit.Pos.Set(new Position { X = 0, Y = 0, Z = 0 }),
Unit.Stats.Set(new UnitStats { Health = 100, MaxHealth = 100 }));
tx.Commit();
// ─── Read — one Open() amortized across every component access on `e` ───
using var rtx = dbe.CreateQuickTransaction();
if (rtx.TryOpen(id, out EntityRef e)) // try-pattern — no exception on a stale reference
{
Position pos = e.Read(Unit.Pos);
}
// ─── Write — OpenMut once, write/disable several components ───
using var wtx = dbe.CreateQuickTransaction();
EntityRefMut m = wtx.OpenMut(id);
Position p = m.Read(Unit.Pos);
p.X += 1f;
m.Set(Unit.Pos, p);
m.Disable(Unit.Stats); // O(1) bit flip — data preserved, not freed, instantly re-enable-able
wtx.Commit();
// ─── Maybe-stale target (a stored id, a link) — one resolve, no exception ───
using var ttx = dbe.CreateQuickTransaction();
if (ttx.TryOpenMut(id, out EntityRefMut t))
{
var stats = t.Read(Unit.Stats);
stats.Health -= 10;
t.Set(Unit.Stats, stats);
}
bool alive = ttx.IsAlive(id); // the existence probe — a lookup, no open
ttx.Commit();
// ─── Destroy — tombstones now (cascade-deletes configured children); freed later by GC ───
using var dtx = dbe.CreateQuickTransaction();
dtx.Destroy(id);
dtx.Commit();
⚠️ Guarantees & limits
- One LinearHash probe per
Open/OpenMut/TryOpen/TryOpenMut, amortized across every subsequentRead/Seton that handle (~1-5ns per component forSingleVersion/Transient). Measured open-plus-one-access, warm caches, 50 000 cluster entities in spawn order, Ryzen 9 7950X (Typhon.Benchmark --aa-bench, 2026-09-25): ~90 ns fortx.Open+Read, ~100 ns fortx.OpenMut+ a write, ~55 ns / ~75 ns throughtx.For<T>(). The copy itself costs nothing measurable up to 64-byte components; a 512-byte one adds ~24 ns to an open + read and ~40 ns to a read-modify-set (#1199). Expect more when the lookup misses the CPU cache (random ids over a large table). For "write it if it still exists",TryOpenMutis that one probe — notTryOpenfollowed byOpenMut. - Every accessor —
Transaction/EntityAccessor,PointInTimeAccessorworkers,ArchetypeAccessor<TArch>(tx.For<TArch>()) — has the same five:Open/OpenMutthrow on a missing or invisible entity,TryOpen/TryOpenMutreturnfalse,IsAliveis the existence probe.BulkLoadSessionexposes theMutpair.ArchetypeAccessortreats its transaction's pending destroys as missing, like the transaction does, but does not see the transaction's own spawns: they are not in the EntityMap until commit. OpenMut/TryOpenMutrun the transaction's mutation check first, found or not: on a read-only or finished transaction they throw.EntityRefandEntityRefMutareref structs — stack-only, cannot escape their creating accessor/transaction, cannot be stored in a field or passed across threads.EntityRefMut→EntityRefis an implicit copy, so read-only helpers take anEntityRef.- A handle stays valid for its whole transaction: no member returns a reference into a page (rule EP-03). A transaction refreshes its epoch every 128 opens, spawns or enumerated entities, so a long one holds a bounded set of pages; a handle whose page was let go of in between finds it again on its next call.
- To change one field, read the component, change the copy and
Setit back.Set<T>is the dirty boundary — it marks dirty (or stages copy-on-write) and stores the value in one call; no separateMarkDirtystep. - A stored component's
ComponentCollectionis changed throughEntityRefMut.CreateComponentCollectionAccessor(comp, ref copy, ref copy.Field): on aVersionedcomponent it creates the new revision first, so the committed revision's buffer is cloned, never edited in place, and on dispose it stores the buffer it ended on into the component — noSetis needed for the collection. - Writing a
Versionedcomponent requires a fullTransaction— a bareEntityAccessororPointInTimeAccessorworker accessor throws onSetto aVersionedslot (read-only there). Spawn/SpawnBatchentities are invisible to other transactions until commit (BornTSN = commit TSN);Destroyonly tombstones (DiedTSN = commit TSN) — entries/chunks are reclaimed by deferred GC once no live transaction can still see the entity; an entity this same transaction alsoSpawned is freed at commit instead, since it was never published and no other transaction can reference it.- Enable/Disable never frees or reallocates a chunk — data is preserved for an immediate, zero-cost re-enable
(
DisablethenEnablealone, no value required). Three states: enabled, disabled (value stored, O(1) re-enable), and absent (Versioned component never supplied at Spawn —Enable(comp)throws; useEnable(comp, in value)). Zero overhead unless a concurrent transaction is mid-Enable/Disable. - There is no
tx.Read<T>(id)shorthand — alwaysOpen/OpenMutfirst to obtain a handle. - There is no flat CRUD API (
CreateEntity/ReadEntity/UpdateEntity/DeleteEntity) —EntityRef/EntityRefMutare the only entity manipulation path.
🧪 Tests
- EntitySpawnTests — Spawn/Open/OpenMut/Read/Set core paths,
TryOpenon a stale id, rollback-doesn't-leak-chunks - EntityDestroyTests — Destroy tombstoning, visibility after commit vs. same-transaction, cascade through
EntityLink - EnableDisableTests — Enable/Disable bit-flip semantics, data preservation across re-enable, MVCC visibility of enabled-bits history
- EntityRefMutTests — the read-only / writable type split, no member returning a reference,
TryOpenMut/IsAliveon every accessor, the mutation check on a read-only transaction - EntityHandleLifetimeTests — a handle reads and sets its own entity after its page was evicted under it; a read-only transaction opening more entities than the cache holds keeps a bounded set of pages