Skip to content

t1k:unity:dots-core:enableable-components

FieldValue
Moduledots-core
Version2.3.2
Efforthigh
Tools

Keywords: components, DOTS, ECS, enableable

/t1k:unity:dots-core:enableable-components

Handles IEnableableComponent patterns — toggle, query, performance tradeoffs. Does NOT handle general ECS queries (→ dots-ecs) or ECB patterns (→ dots-entity-command-buffer).

// Declare: add IEnableableComponent alongside IComponentData
public struct IsAlive : IComponentData, IEnableableComponent { }
public struct IsMoving : IComponentData, IEnableableComponent { }
public struct IsAttacking: IComponentData, IEnableableComponent { }
public struct IsStunned : IComponentData, IEnableableComponent { }

Enableable components hold data AND an enabled bit — same chunk, no archetype change on toggle.

OperationChunk move?Use when
SetComponentEnabled(entity, false)NoFrequent on/off toggle (stun, move, attack)
EntityManager.RemoveComponentYesPermanent state — entity never needs it again
Add tag componentYesRare, permanent category distinction

Rule: if the state toggles more than once per second on average, use IEnableableComponent.

// In a system (SystemAPI):
SystemAPI.SetComponentEnabled<IsStunned>(entity, true);
bool stunned = SystemAPI.IsComponentEnabled<IsStunned>(entity);
// In an IJobEntity (via EnabledRefRW):
void Execute(EnabledRefRW<IsMoving> moving, ref LocalTransform tf)
{
if (!moving.ValueRO) return;
// process movement...
moving.ValueRW = false; // disable inside job — safe
}
// WithAll<T> — matches entities where T is PRESENT and ENABLED
// (disabled T entities are excluded by default)
SystemAPI.Query<RefRW<Health>>().WithAll<IsAlive>();
// WithDisabled<T> — matches entities where T is present but DISABLED
SystemAPI.Query<RefRW<Health>>().WithDisabled<IsAlive>();
// WithAny<T> — matches if T present (enabled OR disabled)
// WithNone<T> — excludes entities that have T at all (regardless of enabled state)

Key rule: WithAll does NOT match disabled components — a common source of missed entities.

// Read-only check
void Execute(EnabledRefRO<IsAlive> alive) { if (!alive.ValueRO) return; }
// Read-write toggle (no structural change, safe in parallel job)
void Execute(EnabledRefRW<IsAttacking> attacking) { attacking.ValueRW = false; }
// Batch via query — more efficient than per-entity loop
var query = SystemAPI.QueryBuilder().WithAll<IsStunned>().Build();
state.EntityManager.SetComponentEnabled<IsStunned>(query, false);
// ECB supports SetComponentEnabled — deferred, plays back in order
ecb.SetComponentEnabled<IsMoving>(entity, false);

Gotcha: ECB SetComponentEnabled only works if the entity already has the component in its archetype. If the component was never added, this silently does nothing — add the component first.

  • Enable/disable = bit flip in chunk metadata — O(1), no memory allocation
  • Structural change (add/remove) = archetype migration = chunk allocation potential
  • Querying disabled components (WithDisabled) still iterates chunks — use sparingly in hot paths

Gotcha — GetSingletonEntity() rejects enableable types

Section titled “Gotcha — GetSingletonEntity() rejects enableable types”

SystemAPI.GetSingletonEntity<T>(), SystemAPI.GetSingleton<T>(), and SystemAPI.HasSingleton<T>() ALL throw under Burst when T : IEnableableComponent:

InvalidOperationException: Can't call GetSingletonEntity() on queries containing enableable component types.

The default singleton query rejects enableable types because the singleton’s enabled state is ambiguous — “is the singleton enabled or disabled?” is a per-entity question, not a query-cardinality question. The fix is an explicit query built with EntityQueryOptions.IgnoreComponentEnabledState:

public partial struct MySystem : ISystem
{
private EntityQuery signalQuery;
public void OnCreate(ref SystemState state)
{
this.signalQuery = new EntityQueryBuilder(Allocator.Temp)
.WithAll<MyEnableableSingleton>()
.WithOptions(EntityQueryOptions.IgnoreComponentEnabledState)
.Build(ref state);
state.RequireForUpdate<MyEnableableSingleton>();
}
public void OnUpdate(ref SystemState state)
{
var entity = this.signalQuery.GetSingletonEntity(); // works — query ignores enabled state
if (!state.EntityManager.IsComponentEnabled<MyEnableableSingleton>(entity))
return; // explicit per-entity enabled check
// ... do work ...
}
}

Key distinction:

  • EntityQueryOptions.IgnoreComponentEnabledState — query treats enableable as “match regardless of enabled bit” (what you want here)
  • EntityQueryOptions.IncludeDisabledEntities — query also matches entities with the whole-entity Disabled tag (different concept; do NOT use for enableable singleton lookup)

Same pattern applies to SystemBase:

this.signalQuery = new EntityQueryBuilder(Allocator.Temp)
.WithAll<MyEnableableSingleton>()
.WithOptions(EntityQueryOptions.IgnoreComponentEnabledState)
.Build(this);

Symptom: under Burst the __codegen__OnUpdate stack trace pinpoints the singleton call site. Caught multiple times in production systems that converted a plain tag/data component into IEnableableComponent without updating their singleton lookups.

Query filter trap — WithAll<T> and RefRW<T> exclude entities where T is DISABLED

Section titled “Query filter trap — WithAll<T> and RefRW<T> exclude entities where T is DISABLED”

When you query an IEnableableComponent T using SystemAPI.Query<RefRW<T>>() OR .WithAll<T>(), the query matches entities where T is present AND enabled only. Disabled-T entities are excluded by default.

Failure shape: a system whose JOB is to TOGGLE T from disabled → enabled (state-transition systems like SkillTapTriggerSystem, BossAbilityActivationSystem) cannot iterate the entities it needs to act on — because they’re disabled, the query filters them out.

Fix — .WithPresent<T>() matches enabled OR disabled:

// WRONG — filters out idle (disabled) casters; system can never start a cast
foreach (var (request, state) in
SystemAPI.Query<EnabledRefRW<SkillTapRequest>, RefRW<ActiveCastState>>())
{ /* ... */ }
// RIGHT — .WithPresent<ActiveCastState>() includes disabled entities so the
// system can flip ActiveCastState from disabled → enabled.
foreach (var (request, state) in
SystemAPI.Query<EnabledRefRW<SkillTapRequest>, RefRW<ActiveCastState>>()
.WithPresent<ActiveCastState>())
{ /* ... */ }

Query-filter semantics:

  • .WithAll<T>() / RefRW<T> / RefRO<T> → present AND enabled (default filter)
  • .WithPresent<T>() → enabled or disabled (the union)
  • .WithDisabled<T>() → present AND disabled only

Codebase precedent: WagerCardSystem, BossPhaseShakeSystem use .WithPresent<T>() for the same reason.

Also applies to SystemAPI.Query foreach (not just IJobEntity): the default enabled-only filter hits the managed-system query path identically. A SystemAPI.Query<RefRO<X>, EnabledRefRW<TEvent>>() foreach that must SET TEvent from its initial disabled state iterates zero entities — and silently no-ops with no exception — unless you add .WithPresent<TEvent>().

// WRONG — TickTimerCompletedEvent starts disabled; query skips every timer,
// so the completion event is never set to true.
foreach (var (timer, completed) in
SystemAPI.Query<RefRO<TickTimer>, EnabledRefRW<TickTimerCompletedEvent>>())
{ /* never runs */ }
// RIGHT
foreach (var (timer, completed) in
SystemAPI.Query<RefRO<TickTimer>, EnabledRefRW<TickTimerCompletedEvent>>()
.WithPresent<TickTimerCompletedEvent>())
{ /* iterates every timer regardless of event-enabled state */ }

Recurred 3+ times (HIGH-VALUE pattern): TickTimerEvaluationSystem (2026-05-29, fix in unity-dots-library 4ed1a31), RunResetSystem (9d50ce2), SkillTapTriggerSystem + SkillAutoFireFallbackSystem (2026-05-28 W7, a02cd35) all shipped the same disabled→enabled transition bug. Any system whose JOB is to ENABLE an IEnableableComponent from its disabled default needs .WithPresent<T>() — the absence is a silent zero-iteration no-op, never a compile or runtime error.

Cross-ReferenceContent
→ See dots-ecsGeneral ECS queries, IComponentData, SystemAPI
→ See dots-entity-command-bufferECB patterns including SetComponentEnabled
→ See dots-rpgCore module — IsAlive, IsMoving, IsAttacking usage