Build Log

Why I added ActionSO to Reactive SO (a Command pattern detour)

  • Unity
  • Reactive SO
  • ScriptableObject
  • Command Pattern
  • Game Development

Reactive SO’s Event Channels are great for “something happened, anyone interested?” and its Variables are great for shared state that several places need to read. Neither one is great for “run this one small thing, right now, from a button.”

I kept reaching for both anyway, and both kept feeling like the wrong tool, until I gave the gap its own type.

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 post is about ActionSO, the Command-pattern piece of Reactive SO — why the existing pieces didn’t cover it, the three shapes I tried before landing on a ScriptableObject, and a caller-info bug that shipped with the first version and is still waiting on a release to fix.

The gap Event Channels and Variables didn’t cover

Most of what I wire up in a small game is either “notify” or “hold state.” A door opens, an event fires, a few listeners react — Event Channel. A player’s health changes, everyone who cares reads the same asset — Variable. Reactive SO already covered both.

What it didn’t cover was “do this specific thing, with this specific configuration, and let a designer or an event table decide when.” A reward button that gives 50 gold and plays a sound. A quest step that unlocks a door. A dialogue choice that triggers three different outcomes depending on which node the player picked.

These aren’t broadcasts — nobody else needs to know they happened, except maybe the debugger — and they aren’t shared state either. They’re closer to a single verb: execute this, with this configuration, right now.

An Event Channel could be stretched to cover it (raise OnGiveReward, have exactly one listener react), but that’s using a broadcast primitive to simulate a direct call, and the “exactly one listener” part is an invariant nothing enforces. A Variable doesn’t fit at all — there’s no state to hold, just behavior to run.

What I tried first

The gap showed up first on a small jam game, where half the buttons in the UI needed to do one small thing each — give a reward, trigger a cutscene beat, unlock the next room. Before I got to a ScriptableObject-based command, I went through three shapes that all had the same failure mode: they solved the immediate wiring problem and made the next one worse.

UnityEvent on the button. The obvious first move — drop a UnityEvent on the button component, wire it in the Inspector, done.

It got tangled with prefab references almost immediately: the moment a button lived on a prefab that got reused or duplicated, UnityEvent’s persistent listeners — scene-and-object references baked into the serialized data — either pointed at the wrong instance or dragged a stale reference along with the prefab variant.

Fine for a genuinely one-off UI callback; a liability the moment the same button shape gets reused.

Inline lambdas in a MonoBehaviour. The next instinct was to skip the asset entirely and just write button.onClick.AddListener(() => { ... }) with the logic inline.

That sidesteps the prefab-reference problem, but it defeats the decoupling I was trying to keep — the behavior lives nowhere a data-driven system can reference it, and every new place that needs the same “give reward” logic either duplicates the lambda or has to go find and call a method on some manager. Exactly the tightly-coupled shape Reactive SO exists to avoid.

A one-off dispatcher class. In between, I wrote a small dispatcher — a plain C# class mapping string keys to Action delegates, registered somewhere at startup.

It solved the reuse problem, but it was ad-hoc and not reusable beyond that one jam: it isn’t an asset, it can’t be configured per-instance in the Inspector, and every new use case meant adding another case to the dispatcher instead of just creating a new asset.

None of these are bad techniques in isolation — UnityEvent is still right for a lot of UI wiring, and inline lambdas are still right for genuinely one-off callbacks.

They just aren’t the shape I wanted for “reusable, asset-configurable, Inspector-visible command,” which is a description that matches the Command pattern closely enough that I stopped trying to avoid it.

Where ActionSO landed

ActionSO wraps a single ScriptableObject around one command, in the same spirit as Event Channels and Variables — configured as an asset, referenced by anything that needs to trigger it, invisible to whatever it eventually does.

using Tang3cko.ReactiveSO;
using UnityEngine;

[CreateAssetMenu(menuName = "Game/Actions/Spawn Effect")]
public class SpawnEffectAction : ActionSO
{
    [SerializeField] private GameObject effectPrefab;
    [SerializeField] private float duration = 2f;

    public override void Execute(
        [System.Runtime.CompilerServices.CallerMemberName] string callerMember = "",
        [System.Runtime.CompilerServices.CallerFilePath] string callerFile = "",
        [System.Runtime.CompilerServices.CallerLineNumber] int callerLine = 0)
    {
        var instance = Instantiate(effectPrefab);
        Destroy(instance, duration);

#if UNITY_EDITOR
        NotifyActionExecuted(new CallerInfo(callerMember, callerFile, callerLine));
#endif
    }
}

That NotifyActionExecuted call is on the base class, but every derived Execute() override has to remember to make it — nothing calls it automatically. Forget it, and the action still runs; it just goes dark in the Monitor tab.

Calling it from a MonoBehaviour looks the same as any other Reactive SO asset reference:

public class RewardButton : MonoBehaviour
{
    [SerializeField] private ActionSO rewardAction;

    public void OnClick()
    {
        rewardAction?.Execute();
    }
}

Two things came out of that shape directly.

ActionSO and ActionSO<T> split on where the parameters live. A reward button with a fixed payout — 50 gold, always — is a plain ActionSO, and the payout is a serialized field on the asset. Configured once, in the Inspector, and every reference to that asset behaves the same way.

A damage action, on the other hand, needs the amount at the moment of the hit, which nothing about the asset itself can know — that’s ActionSO<int>, and Execute(int damage) takes the value from the caller. The rule I ended up using: if the value is fixed per-asset, it’s a field; if it varies per call, it’s the type parameter.

Description exists for the same reason showInMonitor/showInConsole do on Event Channels. It’s Inspector-visible documentation for an asset that otherwise has no code next to it in the Project window.

A folder full of GiveGold.asset, SpawnExplosion.asset, PlayFanfare.asset is hard to navigate from names alone; the description field is what shows up when someone selects one to remember what it actually does.

The CallerMemberName / CallerFilePath / CallerLineNumber parameters on Execute() follow the same caller-info pattern .NET provides for exactly this — the compiler fills them in at the call site, so rewardAction?.Execute() costs nothing extra to write and still reports “which method called this” to whatever’s watching.

What it unlocked

Once actions were assets instead of code paths, the existing Reactive SO tooling picked them up almost for free, the same way it already covered Event Channels and Variables:

  • Manual Execute button. The custom ActionSO Inspector adds an “Execute” section during Play Mode — a plain button for a non-generic ActionSO. Triggering a reward or a spawn effect by hand, without wiring up a temporary test button in the scene, turned out to be the feature I use most when iterating on a new action. ActionSO<T> doesn’t get the same button — the Inspector just tells you to call Execute(value) from code instead, which means testing a typed action by hand still means writing a small throwaway caller.
  • Asset Browser and Dependency Analyzer coverage. Actions show up in the Asset Browser alongside Event Channels and Variables, and the Dependency Analyzer scans for unused or unreferenced action assets the same way it already did for the other types.
  • Monitor Window integration. Action executions get their own tab in the unified Monitor window, logged the same way an Event Channel raise is — caller info included.

That last one is what actually closed the loop for me: a reward button that silently does nothing because the asset reference is unassigned looks the same on screen as one that’s working, but the Monitor tab tells them apart immediately.

Quest and event-table systems that reference ActionSO[] fields and loop over Execute() are a natural fit for this — an array of actions on a quest asset instead of a hardcoded reward method — but that part is a usage pattern, not something Reactive SO ships as a system of its own.

A caller-info bug that’s still open

The Execute() signature above — CallerMemberName/CallerFilePath/CallerLineNumber as always-present optional parameters — is what actually shipped starting in v2.1.0, and it has a problem I didn’t catch until later: those parameters aren’t guarded behind #if UNITY_EDITOR.

CallerFilePath substitutes the full absolute source path of the call site at compile time, which means every Execute() call site bakes in a full local filesystem path down to the developer’s home directory — into player builds, not just the Editor.

Harmless for anyone running the Editor locally, mildly unpleasant as a thing to notice inside a shipped .exe.

Event Channels turned out to have the identical bug in RaiseEvent(), and by the time I got to ActionSO I already had a fix worked out for that one — so I know roughly what the fix looks like here too.

Move the caller parameters behind #if UNITY_EDITOR, which for ActionSO means Execute() can no longer stay abstract and overridden directly, because every derived class would need its own #if UNITY_EDITOR block around its override signature to match.

The design I’m currently working through instead makes Execute() a non-virtual entry point that always exists, with derived classes implementing a plain OnExecute() — no caller parameters to guard, because they never reach the derived class at all, and Monitor notification moves into that same non-virtual entry point instead of being left to each override to remember.

None of this has shipped yet — both the RaiseEvent fix and this one are sitting on the same unreleased line of work, not in anyone’s installed package.

Things still bugging me

  • The single-argument limit on ActionSO<T>. Anything that needs two or three related values at execution time ends up either packed into a tuple or a small struct, or split into two actions that fire back to back. Neither feels like the obviously correct answer yet.
  • Where the line sits against Event Channel + Listener. Both are “something triggers, something responds” from a caller’s point of view; the difference I keep coming back to is direction — an Event Channel doesn’t know or care who’s listening, an ActionSO reference is a caller deliberately choosing what to run. That distinction is clear in my head and less clear the first time someone else reads the code.
  • Whether Description should be localizable. Right now it’s a plain string field, fine for my own projects, probably not fine the moment an action’s description needs to show up in a build a non-English-reading teammate is debugging.

Wrap-up

The three things I tried before ActionSO weren’t wrong choices in general — UnityEvent is still what I reach for on a genuinely one-off button, and an inline lambda is still fine for something that will only ever be called from one place.

They just don’t hold up once “reusable, asset-configurable, Inspector-visible” is the actual requirement, and pretending they do is how a jam-sized shortcut turns into the thing slowing down the next feature.

The caller-info bug is a reminder that copying a working pattern (Event Channels’ caller-info parameters) doesn’t guarantee copying it correctly — I’ll write about the fix once it’s actually shipped, not before.

References