Table of Contents

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 subsequent Read/Set on that handle (~1-5ns per component for SingleVersion/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 for tx.Open + Read, ~100 ns for tx.OpenMut + a write, ~55 ns / ~75 ns through tx.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", TryOpenMut is that one probe — not TryOpen followed by OpenMut.
  • Every accessor — Transaction / EntityAccessor, PointInTimeAccessor workers, ArchetypeAccessor<TArch> (tx.For<TArch>()) — has the same five: Open/OpenMut throw on a missing or invisible entity, TryOpen/TryOpenMut return false, IsAlive is the existence probe. BulkLoadSession exposes the Mut pair. ArchetypeAccessor treats 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/TryOpenMut run the transaction's mutation check first, found or not: on a read-only or finished transaction they throw.
  • EntityRef and EntityRefMut are ref structs — stack-only, cannot escape their creating accessor/transaction, cannot be stored in a field or passed across threads. EntityRefMut → EntityRef is an implicit copy, so read-only helpers take an EntityRef.
  • 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 Set it back. Set<T> is the dirty boundary — it marks dirty (or stages copy-on-write) and stores the value in one call; no separate MarkDirty step.
  • A stored component's ComponentCollection is changed through EntityRefMut.CreateComponentCollectionAccessor(comp, ref copy, ref copy.Field): on a Versioned component 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 — no Set is needed for the collection.
  • Writing a Versioned component requires a full Transaction — a bare EntityAccessor or PointInTimeAccessor worker accessor throws on Set to a Versioned slot (read-only there).
  • Spawn/SpawnBatch entities are invisible to other transactions until commit (BornTSN = commit TSN); Destroy only 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 also Spawned 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 (Disable then Enable alone, no value required). Three states: enabled, disabled (value stored, O(1) re-enable), and absent (Versioned component never supplied at Spawn — Enable(comp) throws; use Enable(comp, in value)). Zero overhead unless a concurrent transaction is mid-Enable/Disable.
  • There is no tx.Read<T>(id) shorthand — always Open/OpenMut first to obtain a handle.
  • There is no flat CRUD API (CreateEntity/ReadEntity/UpdateEntity/DeleteEntity) — EntityRef / EntityRefMut are the only entity manipulation path.

🧪 Tests

  • EntitySpawnTests — Spawn/Open/OpenMut/Read/Set core paths, TryOpen on 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 / IsAlive on 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