Build Log

The MonoBehaviour base class that ate my ReactiveEntitySet boilerplate

  • Unity
  • Reactive SO
  • MonoBehaviour
  • ScriptableObject
  • Game Development

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.

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.

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 Set reference is per-component. Every prefab carrying a ReactiveEntity subclass needs its set asset assigned in the Inspector, and a missing assignment fails at runtime. Some shared resolver — a project-scope registry mapping TData to its set — would remove the per-prefab wiring, at the cost of the explicitness I usually defend.
  • InitialState as a property, as above.
  • The teardown ordering contract is prose, not types. A sealed OnDisable calling protected template methods would enforce it, but then subclasses lose the ability to extend OnDisable at 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.

References