QfStudio.Godette.ReactiveUI
1.2.0
dotnet add package QfStudio.Godette.ReactiveUI --version 1.2.0
NuGet\Install-Package QfStudio.Godette.ReactiveUI -Version 1.2.0
<PackageReference Include="QfStudio.Godette.ReactiveUI" Version="1.2.0" />
<PackageVersion Include="QfStudio.Godette.ReactiveUI" Version="1.2.0" />
<PackageReference Include="QfStudio.Godette.ReactiveUI" />
paket add QfStudio.Godette.ReactiveUI --version 1.2.0
#r "nuget: QfStudio.Godette.ReactiveUI, 1.2.0"
#:package QfStudio.Godette.ReactiveUI@1.2.0
#addin nuget:?package=QfStudio.Godette.ReactiveUI&version=1.2.0
#tool nuget:?package=QfStudio.Godette.ReactiveUI&version=1.2.0
QfStudio.Godette.ReactiveUI
ReactiveUI integration for Godot Engine
Documentation: qfstudio.github.io/QfStudio.Godette.ReactiveUI
ReactiveUI is a composable, cross-platform MVVM (Model-View-ViewModel) framework for .NET. It uses reactive extensions to bind UI elements to ViewModel properties and commands, keeping views and business logic cleanly separated.
QfStudio.Godette.ReactiveUI provides the platform services that make ReactiveUI work with Godot Engine - scheduling, activation, property-change notification, and command binding. If you have used ReactiveUI with Avalonia or WPF, this is the same this.Bind / this.BindCommand / WhenActivated story, now wired to Godot nodes and signals. See Developer.md for implementation details.
Features
- Data binding & command binding --
this.Bind/this.OneWayBind/this.BindCommandto Godot nodes, backed by signal-driven and per-frame-polling property binders;CanExecuteautomatically disables the target control. - Reactive view properties -- An
[Export]Godot scene variable can be reactive. - Activation lifecycle --
WhenActivateddriven by Godot scene-tree presence and node readiness; subscriptions cleaned up on deactivation. - Signal → Observable -- type-safe
ObserveXxx()extensions for common controls plus a generic typedObserveSignal<T...>bridge for custom signals. - Collection binding -- the
ItemsBinderfamily synchronizes anObservableCollection<T>to Godot containers. - View location & routing --
GodotViewLocatormaps ViewModels to.tscnscenes and integrates with ReactiveUIRoutingStatefor navigation. - Interactions & validation -- standard ReactiveUI
BindInteractionandReactiveUI.Validation'sBindValidationwork out of the box. - Frame-aware operators --
EveryUpdate,DelayFrame,IntervalFrame,DebounceFrame,ThrottleFirstFrame,ChunkFrame, andPollEveryUpdate. - Main-thread scheduling --
GodotMainThreadSchedulerplus process-frame and physics-frame schedulers registered with ReactiveUI. - Source generator --
[GodotViewFor<T>]implementsIViewFor<T>for.tscnroot scripts with no boilerplate.
In the current version of QfStudio.Godette.ReactiveUI, it is designed to work with ReactiveUI v23. ReactiveUI v24 released on July 26th this year (a few hours ago at the moment of writing) is not supported yet. This library is not optimized for zero-allocation; allocation reduction work is planned within roughly the next year together with the ReactiveUI v24 upgrade.
Prerequisites
- .NET 10
- Godot 4.1+
- ReactiveUI v23
Installation
dotnet add package QfStudio.Godette.ReactiveUI
Two optional but recommended packages improve the development experience:
GodotSharp.SourceGenerators provides the [SceneTree] attribute for type-safe scene loading and strongly-typed node access without GetNode calls.
dotnet add package GodotSharp.SourceGenerators
<details> <summary>With vs Without <code>[SceneTree]</code></summary>
Annotate a .tscn root script to get:
- A
TscnFilePathstatic property for type-safe scene loading. - Strongly-typed fields for nodes marked
unique_name_in_owner-- noGetNodecalls needed.
// With [SceneTree] -- nodes are directly accessible as properties
[SceneTree(root: "_root")]
public partial class MyScene : Control
{
public override void _Ready()
{
BackButton.Pressed += () => GetTree().ChangeSceneToFile(HomeScene.TscnFilePath);
NameEdit.Text = "hello";
}
}
// Without [SceneTree] -- use GetNode with string paths
public partial class MyScene : Control
{
public override void _Ready()
{
GetNode<Button>("BackButton").Pressed += () =>
GetTree().ChangeSceneToFile("res://Views/HomeScene.tscn");
GetNode<LineEdit>("NameEdit").Text = "hello";
}
}
</details>
ReactiveUI.SourceGenerators provides the [Reactive] attribute to auto-generate RaiseAndSetIfChanged boilerplate for partial properties.
dotnet add package ReactiveUI.SourceGenerators
<details> <summary>With vs Without <code>[Reactive]</code></summary>
// With [Reactive]
public partial class MyViewModel : ReactiveObject
{
[Reactive] public partial string Name { get; set; } = "";
}
// Without [Reactive] -- manual backing field + RaiseAndSetIfChanged
public class MyViewModel : ReactiveObject
{
private string _name = "";
public string Name
{
get => _name;
set => this.RaiseAndSetIfChanged(ref _name, value);
}
}
</details>
Autoload Setup
Create a bootstrapper class to initialize ReactiveUI services and add it as an Autoload in Godot:
using Godot;
using QfStudio.Godette.ReactiveUI;
using ReactiveUI.Builder;
public partial class RxAppBootstrapper : Godot.Node
{
private readonly GodotFrameScheduler _processFrameScheduler = new();
private readonly GodotFrameScheduler _physicsFrameScheduler = new();
public RxAppBootstrapper()
{
RxAppBuilder.CreateReactiveUIBuilder()
.WithGodot(_processFrameScheduler, _physicsFrameScheduler)
.WithGodotConverters()
.WithGodotViewLocator(locator =>
{
locator.RegisterViewsFromAssemblyViaReflection(typeof(RxAppBootstrapper).Assembly, verbose: false);
})
.BuildApp();
}
public override void _Process(double delta) => _processFrameScheduler.NotifyProcess(delta);
public override void _PhysicsProcess(double delta) => _physicsFrameScheduler.NotifyProcess(delta);
}
WithGodot(processFrameScheduler, physicsFrameScheduler) sets up the Godot platform: it creates the main-thread scheduler (from SynchronizationContext.Current), exposes all three schedulers through GodotSchedulers, registers the Godot platform services (GodotActivationFetcher, GodotPropertyBinder, GodotPollBasedPropertyBinder, GodotCommandBinder) via GodotRegistrations, and then calls WithCoreServices(). The float↔double binding converters (FloatToDoubleConverter, DoubleToFloatConverter) and the GodotViewLocator registration are both optional and opt-in: the converters via .WithGodotConverters() and the view locator via .WithGodotViewLocator(...) (see below) -- neither is part of WithGodot.
In Godot Editor, go to Project > Project Settings > Autoload and add this script as an Autoload with a name like RxAppBootstrapper.
RxAppBuilder.BuildApp() mirrors the scheduler registered via .WithMainThreadScheduler(...) into ReactiveUI's RxSchedulers.MainThreadScheduler, so ObserveOn(RxSchedulers.MainThreadScheduler) (used in the examples below) resolves to the same GodotMainThreadScheduler set up here. GodotSchedulers is the Godot-side alias for the same instances, used by frame operators and other Godot-specific APIs.
Without .WithGodotConverters() (which registers FloatToDoubleConverter/DoubleToFloatConverter), bindings between Godot controls that expose double properties (e.g. Range.Value, ColorPicker.Color) and ViewModel float properties will throw ConverterNotFoundException at bind time. The library also ships EnumToStringConverter<TEnum>, StringToEnumConverter<TEnum>, and Variant-to/from-primitive converters -- register whichever ones you need via .WithConverter(...) in the builder above.
Usage
Concepts
Activation semantics: A view is activated (true) when its Godot Node is inside the scene tree and IsNodeReady() returns true. The GodotActivationFetcher emits true from three paths:
- the
Readysignal (first entry, all children initialized); TreeEntered+IsNodeReady()(re-entry after the node was already ready);- an initial
IsInsideTree() && IsNodeReady()check at subscription time.
It emits false on TreeExited. This is semantically equivalent to Avalonia's AttachedToVisualTree / DetachedFromVisualTree.
Note: the C# virtual method _Ready runs before the Ready signal is emitted, so a ViewModel assigned in _Ready is already set by the time WhenActivated fires.
usings
All examples below assume these usings are in scope:
using QfStudio.Godette.ReactiveUI;
using ReactiveUI;
using System.Reactive.Disposables; // for DisposeWith(d) used throughout
[GodotViewFor<T>] and the generated ViewModel property are emitted by this library's bundled source generator into the QfStudio.Godette.ReactiveUI namespace -- no extra using or package reference is required.
Basic Setup
You'll need a ViewModel that implements IActivatableViewModel, and a View that uses the [GodotViewFor<T>] source-generator attribute to implement IViewFor<T>. In the constructor, call WhenActivated and set up your bindings inside it:
// ViewModel
public partial class MyViewModel : ReactiveObject, IActivatableViewModel
{
public ViewModelActivator Activator { get; } = new();
[Reactive] public partial string Name { get; set; } = "";
}
// View (.tscn root script)
[GodotViewFor<MyViewModel>]
public partial class MyScene : Control
{
public MyScene()
{
this.WhenActivated(d =>
{
this.Bind(ViewModel, vm => vm.Name, v => v.NameEdit.Text)
.DisposeWith(d);
});
}
public override void _Ready()
{
ViewModel = new MyViewModel();
}
}
<details> <summary>Why assign <code>ViewModel</code> in <code>_Ready</code>?</summary>
In Godot there is no built-in UI/routing framework that creates views and injects their ViewModel for you. Unlike Avalonia + ReactiveUI, where RoutingState and the platform's IViewLocator (resolved via DataTemplates / Splat) typically construct the View and set ViewModel for you during navigation, in Godot each scene's root script must instantiate its own ViewModel at some point. The recommended place is _Ready, because:
- Godot guarantees
_Readyis called after all children are initialized, so[SceneTree]-generated node properties (e.g.NameEdit) are non-null here; - Godot's C# virtual
_Readyruns before theReadysignal is emitted, andWhenActivatedsubscribes to theReadysignal viaGodotActivationFetcher, so_Ready'sViewModel = new MyViewModel();completes before theWhenActivatedcallback fires.
If you wire navigation yourself (see Routing below), the RoutedViewController sets view.ViewModel = viewModel after resolving the view, so you don't need _Ready assignment for routed views -- only for top-level/root scenes.
</details>
Data Binding
Two-way binding with Bind, one-way binding with OneWayBind:
this.WhenActivated(d =>
{
// Two-way: LineEdit.Text <-> ViewModel.Name
this.Bind(ViewModel, vm => vm.Name, v => v.NameEdit.Text)
.DisposeWith(d);
// One-way with converter
this.OneWayBind(ViewModel, vm => vm.Score, v => v.ScoreLabel.Text,
score => $"{score:F1}")
.DisposeWith(d);
// Derived value
this.WhenAnyValue(x => x.ViewModel!.Name, x => x.ViewModel!.Notes)
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(tuple => { /* update UI */ })
.DisposeWith(d);
});
Command Binding
Bind a ReactiveCommand to a BaseButton (triggers on Pressed) or LineEdit (triggers on TextSubmitted). CanExecute automatically disables the control:
this.WhenActivated(d =>
{
// Button press executes the command
this.BindCommand(ViewModel, vm => vm.SaveCommand, v => v.SaveButton)
.DisposeWith(d);
// LineEdit submits the command, passing the current text as parameter
this.BindCommand(ViewModel, vm => vm.SearchCommand, v => v.SearchEdit,
vm => vm.QueryString)
.DisposeWith(d);
// Conditional command
this.Bind(ViewModel, vm => vm.IsEnabled, v => v.CheckButton.ButtonPressed)
.DisposeWith(d);
this.BindCommand(ViewModel, vm => vm.DoWorkCommand, v => v.WorkButton)
.DisposeWith(d);
});
<details> <summary>How it works</summary>
Two binders cooperate to deliver property-change notifications:
GodotPropertyBinder -- signal-based
Subscribes to built-in Godot signals so changes arrive instantly with no frame delay:
| Control type | Property | Godot signal |
|---|---|---|
Range |
Value |
ValueChanged |
LineEdit |
Text |
TextChanged |
TextEdit |
Text |
TextChanged |
BaseButton |
ButtonPressed |
Toggled |
TabContainer |
CurrentTab |
TabChanged |
TabBar |
CurrentTab |
TabChanged |
OptionButton |
Selected |
ItemSelected |
ColorPicker |
Color |
ColorChanged |
ColorPickerButton |
Color |
ColorChanged |
GodotPollBasedPropertyBinder — per-frame polling
For any GodotObject property that does not have a dedicated signal, the binder reads the value every frame via Observable.PollEveryUpdate and emits when the value changes. Because it relies on polling, there is at most one frame of latency.
</details>
Activation Lifecycle
When a view is activated (entering the scene tree and ready), WhenActivated fires. All subscriptions registered via DisposeWith(d) are cleaned up on deactivation:
public MyScene()
{
this.WhenActivated(d =>
{
// subscriptions are disposed when the view is deactivated
this.WhenAnyValue(x => x.ViewModel!.Name)
.Subscribe(name => GD.Print($"Name: {name}"))
.DisposeWith(d);
Disposable.Create(() => GD.Print("deactivated"))
.DisposeWith(d);
});
}
Reactive View Properties
The examples above observe the ViewModel. A node can also expose its own observable properties -- useful for reusable custom nodes that have no ViewModel of their own (e.g. an item view inside ItemsBinder, or a self-contained widget).
Because Godot has no equivalent of WPF's DependencyProperty or Avalonia's StyledProperty, a view property becomes observable in one of two ways: it raises its own change notifications, or it is polled every frame. Making the node an IReactiveObject unlocks the first; engine-declared properties without its own notification signals fall back to the second automatically.
// usings: Godot, ReactiveUI, ReactiveUI.SourceGenerators
[SceneTree(root: "_root")]
[IReactiveObject] // from ReactiveUI.SourceGenerators -- generates the four IReactiveObject members
public partial class CustomNode : Control, IActivatableView
{
// Observable AND inspector-editable
[Reactive]
[Export]
public partial int ClickCount { get; set; }
public CustomNode()
{
this.WhenActivated(d =>
{
// User-declared [Reactive] property
this.WhenAnyValue(x => x.ClickCount)
.Subscribe(count => CountLabel.Text = $"ClickCount: {count}")
.DisposeWith(d);
// Engine-declared property
this.WhenAnyValue(x => x.Position)
.Subscribe(pos => PositionLabel.Text = $"position: {pos}")
.DisposeWith(d);
});
}
public override void _Ready()
{
IncrementButton.Pressed += () => ClickCount++;
}
}
IActivatableView is a marker interface -- it is all WhenActivated needs, so a node can use the activation lifecycle without a ViewModel. Views declared with [GodotViewFor<T>] already implement IReactiveObject and IActivatableView, so they support [Reactive][Export] out of the box.
One thing worth remembering: on an IReactiveObject or INotifyPropertyChanged node, a plain CLR property is silently unobservable. If you observe a user-declared property that never raises change notifications, you get the initial value and nothing more. Use [Reactive] or a manual RaiseAndSetIfChanged.
<details> <summary>Getting <code>IReactiveObject</code> onto a node</summary>
Either works and they are interchangeable:
// 1. [IReactiveObject] attribute (ReactiveUI.SourceGenerators)
[IReactiveObject]
public partial class CustomNodeA : Control, IActivatableView { /* ... */ }
// 2. Hand-written
public partial class CustomNodeB : Control, IReactiveObject, IActivatableView
{
public event PropertyChangedEventHandler? PropertyChanged;
public event PropertyChangingEventHandler? PropertyChanging;
void IReactiveObject.RaisePropertyChanged(PropertyChangedEventArgs args) => PropertyChanged?.Invoke(this, args);
void IReactiveObject.RaisePropertyChanging(PropertyChangingEventArgs args) => PropertyChanging?.Invoke(this, args);
}
A shared abstract base carrying IReactiveObject members is the third option. This comes closest to Avalonia, where a custom view inherits a framework-provided base class -- e.g. class MyView : ReactiveUserControl<MyViewModel>, with IViewFor<T> already implemented by the base. It is rarely practical in Godot, though: C# has no multiple inheritance, Godot does not support generic classes in script resources, and a view must derive from a Godot node, leaving no room to also inherit the shared abstract base. In practice, prefer one of the first two options, combined with the source generator and [GodotViewFor<T>].
</details>
Signal → Observable
For most Godot controls that fire a signal to notify value or state changes, the library provides corresponding ObserveXxx() extension methods covering BaseButton, Range, LineEdit, TextEdit, ItemList, OptionButton, TabBar, TabContainer, ColorPicker, ColorPickerButton, Tree, PopupMenu, FileDialog, and SceneTree:
this.WhenActivated(d =>
{
ToggleButton.ObserveToggled()
.Subscribe(on => ViewModel!.IsToggled = on)
.DisposeWith(d);
LineEdit.ObserveTextChanged()
.Subscribe(text => ViewModel!.InputText = text)
.DisposeWith(d);
GetTree().ObserveProcessFrame()
.Subscribe(_ => ViewModel!.FrameCount++)
.DisposeWith(d);
});
For any GodotObject, bridge custom signals to IObservable<T> with built-in extension methods:
this.WhenActivated(d =>
{
// Overloads for 0...7 typed arguments;
// N-arg overloads emit ValueTuple<T1, ..., TN>, 0-arg emits Unit
// 0-arg signal -> IObservable<Unit>
MyNode.ObserveSignal("my_signal")
.Subscribe(_ => { /* fired with no payload */ })
.DisposeWith(d);
// 1-arg signal -> IObservable<ValueTuple<T1>>
MyNode.ObserveSignal<string>("my_signal")
.Subscribe(args => { /* args.Item1 */ })
.DisposeWith(d);
// 3-arg signal -> IObservable<ValueTuple<T1, T2, T3>>
MyNode.ObserveSignal<int, string, bool>("my_signal")
.Subscribe(args => { var (i, s, b) = args; /* ... */ })
.DisposeWith(d);
});
Collection Binding
The library provides two families of binders: ItemsBinder synchronizes an ObservableCollection<TViewModel> to a Godot node container by creating and managing child nodes; the indexed control binders (ItemListBinder, OptionButtonBinder, TabBarBinder, PopupMenuBinder) bind to Godot's built-in item controls via their native add/remove APIs.
Binders are designed as thin wrappers over Godot's native APIs and should cover the common use cases. For complex scenarios that fall outside the built-in binders' scope, you can always combine a binder with Godot's native API calls on the same control, or create your own binder by extending CollectionBinderBase<TContainer, TViewModel>.
ItemsBinder (Node Tree)
ItemsBinder maps each ViewModel to a child node inside a Godot container (e.g. VBoxContainer, HBoxContainer). The simplest usage is a custom node factory with a VM binder -- no GodotViewLocator required. For larger projects, you can also resolve views through GodotViewLocator:
// Custom node factory + VM binder -- no ViewLocator needed
var labelBinder = new ItemsBinder<VBoxContainer, Label, ItemViewModel>(
() => new Label(),
(label, vm) => label.Text = vm.Name);
this.WhenActivated(d =>
{
labelBinder.Connect(LabelContainer, ViewModel!.Items)
.DisposeWith(d);
});
// Via view locator -- resolves .tscn scenes for each ViewModel
var itemsBinder = new ItemsBinder<VBoxContainer, ItemLabel, ItemViewModel>(
new GodotViewLocator()); // or Splat.Locator.Current.GetService<GodotViewLocator>()! if registered
this.WhenActivated(d =>
{
itemsBinder.Connect(ItemsContainer, ViewModel!.Items)
.DisposeWith(d);
});
Indexed Control Binders
ItemListBinder, OptionButtonBinder, TabBarBinder, and PopupMenuBinder bind to Godot's built-in indexed controls. They accept Expression<Func<TViewModel, string?>> / Expression<Func<TViewModel, Texture2D?>> selectors and keep the control's text/icon in sync via WhenAnyValue when TViewModel implements INotifyPropertyChanged. POCO view models only get the initial value written at add/replace time.
All four expose ObserveSelection() for tracking the user's selection, and share the same Connect(control, collection) API:
// ItemList
var itemListBinder = new ItemListBinder<ItemViewModel>(textSelector: vm => vm.Name);
this.WhenActivated(d =>
{
itemListBinder.Connect(itemListControl, items)
.DisposeWith(d);
itemListBinder.ObserveSelection()
.Subscribe(vm => { /* handle selection */ })
.DisposeWith(d);
});
// OptionButton
var optionBinder = new OptionButtonBinder<ItemViewModel>(textSelector: vm => vm.Name);
this.WhenActivated(d =>
{
optionBinder.Connect(optionButton, items)
.DisposeWith(d);
optionBinder.ObserveSelection()
.Subscribe(vm => { /* handle selection */ })
.DisposeWith(d);
});
// TabBar
var tabBinder = new TabBarBinder<ItemViewModel>(textSelector: vm => vm.Name);
this.WhenActivated(d =>
{
tabBinder.Connect(tabBar, items)
.DisposeWith(d);
tabBinder.ObserveSelection()
.Subscribe(vm => { /* handle selection */ })
.DisposeWith(d);
});
// PopupMenu -- with command binding, each item executes its own ICommand
var menuBinder = new PopupMenuBinder<ItemViewModel>(
textSelector: vm => vm.Label,
iconSelector: vm => vm.Icon,
commandSelector: vm => vm.ActionCommand,
commandParameterSelector: vm => vm.Parameter);
this.WhenActivated(d =>
{
menuBinder.Connect(popupMenu, menuItems)
.DisposeWith(d);
menuBinder.ObserveSelection()
.Subscribe(vm => { /* handle selection */ })
.DisposeWith(d);
});
Connect(...) returns an IDisposable that detaches the binder from the container and the collection. Always dispose it (typically via DisposeWith(...) inside WhenActivated) so cleanup happens on deactivation.
<details> <summary>PopupMenu command binding</summary>
PopupMenuBinder supports ICommand binding via two additional constructor parameters:
commandSelector— anExpression<Func<TViewModel, ICommand?>>that selects the command for each menu item.commandParameterSelector— an optionalExpression<Func<TViewModel, object?>>that selects the parameter passed toCanExecuteandExecute.
When commandSelector is provided, the binder:
- Tracks each command's
CanExecuteChangedevent and automatically callsContainer.SetItemDisabledto reflectCanExecutestate. - Subscribes to
Container.ObserveIdPressed()and executes the corresponding command with its parameter when a menu item is clicked.
// ViewModel -- each menu item carries its own command
public partial class MenuItemViewModel : ReactiveObject
{
[Reactive] public partial string Label { get; set; } = "";
[Reactive] public partial Texture2D? Icon { get; set; }
public ICommand? ActionCommand { get; set; }
public object? Parameter { get; set; }
}
// Bind with command
var menuBinder = new PopupMenuBinder<MenuItemViewModel>(
textSelector: vm => vm.Label,
iconSelector: vm => vm.Icon,
commandSelector: vm => vm.ActionCommand,
commandParameterSelector: vm => vm.Parameter);
</details>
<details> <summary>Why a Binder instead of an <code>ItemsControl</code>?</summary>
In Avalonia/WPF, collection synchronization is built into the templating stack: you bind ItemsControl.ItemsSource and the framework's ItemContainerGenerator creates a container per item, applies the DataTemplate, and wires DataContext. Godot has no XAML/template engine and no ItemsSource property -- its VBoxContainer, ItemList, OptionButton, Tree, etc. are heterogeneous controls with completely different add/remove APIs (AddChild, AddItem, AddItem+set_metadata, CreateItem...). There is no shared "item generator" the binding layer can hook into.
The *Binder types fill that gap. Each binder encapsulates the control-specific add/remove/replace/move logic for one Godot control family and exposes a uniform Connect(container, collection) API. This keeps the view-side code declarative (the same shape as WhenActivated + DisposeWith(d) used elsewhere in ReactiveUI) while staying a thin adapter over Godot's native APIs -- no shadow visual tree, no intermediate "items host" node, no allocation-heavy template expansion. The trade-off is that you pick the binder matching your control (ItemsBinder for node containers, ItemListBinder for ItemList, ...), rather than one universal ItemsControl.
A second reason is structural: an Avalonia-style ItemsControl<T> would have to derive from a Godot control (Godot.Node), but Godot treats every Godot.Node-derived C# class as a script resource tied to a unique path inside the Godot project's source directory, and it does not support generic Godot.Node types at all (see Developer.md § Limitations for Godot). A reusable, generic collection host therefore cannot live in a third-party assembly nor be typed per item. The binder sidesteps both constraints -- it is a plain, generic C# class that drives an existing Godot control through a Connect(container, ...) call, which is exactly why it ships in this library while a generic ItemsControl<TNode, TView, TViewModel> cannot.
</details>
Interaction
Bind a ViewModel's Interaction<TInput, TOutput> to a View-level handler (e.g. a dialog):
// ViewModel
public Interaction<string, bool> ConfirmDelete { get; } = new();
DeleteCommand = ReactiveCommand.CreateFromTask(async () =>
{
var confirmed = await ConfirmDelete.Handle("Confirm to delete?");
ResultText = confirmed ? "Confirmed" : "Canceled";
});
// View
this.WhenActivated(d =>
{
this.BindInteraction(ViewModel, vm => vm.ConfirmDelete, async context =>
{
ConfirmDialog.DialogText = context.Input;
ConfirmDialog.PopupCentered();
var tcs = new TaskCompletionSource<bool>();
ConfirmDialog.Confirmed += () => tcs.TrySetResult(true);
ConfirmDialog.Canceled += () => tcs.TrySetResult(false);
context.SetOutput(await tcs.Task);
}).DisposeWith(d);
});
BindInteraction is standard ReactiveUI; the dialog wiring above is user code -- the library ships no dialog-specific interaction helpers.
Validation
ReactiveUI.Validation is a separate package -- install it first:
dotnet add package ReactiveUI.Validation
Then define rules on the ViewModel and bind error messages on the View:
// usings: ReactiveUI, ReactiveUI.SourceGenerators,
// ReactiveUI.Validation.Abstractions, ReactiveUI.Validation.Contexts,
// ReactiveUI.Validation.Extensions
// ViewModel -- implement IActivatableViewModel and IValidatableViewModel
public partial class MyViewModel : ReactiveObject, IActivatableViewModel, IValidatableViewModel
{
public ViewModelActivator Activator { get; } = new();
public IValidationContext ValidationContext { get; } = new ValidationContext();
[Reactive] public partial string Email { get; set; } = "";
public MyViewModel()
{
this.ValidationRule(vm => vm.Email,
email => !string.IsNullOrWhiteSpace(email) && email.Contains('@'),
"Email must contain '@'.");
}
}
// View
this.WhenActivated(d =>
{
this.Bind(ViewModel, vm => vm.Email, v => v.EmailEdit.Text)
.DisposeWith(d);
this.BindValidation(ViewModel, vm => vm.Email, v => v.ErrorLabel.Text)
.DisposeWith(d);
});
View Location (GodotViewLocator)
GodotViewLocator is the bridge between ReactiveUI's view resolution and Godot's PackedScene system. In Avalonia, IViewLocator is typically wired via XAML DataTemplates -- the platform inspects a ViewModel's type at binding time and instantiates the matching Control declared in XAML. Godot has no equivalent of DataTemplate-driven view resolution; scenes are loaded by GD.Load<PackedScene>(path).Instantiate(). GodotViewLocator provides that mapping manually: register a ViewModel type against a .tscn path, and ResolveView will load and instantiate the scene as an IViewFor<TViewModel>.
Registering GodotViewLocator into the Splat locator (as the Autoload does) is optional. You can instead create one on demand wherever you need it -- for example var locator = new GodotViewLocator(); locator.RegisterView<MyView, MyViewModel>(...); -- and pass it directly to RoutedViewController / ItemsBinder. The Splat registration is only a convenience so that library components that resolve through Locator.Current can find a shared instance.
There are three ways to register views on a GodotViewLocator instance:
var locator = new GodotViewLocator();
// 1. Explicit, view + viewmodel types
locator.RegisterView<MyView, MyViewModel>("res://Views/MyView.tscn");
// or if you have GodotSharp.SourceGenerators installed
locator.RegisterView<MyView, MyViewModel>(MyView.TscnFilePath);
// 2. Only the viewmodel type ( viewType inferred from the IViewFor<T> implementation )
locator.RegisterView<MyViewModel>("res://Views/MyView.tscn");
// 3. Reflect the whole assembly -- picks up every concrete type implementing
// IViewFor<TViewModel> that also exposes a static TscnFilePath property.
locator.RegisterViewsFromAssemblyViaReflection(typeof(MyView).Assembly);
Option 3 relies on the TscnFilePath static property generated by GodotSharp.SourceGenerators ([SceneTree]). That package is entirely optional -- options 1 and 2 accept a "res://..." path string directly and need no source generator. If you skip it, just call RegisterView(...) manually for each ViewModel/View pair; routing, ItemsBinder, and other view-resolution features work the same once the registrations are in place.
RegisterView<TViewModel>(path) only stores the ViewModel type and the .tscn path. At resolve time GodotViewLocator does GD.Load<PackedScene>(path).Instantiate<IViewFor<TViewModel>>() -- the actual view class is whatever the .tscn root script implements (it must implement IViewFor<TViewModel>). The <TView> type argument in RegisterView<TView, TViewModel>(...) is used only for compile-time validation and does not influence resolution.
ResolveView is normally called by ReactiveUI (or the sample RoutedViewController below) rather than your own code; you just keep the registrations up to date.
Routing
ReactiveUI's RoutingState works with GodotViewLocator for page navigation. The library provides the view locator and registration API; you still need a small adapter to swap child nodes on navigation -- RoutedViewController below is not part of the NuGet package, it is sample code you can copy from IntegrationTests/Views/Routing/RoutedViewController.cs:
// Setup (in _Ready or constructor)
var locator = new GodotViewLocator();
locator.RegisterView<PageAView, PageAViewModel>(PageAView.TscnFilePath);
locator.RegisterView<PageBViewModel>(PageBView.TscnFilePath);
var shell = new ShellViewModel(); // implements IScreen with RoutingState
var router = new RoutedViewController(shell.Router, locator); // sample adapter, see note above
router.Connect(ContentContainer);
// Navigate
shell.Router.Navigate.Execute(new PageAViewModel(shell));
shell.Router.NavigateBack.Execute().Subscribe();
Frame Operators
Frame-aware reactive operators powered by SceneTree.ProcessFrame:
this.WhenActivated(d =>
{
// Emit every frame (or pass RxSchedulers.PhysicsFrameScheduler for physics frame)
Observable.EveryUpdate()
.Subscribe(_ => { /* per-frame work */ })
.DisposeWith(d);
// Delay by frames
Observable.AfterFrame(0)
.DelayFrame(30)
.Subscribe(_ => { /* fires after 30 frames */ })
.DisposeWith(d);
// Emit once after N frames, then every M frames (interval in frames)
Observable.IntervalFrame(60)
.Subscribe(_ => { /* every 60 frames */ })
.DisposeWith(d);
// Emit a single value after N frames
Observable.ReturnFrame("ready", 30)
.Subscribe(msg => { /* fires after 30 frames */ })
.DisposeWith(d);
// Debounce: emit after 30 frames of silence
input.DebounceFrame(30)
.Subscribe(value => { /* ... */ })
.DisposeWith(d);
// Throttle: emit first value per 60-frame window
input.ThrottleFirstFrame(60)
.Subscribe(value => { /* ... */ })
.DisposeWith(d);
// Chunk: collect values and emit a list every 30 frames
input.ChunkFrame(30)
.Subscribe(batch => { /* IList<T> */ })
.DisposeWith(d);
// Poll a property every frame, emit on change
Observable.PollEveryUpdate(this, v => v.FreeIcon.Position)
.Subscribe(pos => { /* ... */ })
.DisposeWith(d);
});
EveryUpdate does not guarantee parent-before-child ordering -- and this is intentional. Unlike Godot's native _Process, which runs parents before children by tree order, EveryUpdate callbacks run in subscription order. Guaranteeing tree order would force the scheduler to track each subscription's owning node and re-sort work items every frame -- a per-frame cost for a guarantee most subscriptions never need -- and would couple the Rx stream abstraction to the scene tree. If your logic depends on parent/child ordering, override _Process/_PhysicsProcess on the nodes instead; Godot provides that ordering for free.
Alternatives
If this library isn't a fit for your needs, consider:
- R3 zero-allocation Rx.NET reimplementation by the author of UniRx. Good fit if you prefer
ReactivePropertyover full MVVM or want frame operators as core. Can run alongside ReactiveUI (e.g. ReactiveUI at UI layer, R3 at business-logic layer).
License
MIT License
Development
See Developer.md.
AI Disclosure
AI tools are used only for code suggestions and trivial tasks. All AI-generated contributions are reviewed, tested, and approved by the author who assumes full responsibility for the final code.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- GodotSharp (>= 4.1.0)
- ReactiveUI (>= 23.2.28)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.2.0 | 81 | 9/3/2026 |
| 1.2.0-beta5 | 95 | 8/7/2026 |
| 1.2.0-beta4 | 107 | 8/2/2026 |
| 1.2.0-beta3 | 96 | 7/28/2026 |
| 1.2.0-beta2 | 90 | 7/27/2026 |
| 1.2.0-beta | 89 | 7/26/2026 |