Skip to main content
The HUD score counter, the deck count badge, the card selection indicator, the AI thinking pulse — every piece of live UI in SET: 3D Edition is driven by the same mechanism: a reactive stream of state snapshots flows out of 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:
This has three problems:
  1. CPU waste. It runs every frame even when the score hasn’t changed.
  2. Hidden coupling. Update() reaches into _gameSession directly, creating a tight dependency between the View and the Game Session’s public API.
  3. Multi-mode fragility. When Multiplayer gets added, the session’s API changes and every polling call needs updating.
The reactive approach inverts this. 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:
Views never call GameSession methods directly. Input becomes a command and is handled by the Application layer.

Key Interface: IInputHandler

Input in SET: 3D Edition is abstracted through IInputHandler, 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 through IViewPresenter, 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

The ViewModel has no Unity dependencies. It is a plain C# class you can construct and test in an EditMode test without ever entering Play Mode.

Example View

The View contains zero game logic. It wires component references to ViewModel properties and nothing else.

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:
Use a separate subscription with its own 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 separate EventStream 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 a ReactiveProperty 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:
If you see a 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

Every Subscribe() 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 MonoBehaviour View has OnDestroy() => _disposables.Dispose()
  • Every pure C# ViewModel implements IDisposable and calls _disposables.Dispose() in Dispose()
  • Subscriptions to ViewModel properties are set up in Start(), never in Awake() (injection runs between the two)
  • Nakama/network callbacks use .ObserveOnMainThread() before any state or UI update
  • DistinctUntilChanged applied 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 GameSession directly — input routes through IInputHandler.CommandStream

Banned Patterns


Common Mistakes

Forgetting OnDestroy 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).

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.