Views
Experimental Feature - Views are available in v2.2.0 (unreleased). The API may change in future versions. Use in production at your own discretion.
Purpose
A ReactiveView<TData> is a filtered subset of an entity set that updates itself automatically whenever entity data or traits change.
What are views?
A view holds the IDs of entities that satisfy a predicate. When an entity’s data or traits change, the view re-evaluates the predicate and adds or removes the entity from its membership. You subscribe to OnEnter and OnExit to react to those changes.
Without views, filtering looks like this:
// Pull model: you scan the full set every frame
entitySet.ForEach((id, state) =>
{
if (state.HealthPercent < 0.3f)
HighlightEnemy(id);
});
With a view, the set tells you when membership changes:
// Push model: the view notifies you
lowHealthView.OnEnter += id => HighlightEnemy(id);
lowHealthView.OnExit += id => ClearHighlight(id);
The view is evaluated once per entity change rather than once per frame per entity.
Creating a data-only view
Pass a Func<TData, bool> predicate to CreateView. The default trigger is ViewTrigger.DataOnly, which re-evaluates the predicate whenever an entity’s data changes.
public class EnemyAlertSystem : MonoBehaviour
{
[SerializeField] private EnemyEntitySetSO entitySet;
private ReactiveView<EnemyState> lowHealthView;
private void OnEnable()
{
// Include enemies with less than 30% health
lowHealthView = entitySet.CreateView(
state => state.HealthPercent < 0.3f,
ViewTrigger.DataOnly
);
lowHealthView.OnEnter += OnEnemyLowHealth;
lowHealthView.OnExit += OnEnemyHealthRecovered;
}
private void OnDisable()
{
lowHealthView?.Dispose();
lowHealthView = null;
}
private void OnEnemyLowHealth(int enemyId)
{
Debug.Log($"Enemy {enemyId} is low health");
}
private void OnEnemyHealthRecovered(int enemyId)
{
Debug.Log($"Enemy {enemyId} recovered");
}
}
CreateView evaluates the predicate against every entity currently in the set, so the view is populated immediately on creation.
Creating a trait-aware view
Pass a Func<TData, ulong, bool> predicate to filter by both data and traits. The second argument is the entity’s raw trait bitmask. Use TraitMaskUtility.ToUInt64<TTraits> to convert your enum to the ulong you compare against.
public class AggroLowHealthSystem : MonoBehaviour
{
[SerializeField] private EnemyEntitySetSO entitySet;
private ReactiveView<EnemyState> dangerousEnemyView;
private void OnEnable()
{
ulong aggroMask = TraitMaskUtility.ToUInt64(EnemyTraits.IsAggro);
// Match enemies that are aggro AND below 50% health
dangerousEnemyView = entitySet.CreateView(
predicate: (state, traitMask) =>
(traitMask & aggroMask) != 0 && state.HealthPercent < 0.5f,
triggerOn: ViewTrigger.All,
observedTraitMask: aggroMask
);
dangerousEnemyView.OnEnter += id => Debug.Log($"Enemy {id} is dangerous");
dangerousEnemyView.OnExit += id => Debug.Log($"Enemy {id} is no longer dangerous");
}
private void OnDisable()
{
dangerousEnemyView?.Dispose();
dangerousEnemyView = null;
}
}
The observedTraitMask parameter tells the view which trait bits to watch. When only unrelated traits change, the predicate is not re-evaluated. This avoids unnecessary work when a set has many trait types or high-frequency trait changes.
ViewTrigger modes
| Mode | Value | When the predicate is re-evaluated |
|---|---|---|
None | 0 | Never after creation — membership is static |
DataOnly | 1 | When an entity’s data changes |
TraitsOnly | 2 | When an entity’s traits change |
All | 3 | When either data or traits change |
None is the right choice for views that never need to update after initialization — for example, a view that groups entities by a fixed property set at registration. Most gameplay filters (health thresholds, position ranges) work well with DataOnly. If the predicate depends entirely on trait flags, pick TraitsOnly. When both data and traits contribute to membership, All covers both triggers.
Membership events
OnEnter fires when an entity passes the predicate. OnExit fires when an entity fails it. Both pass the entity ID.
public class LowHealthUI : MonoBehaviour
{
[SerializeField] private EnemyEntitySetSO entitySet;
[SerializeField] private GameObject warningIconPrefab;
private ReactiveView<EnemyState> lowHealthView;
private Dictionary<int, GameObject> warningIcons = new();
private void OnEnable()
{
lowHealthView = entitySet.CreateView(state => state.HealthPercent < 0.25f);
lowHealthView.OnEnter += SpawnWarning;
lowHealthView.OnExit += RemoveWarning;
}
private void OnDisable()
{
lowHealthView?.Dispose();
lowHealthView = null;
}
private void SpawnWarning(int enemyId)
{
var icon = Instantiate(warningIconPrefab);
warningIcons[enemyId] = icon;
}
private void RemoveWarning(int enemyId)
{
if (warningIcons.TryGetValue(enemyId, out var icon))
{
Destroy(icon);
warningIcons.Remove(enemyId);
}
}
}
OnEnter also fires when an entity registers directly into the view’s predicate range, and OnExit fires when an entity unregisters while in the view.
Iterating view members
Use foreach to iterate all member IDs, or Contains for a one-off membership check.
public class ThreatHUD : MonoBehaviour
{
[SerializeField] private EnemyEntitySetSO entitySet;
private ReactiveView<EnemyState> aggroView;
private void OnEnable()
{
ulong aggroMask = TraitMaskUtility.ToUInt64(EnemyTraits.IsAggro);
aggroView = entitySet.CreateView(
(state, mask) => (mask & aggroMask) != 0,
ViewTrigger.TraitsOnly,
aggroMask
);
}
private void OnDisable()
{
aggroView?.Dispose();
aggroView = null;
}
public void DrawThreatList()
{
foreach (int id in aggroView)
{
DrawThreatMarker(id);
}
}
public bool IsTargetThreatening(int enemyId)
{
// O(1) check — no iteration needed
return aggroView.Contains(enemyId);
}
}
Count gives the current number of members without iterating.
int threats = aggroView.Count;
Lifecycle
Call Dispose() when the view is no longer needed. The standard place is OnDisable, which mirrors where you call Dispose on NativeContainers.
private void OnDisable()
{
lowHealthView?.Dispose();
lowHealthView = null;
}
Dispose removes the view from the parent set’s internal list. If you skip it, the set holds a reference to the view indefinitely and continues evaluating its predicate on every entity change. In the Editor, this produces a memory leak warning when the set asset is destroyed at domain reload.
Never call methods on a view after
Dispose.Containswill return false,Countwill return 0, and iterating will throw an exception because the underlyingNativeHashSethas been freed.
Bulk operation behavior
Clear() and RestoreSnapshot() remove or replace all entities at once. For these operations, the view rebuilds its membership silently without raising OnEnter or OnExit. This is intentional — firing per-entity events for a hundred or more entities during a bulk reset is rarely useful and adds measurable overhead.
OnEnterandOnExitare NOT raised duringClear()orRestoreSnapshot(). After either call, membership in all views reflects the new state, but no events fire. If you need to react to the bulk change, subscribe toOnSetChangedon the entity set instead.
// This fires OnSetChanged once, not OnExit for each member
entitySet.Clear();
// This also fires OnSetChanged once after rebuilding
entitySet.RestoreSnapshot(snapshot);
API summary
ReactiveView<TData> members
| Member | Description |
|---|---|
int Count | Number of entities currently in the view |
bool Contains(int id) | Returns true if the entity is a current member; O(1) |
GetEnumerator() | Iterates member IDs; compatible with foreach |
event Action<int> OnEnter | Fired when an entity enters the view |
event Action<int> OnExit | Fired when an entity exits the view |
void Dispose() | Releases native memory and unregisters from the parent set |
CreateView overloads
| Signature | Default trigger | Use for |
|---|---|---|
CreateView(Func<TData, bool> predicate, ViewTrigger triggerOn) | DataOnly | Predicates that depend only on entity data |
CreateView(Func<TData, ulong, bool> predicate, ViewTrigger triggerOn, ulong observedTraitMask) | All | Predicates that depend on traits, or on both data and traits |
Performance
| Operation | Complexity |
|---|---|
CreateView | O(n) — evaluates predicate for all current entities |
Contains | O(1) |
GetEnumerator | O(n) iteration |
| Membership update per entity change | O(v) where v = number of active views on the set |
Each view allocates a NativeHashSet<int> backed by persistent native memory. Keep the number of views per set reasonable. Three to five views on a single set is typical; dozens may add measurable per-entity overhead.
Next steps
- Patterns - See how views combine with event channels and systems
- Job System - Use
NativeSlicedata alongside views for Burst-compatible processing - Observability - Monitor view membership counts at runtime