The StatusBoardDemo sample for Reactive SO needed citizens who could be Cursed, Poisoned, and Shielded at the same time, and a table view that could filter on any combination of those.
This post is about the trait system I built to answer that — a 64-bit bitmask stored next to each entity’s data, and the API around it.
Unity Asset StoreReactive SO | Game ToolkitsScriptableObject-based reactive architecture for Unity. Variables, Event Channels, Runtime Sets, GPU Sync, Reactive Entity Sets, and dedicated debugging windows.This is a companion to the earlier ReactiveEntitySet posts on ownership vs belonging and what I took from studying ECS. Traits shipped in v2.2.0.
What kicked this off
ReactiveEntitySetSO<TData> already had a TData struct per entity for the data that changes — health, position, whatever. Status flags are a different shape of problem. IsDead, IsStunned, IsAggro don’t change every frame, they’re boolean, and there can be several of them active on the same entity at once.
Two obvious options, and both were unsatisfying for the same reason:
- Add
boolfields toTDatadirectly. Works for two or three flags, then the struct turns into a pile of booleans and every consumer has to know which ones are mutually exclusive by convention rather than by type. - Give each entity a
HashSet<EnemyTraits>or similar managed collection. That solves the modeling problem but puts a managed allocation on a per-entity basis, which is exactly the kind of thingReactiveEntitySetSO<TData>exists to avoid —TDataitself is required to beunmanagedfor this reason.
What I actually wanted was closer to what an ECS calls a tag component: a label with no payload, cheap to add and remove, cheap to query in bulk (“give me every aggro-and-not-stunned enemy”).
Flecs — the ECS I’d been reading through while designing the earlier RES pieces — stores tags as zero-sized components with no per-entity storage at all, moving the entity between archetype tables instead. RES doesn’t have archetype tables; entities stay in the same sparse-set slot for their whole lifetime.
So the tag itself needed somewhere to live, and it needed to be a fixed, unmanaged size.
Why a 64-bit bitmask
A [Flags] enum was the natural shape for the labels themselves — C# already has bitwise | and & wired up for them, and Enum.HasFlag predates this by decades. The open question was storage: one ulong per entity, alongside the TData array, holding up to 64 flags as bits.
Eight bytes per entity, allocated lazily — a set that never calls a trait method never allocates the trait array at all.
AddTraits ORs a mask in, RemoveTraits ANDs a mask out with ~mask, both O(1) per call. Combined queries (“has all of X” vs “has any of X”) are a single & and a comparison, also O(1).
None of this needs a managed allocation once the backing native array exists, sized to the set’s capacity the same way the rest of RES’s per-entity arrays are.
64 is not a number I derived from anything — it’s what fits in the widest integer type C# has without going to Int128 or a manual multi-word mask, and it comfortably covers what a [Flags] enum on one entity type tends to need in practice (StatusBoardDemo’s CitizenTrait uses 3 of them).
If an entity type genuinely needs more than 64 independent boolean labels, that’s a modeling smell worth looking at before reaching for a wider mask.
Making it work with any [Flags] enum
The storage is a fixed ulong, but I didn’t want to force every trait enum in every project to declare : ulong as its backing type — most [Flags] enums people already have are int-backed, because that’s the default C# gives an enum with no explicit backing type. So the conversion between “your enum” and “the ulong that gets stored” had to handle any of C#‘s integral backing types.
The conversion inspects the enum’s own declared size — 1, 2, 4, or 8 bytes, matching byte, ushort, uint, or ulong — and reinterprets its bit pattern as the matching unsigned type via UnsafeUtility.As, which the Unity Scripting API documents as an unsafe cast: it reinterprets a reference as a different type without a managed allocation. All four backing widths round-trip cleanly through it.
Negative values on a long-backed enum round-trip too — the test suite covers a long-backed enum with HighBit = long.MinValue and All = -1L, converted with unchecked((ulong)(long)traits) and back, which is the standard way to reinterpret a signed value’s bit pattern as unsigned without an overflow check getting in the way.
The bug this design has to guard against
Storing the mask as a ulong doesn’t automatically make a 33rd flag on an int-backed enum safe.
C#‘s shift operators compute their shift count by masking the right-hand operand against the left operand’s bit width — documented directly on the shift-operators reference page — so 1 << 32 on an int neither overflows nor throws.
It silently lands on a different bit than the one you asked for.
That means the 33rd member of a [Flags] enum with no explicit backing type — which defaults to int — collides bit-for-bit with an earlier member, with no compile error and no runtime exception.
TraitMaskUtility.ToUInt64 reads whatever bit pattern the enum actually has, so if the enum aliased two members onto the same bit, that’s the mask it stores.
The exact arithmetic behind the collision, and why a ulong-backed enum doesn’t have the same problem:
The fix is on the enum declaration, not in RES: any [Flags] enum that might grow past 32 members needs an explicit : ulong (or at least a backing type wide enough for its member count).
This isn’t a bug I found by hitting it in a real project — it’s a consequence of how C# shift operators are specified, and the userguide carries it as a documented warning rather than something the library catches at compile time.
The type lock
Each ReactiveEntitySetSO<TData> can hold traits from exactly one enum type, locked in on the first trait call. AddTraits<EnemyTraits>(...) on a set locks it to EnemyTraits; a later AddTraits<BossTraits>(...) on the same set throws InvalidOperationException.
I went back and forth on this. The mask itself doesn’t care what enum produced it — ulong is ulong — so technically the same set could hold different trait vocabularies on different entities, or the same entity could carry bits from two unrelated enums layered into one mask. I didn’t build that.
What decided it was debuggability: if a set’s trait mask can mean different things depending on which enum you decode it with, then GetTraits<TTraits> has no way to know what TTraits should be, the Monitor window has nothing consistent to print, and a snapshot taken with one enum in scope is ambiguous when restored with another.
One set, one trait enum, decoded the same way everywhere that reads it, was the simpler thing to reason about — matching the same “observability over raw throughput” bias the rest of RES’s design leans on.
If an entity type has genuinely unrelated classification needs — weapon traits and buff traits, say — that’s two entity sets, not one mask split across two vocabularies.
The one escape hatch: calling a trait method with None (mask 0) does not lock the type. AddTraits(id, EnemyTraits.None) is a no-op before any trait storage exists, and the type lock only takes effect once storage actually gets touched — so passing None first, then later locking to a completely different enum, is fine.
That’s a real code path, not a documentation nicety: an AddTraits call with a zero mask returns before storage is ever allocated or the type gets checked, which the test suite exercises directly with two different trait enums on the same set.
The API surface
Four mutation methods, matching the semantics you’d expect from bitwise operators:
citizenSet.AddTraits(id, CitizenTrait.Poisoned); // OR — existing flags preserved
citizenSet.RemoveTraits(id, CitizenTrait.Poisoned); // AND-NOT — only this flag clears
citizenSet.SetTraits(id, CitizenTrait.Cursed); // replace the whole mask
citizenSet.ClearTraits(id); // mask := 0
Two query methods with different membership semantics — HasTraits requires every specified bit to be set (ALL), HasAnyTrait requires at least one (ANY):
bool disabled = citizenSet.HasAnyTrait(id, CitizenTrait.Poisoned | CitizenTrait.Cursed);
bool eliteGuard = citizenSet.HasTraits(id, CitizenTrait.Shielded); // combined with a data predicate elsewhere
GetTraits<TTraits> throws if the entity isn’t registered; TryGetTraits<TTraits> returns false instead. Both decode the stored ulong back into TTraits through TraitMaskUtility.FromUInt64.
Iteration and counting scale with the set, same as any full-set walk in RES:
citizenSet.WithTraits(CitizenTrait.Cursed | CitizenTrait.Poisoned, (id, state) => { /* both required */ });
citizenSet.WithAnyTraits(CitizenTrait.Poisoned | CitizenTrait.Shielded, (id, state) => { /* either */ });
int bossCount = citizenSet.CountWithTraits(CitizenTrait.Shielded); // no allocation, just a walk-and-count
WithTraits/WithAnyTraits are O(n) over the registered entities — there’s no secondary index keyed by trait bits, just a straight pass with a mask check. CountWith* is the same walk without the callback allocation concern, so it’s the cheaper choice when a count is all that’s needed.
Two events, OnTraitAdded and OnTraitRemoved, both IntEventChannelSO carrying the entity ID. AddTraits and SetTraits can raise OnTraitAdded; RemoveTraits, SetTraits, and ClearTraits can raise OnTraitRemoved.
SetTraits can raise both in the same call if it both adds and removes bits relative to the previous mask, and raises neither if the new mask equals the old one — the set diffs old-mask against new-mask before deciding what to fire, it doesn’t fire on every call unconditionally.
Views can also observe trait changes directly through a ulong predicate and a traitObserverMask that short-circuits predicate evaluation when a changed bit isn’t one any view cares about — that’s covered in the reactive view post.
Snapshots carry the raw mask array. EntitySetSnapshot<TData> includes a TraitMasks array alongside the entity data, and restoring a snapshot restores the masks verbatim.
Things still bugging me
64 traits per entity type still feels like a number I backed into rather than derived — it’s “whatever fits in the widest primitive”, not a load-bearing design constant. I haven’t hit a real case that needed 65, and if I do, the honest answer today is a second entity set, not a wider mask.
The per-set type lock means an entity type with two genuinely unrelated trait vocabularies — weapon traits and buff traits, say — has to live in two entity sets instead of one. That’s the trade-off I described above, and I still think it’s the right one, but it does mean the set boundary sometimes has to follow “what traits does this need” rather than “what is this entity” alone.
Snapshots store the raw ulong. If a trait enum gets renumbered — someone reorders the members, or a value that used to be 1 << 2 moves to 1 << 4 — an old snapshot restores the wrong bits into the new enum’s meaning, silently.
Nothing currently validates that a restored mask still matches the shape of the enum that produced it. [Flags] enums are already somewhat brittle to member reordering for this reason, and the trait system inherits that brittleness rather than adding a guard against it.
I’m also currently sketching whether trait operations belong in the Monitor’s event log with human-readable names instead of a raw hex mask — decoding a ulong back to Cursed, Poisoned in the log the way AddTraits/RemoveTraits already do in the Editor is a natural next thing to want when watching a set live, but it’s not something I’ve committed to a shape for yet.
Wrap-up
What surprised me after the fact was how little of the surrounding API actually needed to know any of this. HasTraits, WithAnyTraits, CountWithTraits — every one of them reads the same ulong through the same conversion, so getting the enum-to-mask round trip right once did more work than the size of the API surface suggests.
The part I’m least settled on is that the shift-count guard lives in a userguide paragraph, not in the type system: nothing stops someone from declaring a 40-member [Flags] enum with no backing type and finding out about the collision only when two traits start behaving like one.
A source generator or an analyzer that flags an under-sized backing type at compile time would close that gap properly; a runtime check that walks the enum’s members on first use would catch it later but before real damage. Neither exists yet.
References
- Reactive SO — Asset Store
- Companion: Ownership vs Belonging as a New Paradigm for State Management in Unity
- Companion: What studying an ECS taught me about ReactiveEntitySet
- Companion: ReactiveView, querying ECS-style subsets without iterating every frame
- C# — Bitwise and shift operators, shift count masking
- C# — Enumeration types, default underlying type and bit flags
- System.FlagsAttribute
- Unity — UnsafeUtility.As