Build Log

Adding an R3 bridge to Reactive SO without making R3 a dependency

  • Unity
  • Reactive SO
  • R3
  • Rx
  • Game Development

Event channels with += cover most gameplay wiring. Then one day my requirement read like “react to the third spawn only” or “fire when health drops below 30%, once, until it recovers” — and hand-rolling that with counters and flags in a subscriber is exactly the bookkeeping an operator library already solved. For modern Unity that library is R3, Cysharp’s successor to UniRx.

This post covers the bridge that connects Reactive SO to R3, and the part I consider the actual design decision: making sure users who don’t install R3 never pay for it.

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 bridge is two small files

The entire integration is two static extension classes. The first turns event channels into observables:

public static Observable<T> AsObservable<T>(this EventChannelSO<T> channel)
{
    return Observable.FromEvent<T>(
        h => channel.OnEventRaised += h,
        h => channel.OnEventRaised -= h
    );
}

A VoidEventChannelSO overload returns Observable<Unit>. The second class does the same for ReactiveEntitySet, exposing four streams: ObserveAdd, ObserveRemove, ObserveDataChanged (entity IDs), and ObserveSetChanged (a Unit per any change). All of them are the same Observable.FromEvent wrap — subscription attaches a handler, disposal detaches it, R3’s operator chain does the rest.

There’s deliberately no custom observable implementation, no subjects, no caching layer. The channels already are event sources; the bridge just changes their shape.

Variables didn’t need a bridge

My notes for this post originally listed a VariableSO extension to write. While verifying against the code, it turned out none exists — and none is needed. A variable’s change notification is already an event channel: VariableSO<T>.OnValueChanged returns an EventChannelSO<T>, so the existing extension composes:

playerHealth.OnValueChanged.AsObservable()
    .Where(health => health < 30)
    .Subscribe(_ => warningUI.Show());

That composition is covered by its own Edit Mode tests, so it’s a supported path rather than a happy accident. It’s also a small payoff of the library’s internal layering — because variables raise their changes through a channel, anything built for channels works on variables for free.

What the operators buy

Concrete cases from my own use, each one painful as a hand-rolled subscriber and one line as an operator chain:

// React to spawns after the first two (tutorial spawns are scripted)
enemySet.ObserveAdd().Skip(2).Subscribe(OnRealSpawn);

// One stream for "the roster changed at all"
Observable.Merge(
    enemySet.ObserveAdd().Select(_ => Unit.Default),
    enemySet.ObserveRemove().Select(_ => Unit.Default)
).Subscribe(_ => RefreshRosterUI());

None of this requires the library to know about R3 semantics. The streams are raw; the policy lives at the subscription site, which is where it belongs.

The asmdef is the actual feature

The part of this integration I’d defend in a design review isn’t the extensions — it’s the assembly definition around them. The relevant parts:

{
    "name": "Tang3cko.ReactiveSO.R3",
    "references": ["Tang3cko.ReactiveSO", "R3.Unity"],
    "defineConstraints": ["R3_SUPPORT"],
    "versionDefines": [
        {
            "name": "com.cysharp.r3",
            "expression": "",
            "define": "R3_SUPPORT"
        }
    ]
}

The versionDefines block tells Unity to define R3_SUPPORT only when the com.cysharp.r3 package is present, and the defineConstraints block makes the whole assembly compile only under that define. The combination means:

  • With R3 installed, the bridge assembly compiles and the extensions appear
  • Without R3, the assembly doesn’t compile at all — no missing-reference errors, no stub code, nothing to strip
  • The core Tang3cko.ReactiveSO assembly references nothing from R3 in either case

The dependency points one way, and it’s severable. Users who have never heard of Rx get a library with zero trace of it; users who live in operator chains get the bridge by installing one package, with no settings to flip. The asmdef does the detection.

What’s deliberately not there

  • A sink in the other direction — Observable<T> writing into a VariableSO<T> — doesn’t exist yet. Reads compose today; writes still go through normal assignment at the subscription site.
  • No IAsyncEnumerable bridge, no UniTask integration. Both have come up; neither has survived contact with “would I use this in the next game, or am I decorating the library again?”

That second bullet is the filter every addition goes through now. The R3 bridge passed it because the operator cases above came from an actual game. The rest waits for the same justification.

Wrap-up

The extensions took an evening; the asmdef configuration is the part I’d actually recommend copying. Any optional integration — Rx, DOTween, networking layers — can use the same versionDefines trick to stay severable, and “the assembly doesn’t exist unless the dependency does” is a much stronger guarantee than any amount of #if discipline inside shared files.

References