GameSession, ViewModels transform it into display-ready values, and Views subscribe to those values. When a value changes, the UI updates. When nothing changes, nothing runs.
There is no Update() polling. No “forgot to sync this field” bug class. And crucially, the same Presentation code works identically whether the match is Single Player, Pass & Play, or online Multiplayer — the UI has no idea which mode is active.
The R3 reactive pipeline described here is planned for the pre-production implementation phase. The architecture and patterns are established design intent.
Why Reactive UI?
The traditional Unity approach to live UI is polling:- CPU waste. It runs every frame even when the score hasn’t changed.
- Hidden coupling.
Update()reaches into_gameSessiondirectly, creating a tight dependency between the View and the Game Session’s public API. - Multi-mode fragility. When Multiplayer gets added, the session’s API changes and every polling call needs updating.
GameSession pushes state. Views subscribe once and are notified only when something actually changes:
The R3 Library
SET: 3D Edition uses R3 (by Cysharp), the successor to UniRx, purpose-built for Unity. It provides:The MVVM Pattern in SET: 3D Edition
The project uses a three-tier Model-View-ViewModel (MVVM) pattern adapted for Unity:Data Flow
Commands flow in the opposite direction:GameSession methods directly. Input becomes a command and is handled by the Application layer.
Key Interface: IInputHandler
Input in SET: 3D Edition is abstracted throughIInputHandler, declared in SET.Application and implemented by TouchInputHandler in SET.Presentation. This keeps the Application layer completely independent of Unity’s input system.
TouchInputHandler translates Unity touch events into SelectCardCommand, ClaimSelectedCommand, and other IGameCommand implementations, then pushes them onto CommandStream. GameSession subscribes once at startup — it never polls for input.
Key Interface: IViewPresenter
High-level screen transitions and game-board state updates are coordinated throughIViewPresenter, declared in SET.Application:
ScreenPresenter in SET.Presentation implements this interface. The Application layer calls it to trigger screen-level events without having any knowledge of Unity’s scene system or uGUI components.
Example ViewModel
Example View
Why DistinctUntilChanged Matters
GameSession emits a fresh GameStateSnapshot on every state change — card selection, score update, deck count change, board lock, anything. A ViewModel subscribed to the raw stream would re-run its update logic on every single emission, even if the specific field it cares about did not change.
DistinctUntilChanged short-circuits the subscription when the projected value has not changed:
DistinctUntilChanged for each logically independent field. This keeps each binding cheap and ensures UI components are only touched when their data actually changes.
R3 Use Cases Across the Game
The last row is the key takeaway for UI work: every numeric counter on screen maps to one
ReactiveProperty<int> on a ViewModel. If you are writing a loop or checking a value in Update(), you are doing it wrong.
The Mode-Agnostic Benefit
Single Player, Pass & Play, and Multiplayer all feed the same reactive pipeline:MatchViewModel and every View subscribe to IGameStateProvider. They never inspect whether the underlying session is networked or local. Adding a new game mode means providing a different IGameStateProvider implementation — the entire Presentation layer continues to work unchanged.
One-Shot Events: Animations and Toasts
Not everything is a continuously-updated value. Some things happen once — a valid SET is claimed, a card flies to the score counter. These use a separateEventStream on IGameStateProvider:
OfType<T>() filters the event stream to only the event type you care about. The subscription fires exactly once per event, not once per frame.
Threading Rule
Nakama delivers network callbacks on a background thread. Never update aReactiveProperty or call OnNext on a Subject from a background thread — Unity’s rendering and uGUI are not thread-safe.
Marshal all network callbacks to the main thread before touching any game or UI state:
UnityException: get_isActiveAndEnabled can only be called from the main thread error, a background callback is reaching UI without marshalling. Add .ObserveOnMainThread() at the boundary between the network callback and the game state update.
Disposal Rule
EverySubscribe() call returns an IDisposable. If you do not dispose it, the subscription keeps a reference to the subscriber alive and it never gets garbage collected, even after the scene unloads. This is a memory leak.
The pattern to follow, without exception:
AddTo(_disposables) is an R3 extension method that registers the subscription’s IDisposable with the CompositeDisposable. When _disposables.Dispose() is called, every registered subscription is cancelled in one call.
Implementation Checklist
Use this checklist when adding a new reactive binding or ViewModel:- ViewModel is a plain C# class with no Unity (
MonoBehaviour,GameObject) dependencies - Every
ReactiveProperty<T>is declared on the ViewModel, not on the View - Every
Subscribe()call is followed by.AddTo(_disposables) - Every
MonoBehaviourView hasOnDestroy() => _disposables.Dispose() - Every pure C# ViewModel implements
IDisposableand calls_disposables.Dispose()inDispose() - Subscriptions to ViewModel properties are set up in
Start(), never inAwake()(injection runs between the two) - Nakama/network callbacks use
.ObserveOnMainThread()before any state or UI update -
DistinctUntilChangedapplied on each subscription keyed to the specific field it cares about - No
Update()polling for any game state — all updates are subscription-driven - Views never call
GameSessiondirectly — input routes throughIInputHandler.CommandStream
Banned Patterns
Common Mistakes
ForgettingOnDestroy is the most frequent issue. The compiler will not warn you. A leaked subscription holds the entire ViewModel — and transitively the GameSession — alive after the scene unloads. Always verify that every MonoBehaviour with subscriptions has an OnDestroy that calls _disposables.Dispose().
Not using DistinctUntilChanged means the View’s Subscribe callback runs on every snapshot emission. For text fields updated 60 times per second this is not catastrophic, but for operations that trigger layout rebuilds or animations it causes visible frame drops. Be selective.
Subscribing in Awake before injection. VContainer calls [Inject] methods after Awake. If you call Subscribe in Awake, _vm is still null. Put all subscription setup in Start.
Using ReactiveProperty.Value = from multiple threads. ReactiveProperty is not thread-safe. All writes must happen on the main thread (see threading rule above).
Related Pages
Dependency Injection with VContainer
How ViewModels are registered and injected into Views through the composition root.
Assembly Definitions
How asmdefs keep the Presentation layer physically separated from Infrastructure.
Game Session Lifecycle
How GameSession emits state snapshots and events through the IGameStateProvider interface.
Engineering Standards & Patterns
The full pattern toolbox and code review checklist for reactive pipelines.