Table of Contents

Enable/Disable Components

O(1) bit-flip to toggle a component's participation without freeing its data, copying it, or migrating the entity.

Status: βœ… Implemented Β· Visibility: Public Β· Level: πŸ”΅ Core Β· Category: Ecs

🎯 What it solves

Some component data needs to temporarily "not count" β€” a stunned unit's AI shouldn't tick, a downed unit's health regen shouldn't apply β€” without permanently removing the component. Removing and re-adding a component isn't even possible post-spawn (an archetype's component set is fixed for the entity's life), and reallocating storage just to toggle participation would be wasteful when the data needs to come right back. Enable/Disable gives every component slot a cheap two-state toggle that Query, iteration, and optional reads all respect, with the underlying storage untouched.

βš™οΈ How it works (in brief)

Each entity carries one EnabledBits bitmask on its EntityRecord β€” one bit per archetype component slot. Enable<T>(Comp<T>)/Disable<T>(Comp<T>) on a writable EntityRef flip that bit locally and stage the change via StageEnableDisable for commit; a read-only EntityAccessor/PointInTimeAccessor worker throws, since only a full Transaction supports staging structural changes. If the entity lives in cluster (batched SoA) storage, the cluster's own enabled-bit vector is updated immediately too, so bulk cluster iteration sees the change without waiting for commit. Because EnabledBits is entity-level metadata independent of each component's own StorageMode, it carries its own MVCC snapshot isolation through an engine-wide exception dictionary (EnabledBitsOverrides): a fast path (_overrideCount == 0, a single volatile-int read) skips it entirely when no concurrent transaction is mid-toggle; when one is, older transactions still resolve the pre-change bits via a per-entity EnabledBitsHistory. The query engine's .Enabled<T>()/.Disabled<T>() constraints and reactive views integrate for free β€” a per-component hasEnabledAwareViews flag keeps view notification at zero cost unless a view actually filters on enabled state.

πŸ’» Usage

using var tx = dbe.CreateQuickTransaction();
var id = tx.Spawn<Unit>(Unit.Pos.Set(new Position { X = 0, Y = 0, Z = 0 }));
// Velocity omitted at Spawn β€” absent: disabled, and with no storage at all
tx.Commit();

using var wtx = dbe.CreateQuickTransaction();
EntityRef e = wtx.OpenMut(id);
bool moving = e.IsEnabled(Unit.Vel);   // false β€” never set at Spawn
e.Enable(Unit.Vel, in vel);             // absent β†’ supply a value and enable; Enable(Unit.Vel) alone would throw
e.Disable(Unit.Pos);                    // O(1) bit flip β€” data preserved, not freed
e.Enable(Unit.Pos);                     // O(1) bit flip β€” the value is still there, no need to re-supply it
wtx.Commit();

// Safe optional read β€” false if disabled or absent, no exception
using var rtx = dbe.CreateQuickTransaction();
if (rtx.Open(id).TryRead<Velocity>(out var vel)) { /* vel is a copy, not a ref */ }

// Query-side: only entities with Velocity currently enabled
HashSet<EntityId> moving2 = rtx.Query<Unit>().Enabled<Velocity>().Execute();

⚠️ Guarantees & limits

  • O(1) for a component that has a value β€” a single bit flip on the EntityRef's cached bits, no allocation, no copy.
  • Disabling never frees or reallocates a component's chunk β€” data is preserved and immediately available on re-enable, and it does not have to be supplied again; chunks are freed only when the entity is destroyed.
  • Three states, not two. A component the spawn never supplied is absent, distinct from disabled: it has no value at all (for Versioned storage, no chunk and no revision chain). Enable(comp) on it throws β€” there is nothing to enable β€” and Enable(comp, in value) is the way in, costing an allocation rather than a bit flip. Reading an absent component fails exactly as reading a disabled one does, and ReadRaw returns an empty span where a disabled component still yields its bytes.
  • Enable/Disable require the Comp<T> handle overload β€” there is no bare-type-parameter form.
  • Requires a full Transaction β€” a read-only EntityAccessor/PointInTimeAccessor worker accessor throws (StageEnableDisable is Transaction-only).
  • Carries its own MVCC snapshot isolation independent of the component's StorageMode: even a SingleVersion/Transient component's enabled bit stays snapshot-consistent for concurrent readers via EnabledBitsOverrides β€” zero overhead (one volatile-int check) when no transaction is mid-toggle.
  • Visible within the same transaction immediately (read-your-own-writes) β€” .Enabled<T>()/.Disabled<T>() query filters see a pending, uncommitted toggle before that transaction commits.
  • Cluster-stored (batched SoA) entities update the cluster's own enabled-bit vector immediately on toggle, not just at commit, so bulk cluster iteration reflects it right away.
  • TryRead<T> returns a copy, not a ref (an out parameter can't be ref readonly) β€” for zero-copy access, check IsEnabled first, then call Read directly.

πŸ§ͺ Tests

  • EnableDisableTests β€” enabled-bits-after-spawn (full/partial), Enable/Disable bit-flip and data preservation across re-enable, commit persistence, MVCC (older transaction still sees pre-disable bits), multiple toggles in one transaction (last state wins), disable-then-destroy, all-disabled-but-entity-still-alive, same-transaction query visibility of a pending toggle