Somewhere around the dozenth gameplay component that talked to a ReactiveEntitySet, I noticed I was writing the same five lines again: register in OnEnable, unregister in OnDisable, cache GetInstanceID(), subscribe to the entity’s own state changes, unsubscribe on the way out. Copy-pasted lifecycle code drifts — one component forgets the unsubscribe, another unregisters before reading its final state — so I lifted the ritual into a base class.
The shape
ReactiveEntity<TData> is a small abstract MonoBehaviour. A subclass answers two questions — which set, and what initial state — and inherits the rest:
public abstract class ReactiveEntity<TData> : MonoBehaviour
where TData : unmanaged
{
protected abstract ReactiveEntitySetSO<TData> Set { get; }
protected abstract TData InitialState { get; }
public int EntityId => GetInstanceID();
public event Action<TData, TData> OnStateChanged;
protected TData State
{
get => Set[this];
set => Set[this] = value;
}
protected bool IsRegistered => Set != null && Set.Contains(this);
protected virtual void OnBeforeUnregister() { }
}
A concrete enemy ends up looking like a gameplay class instead of lifecycle plumbing:
public sealed class Enemy : ReactiveEntity<EnemyData>
{
[SerializeField] private EnemyEntitySetSO enemySet;
protected override ReactiveEntitySetSO<EnemyData> Set => enemySet;
protected override EnemyData InitialState => new EnemyData { Health = 100 };
public void TakeDamage(int amount)
{
var state = State;
state.Health -= amount;
State = state; // observers notified
}
}
The State property hides the read-modify-write against the set, and EntityId is just GetInstanceID() — which, for runtime-instantiated objects, is a negative number. I verified the ID-based paths handle negatives before trusting it, because “IDs are positive” is exactly the kind of assumption that survives until the first spawned prefab.
Per-entity events, not a global channel with a filter
The piece that earns the base class its keep is OnStateChanged. Before this existed, the tempting pattern was to subscribe to the set’s global data-changed event and filter by entity ID in every handler:
// the antipattern
enemySet.OnDataChanged += (id, data) =>
{
if (id != myId) return; // every entity pays for every change
...
};
With a thousand entities, every state change runs a thousand filters that reject it. The base class instead wires each instance into the set’s per-entity subscription (SubscribeToEntity / UnsubscribeFromEntity) during its lifecycle, and re-raises changes as a plain C# event carrying both old and new state. Subscribers get exactly their own entity’s changes, with the before/after pair for “health went from 80 to 30, play the hurt animation” logic.
The lifecycle, and the order that matters
Registration is the obvious part: OnEnable registers with InitialState and subscribes. Two consequences of that design are less obvious.
First, disable-then-re-enable resets the entity to InitialState. That’s intentional — a pooled enemy that comes back from the dead should come back at full health, not at whatever its corpse held — but it means InitialState is consulted on every enable, not once. Which leads to my main regret: InitialState is a property, and a property that depends on serialized fields reads as if it were a constant when it isn’t. A CreateInitialState() method would have signaled “this runs every time” better. The property version shipped; renaming it now is a breaking change I keep deferring.
Second, teardown order. OnDisable does three things, in this order:
protected virtual void OnDisable()
{
if (Set != null && IsRegistered)
{
Set.UnsubscribeFromEntity(EntityId, HandleStateChanged);
OnBeforeUnregister();
Set.Unregister(this);
}
}
OnBeforeUnregister exists so a subclass can read its final state while it’s still in the set — log the death, drop the loot, hand the score to something else. The subtle decision is that the event unsubscription happens before the hook: anything OnBeforeUnregister does to state will not re-enter this entity’s own OnStateChanged. That prevents a class of teardown loops, but it’s the kind of ordering contract that lives in documentation rather than in the type system. It’s documented; it’s also the part of this class I’d call fragile, because nothing stops an override from rearranging the calls.
What I’d still change
- The
Setreference is per-component. Every prefab carrying aReactiveEntitysubclass needs its set asset assigned in the Inspector, and a missing assignment fails at runtime. Some shared resolver — a project-scope registry mappingTDatato its set — would remove the per-prefab wiring, at the cost of the explicitness I usually defend. InitialStateas a property, as above.- The teardown ordering contract is prose, not types. A sealed
OnDisablecalling protected template methods would enforce it, but then subclasses lose the ability to extendOnDisableat all. I picked flexibility; I’m still not sure.
Wrap-up
This class removed maybe fifteen lines per component across a dozen components — modest arithmetic. The actual win is that the teardown order is now decided once, in one file, instead of re-decided slightly differently in every component that needed a death hook.
The pattern travels, too: any “objects register themselves into a central collection” system grows this same base class eventually. Mine just took a dozen hand-rolled copies to admit it.