ReactiveEntitySetSO<TData> gave me ForEach and the sparse-set storage described in the ECS insights post, but for a long time there was no way to ask “give me the entities where HP is under 30% and they’re poisoned” as a standing question.
If I wanted a live subset, I wrote the filter inline in whatever ForEach loop needed it, every frame, over the whole set.
ReactiveView<TData> is what I built to fix that, and it shipped in v2.2.0 of Reactive SO as “RES Views.”
This post is about the predicate-plus-trigger shape it landed in, and the two safety fixes that came out of letting more than one view watch the same entity. One piece below — how reentry is handled in Release player builds — is still sitting in the next version’s Unreleased changelog section rather than shipped, and I’ve called that out where it comes up.
This is a companion to what I took from studying ECS frameworks and the trait bitmask post — Views are meant to sit on top of both.
The itch
Every ECS I looked at while researching the earlier post has some version of a standing query: DOTS has EntityQuery, Bevy has Changed<T> query filters, EnTT has Signal. RES had none of it.
What I wanted was to keep the predicate in plain C# — no query DSL, no source generator, just a Func — and have the set maintain the subset for me, pushing OnEnter/OnExit instead of me pulling a scan every frame.
How it works
ReactiveView<TData> is a Func<TData, ulong, bool> predicate — entity data plus the entity’s raw trait bitmask — evaluated against a ViewTrigger that decides when re-evaluation happens:
[Flags]
public enum ViewTrigger
{
None = 0,
DataOnly = 1 << 0,
TraitsOnly = 1 << 1,
All = DataOnly | TraitsOnly
}
None means the view only updates on an explicit NotifySetChanged() call — useful for a one-off snapshot grouping you don’t want reacting to every change. DataOnly and TraitsOnly narrow re-evaluation to one axis; All watches both.
The userguide frames this as pull versus push: without a view, checking “who’s below 30% HP” means scanning the whole set every frame; with a view, the set tells you when membership changes instead.
Membership itself is a NativeHashSet<int> of entity IDs, one per view — not the sparse-set / swap-and-pop array the parent ReactiveEntitySetSO<TData> uses for its own storage (that’s a design covered in the ECS insights post, and I initially had it confused with the view’s own storage before checking the source again — a view is closer to a hash-set index over the parent set than a second sparse set).
Predicate-plus-trigger isn’t the first shape I tried.
I first went at this with plain event subscription — subscribing to the set’s raw OnDataChanged/OnTraitChanged channels and filtering manually inside the callback. It worked, but it was verbose: every system that wanted a live subset ended up writing its own “is this entity still in?” check inline, and that check had to be kept in sync by hand wherever it was duplicated.
Next I tried caching a HashSet<int> per query and rebuilding it from a full scan on every set change — simpler to reason about, but it ate CPU, because a full-set rescan on every single write throws away the whole point of ForEach’s O(1) register/unregister; the per-frame scan cost this was supposed to avoid just moved to per-write instead.
Predicate-plus-trigger is what I landed on after both of those: the filter is declared once, ViewTrigger lets individual views opt out of axes they don’t care about, and the membership update stays local to the entity that actually changed.
(The earliest design notes for this feature went straight to a predicate-based model in writing — that’s where the shape ended up on paper, not evidence that the other two weren’t tried first in code.)
The observedTraitMask short-circuit
A view watching only Poisoned shouldn’t pay for a predicate evaluation every time an unrelated trait like Shielded flips. Each view built with a trait-aware predicate is given an observedTraitMask — the bits it actually cares about — and a changed-trait notification is checked against that mask, as a single AND, before the predicate (which can be arbitrary user code) ever runs:
var view = entitySet.CreateView(
predicate: (state, mask) => (mask & poisonedBit) != 0,
triggerOn: ViewTrigger.TraitsOnly,
observedTraitMask: poisonedBit
);
The check happens in two stages, not one. The parent set keeps a running aggregate of every active view’s observedTraitMask, ORed together, and checks the changed bits against that aggregate first — if nothing any view watches changed, the set skips the per-view walk entirely.
Only once that first gate passes does it visit each view and re-check the changed bits against that view’s own observedTraitMask before running its predicate.
A Shielded flip on a set where every view only watches Poisoned never gets past the first gate; a mixed set where some view watches Shielded gets past the first gate but the Poisoned-only views still skip their own predicate at the second.
flowchart LR
A["Trait changes"] -->|"any view's mask?"| B{"Set gate"}
B -->|"no"| E["Skip all views"]
B -->|"yes"| C{"View gate"}
C -->|"unchanged"| F["Skip predicate"]
C -->|"changed"| D["Run predicate"]
Two gates, not one: an aggregate mask across every view rejects most changes before any view is even visited; only entities that clear that first gate pay a per-view mask check. Not to scale — the diagram doesn’t represent relative cost, just the control flow.
With several views on one set, each watching a different slice of trait bits, most trait writes end up touching zero predicates instead of all of them.
OnEnter / OnExit safety
Two problems came up once I had multiple views reacting to the same entity change, and both are now covered by tests in the current test suite.
The first is re-entrancy: what happens if an OnEnter callback on one view turns around and calls SetData or AddTraits on the same set, from inside the set’s own notification dispatch? The behavior is fail-fast — a reentrant write throws rather than being allowed to re-enter the dispatch loop and risk a silently inconsistent view. A test in the suite exercises this directly:
view.OnEnter += id => entitySet.SetData(id, new TData { Value = 99 });
var exception = Assert.Throws<ReactiveEntitySetReentryException>(() =>
entitySet.SetData(1, new TData { Value = 15 }));
ReactiveEntitySetReentryException and this guard holding in Release player builds (not just the Editor and Development Builds) are both still sitting in the next version’s Unreleased changelog section as I write this — in the shipped v2.2.0 behavior, the equivalent reentrant write throws a plainer InvalidOperationException, and only in the Editor or a Development Build; that guard compiles out of a Release build entirely.
So the fail-fast behavior has been there since it shipped; the specific exception type and the Release-build coverage are the part I’m still calling unreleased.
The second is about disposing a view from inside another view’s callback. If OnEnter on view B disposes view A while the set is still walking its list of active views to notify them, a naive for loop over that list skips whatever slid into A’s old index after the removal.
The fix in the current code walks the list with an explicit index and a ReferenceEquals check against the view being visited, so a mid-iteration removal doesn’t cause the next view in line to be silently skipped. A three-view test (A disposes itself via B’s callback, C should still fire) is what pins this down:
viewB.OnEnter += _ => { enterB++; viewA.Dispose(); };
// ...
Assert.That(enterC, Is.EqualTo(1), "Remaining views should not be skipped when another view is disposed");
Both of these came out of thinking through what happens when views stop being independent — once two views can watch the same entity and one can mutate state the other observes, the notification loop itself needs to be safe against a view disappearing or a write re-entering mid-dispatch, not just against one view’s own bookkeeping.
What I haven’t settled
Iteration order over a view’s members isn’t something I’m making any promise about — NativeHashSet<int> doesn’t guarantee an order, and nothing about how the view fills in guarantees a stable one either. If a caller needs members in a specific order, that has to happen outside the view for now.
View composition — a view built from other views, or a chained .Where(...).Closest(position) style API I sketched early on in the design notes for this feature — isn’t in the current shape at all. Every ReactiveView<TData> today reads directly from the parent set; there’s no way to layer one view’s output as another view’s input.
Whether to expose a NativeArray<int> snapshot of a view’s members for Burst job consumption — in the same spirit as the double-buffered NativeArray access the orchestrator piece built for the parent set — is still an open question rather than a decided no.
A view’s membership lives in a NativeHashSet<int>, not a NativeArray, so handing it to a job would mean an explicit copy into a flat array first; I haven’t worked out whether that copy earns its cost against just filtering in the job directly.
One thing that did get further than the original three items above: a second view type, ReactiveView<TData, TContext>, now exists alongside the plain one, for predicates that need an external value the set itself doesn’t track — player position, current game phase, that kind of thing.
It doesn’t auto-update on that external value the way DataOnly/TraitsOnly update on entity state; the caller calls Refresh(newContext) explicitly, and that re-evaluates every entity currently in the set against the new context — an O(n) walk, so it’s meant for context that changes occasionally (room transitions, phase changes), not something like a per-frame player position.
Unlike everything above this one, it hasn’t reached a changelog entry at all yet — not the shipped v2.2.0, not the current Unreleased section either — so I’m treating it as its own separate, still-forming thing rather than folding it into what’s already shipped.
Wrap-up
The predicate-plus-trigger design and the observedTraitMask short-circuit both feel settled to me — they’re what shipped, and the tests pin the behavior down. Iteration order, view composition, and the Burst-facing snapshot question don’t feel settled, and I’d rather leave them open here than pretend otherwise.
Reentry handling is the one piece mid-flight right now: the fail-fast behavior itself has been in since v2.2.0, and what’s still unreleased is narrowing the exception type and making the guard hold in Release builds too — not a new mechanism, just the existing one closing a gap it always had outside the Editor.