Skip to content

About

ReactiveUI integration for Godot Engine (C#)

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Repository files navigation

QfStudio.Godette.ReactiveUI

NuGet Versions NuGet Downloads

ReactiveUI integration for Godot Engine

🚀 Getting Started · 📖 Documentation · 🎓 Demos · 📚 API Reference

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.BindCommand to Godot nodes; CanExecute automatically disables the target control.
  • Reactive view properties -- An [Export] Godot scene variable can be reactive.
  • Activation lifecycle -- WhenActivated driven 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 typed ObserveSignal<T...> bridge for custom signals.
  • Collection binding -- the ItemsBinder family synchronizes an ObservableCollection<T> to Godot containers.
  • View location & routing -- GodotViewLocator maps ViewModels to .tscn scenes and integrates with ReactiveUI RoutingState for navigation.
  • Interactions & validation -- standard ReactiveUI BindInteraction and ReactiveUI.Validation's BindValidation work out of the box.
  • Frame-aware operators -- EveryUpdate, DelayFrame, IntervalFrame, and debounce/throttle/chunk/poll variants synced to process or physics frames.
  • Main-thread scheduling -- GodotMainThreadScheduler plus process-frame and physics-frame schedulers registered with ReactiveUI.
  • Source generator -- [GodotViewFor<T>] implements IViewFor<T> for .tscn root scripts with no boilerplate.

Prerequisites

  • .NET 10
  • Godot 4.1+
  • ReactiveUI v23

This release targets ReactiveUI v23. ReactiveUI v24 (released July 26, 2026) is not yet supported. The library is also not optimized for zero-allocation; allocation-reduction work is planned together with the ReactiveUI v24 upgrade.

Installation

Core package:

dotnet add package QfStudio.Godette.ReactiveUI

Optional but recommended packages improve the development experience:

dotnet add package GodotSharp.SourceGenerators
dotnet add package ReactiveUI.SourceGenerators
With vs Without [SceneTree]

Annotate a .tscn root script to get:

  • A TscnFilePath static property for type-safe scene loading.
  • Strongly-typed fields for nodes marked unique_name_in_owner -- no GetNode calls 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";
    }
}
With vs Without [Reactive]
// 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);
    }
}

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);
}

Each builder call does the following:

  • .WithGodot(_processFrameScheduler, _physicsFrameScheduler) sets up the Godot platform: it creates the main-thread scheduler (from SynchronizationContext.Current), wires up the process/physics-frame schedulers you pass in, and registers the platform services needed for view activation, property binding, and command binding. It also prepares ReactiveUI's core services, so you do not call WithCoreServices() yourself.
  • .WithGodotConverters() (optional) registers the float↔double binding converters (FloatToDoubleConverter, DoubleToFloatConverter).
  • .WithGodotViewLocator(locator => …) (optional) registers the GodotViewLocator; the callback is where you register your views — here, by reflecting the current assembly.

In Godot Editor, go to Project > Project Settings > Autoload and add this script as an Autoload with a name like RxAppBootstrapper.

Note

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. It deactivates (false) on TreeExited. This is semantically equivalent to Avalonia's AttachedToVisualTree / DetachedFromVisualTree.

How activation is detected

The GodotActivationFetcher emits true from three paths:

  • the Ready signal (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.

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();
    }
}
Why assign ViewModel in _Ready?

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 _Ready is called after all children are initialized, so [SceneTree]-generated node properties (e.g. NameEdit) are non-null here;
  • Godot's C# virtual _Ready runs before the Ready signal is emitted, and WhenActivated subscribes to the Ready signal via GodotActivationFetcher, so _Ready's ViewModel = new MyViewModel(); completes before the WhenActivated callback 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.

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);
});
How it works

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.

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.

Getting IReactiveObject onto a node

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>].

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.

PopupMenu command binding

PopupMenuBinder supports ICommand binding via two additional constructor parameters:

  • commandSelector — an Expression<Func<TViewModel, ICommand?>> that selects the command for each menu item.
  • commandParameterSelector — an optional Expression<Func<TViewModel, object?>> that selects the parameter passed to CanExecute and Execute.

When commandSelector is provided, the binder:

  1. Tracks each command's CanExecuteChanged event and automatically calls Container.SetItemDisabled to reflect CanExecute state.
  2. 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);
Why a Binder instead of an ItemsControl?

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.

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);
});

Note

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.

Demos

  • ToDoList -- a two-screen to-do app ported from the classic grokys/todo-tutorial, demonstrating ViewModel-driven screen switching, list rows mounted by ItemsBinder, and a CanExecute-gated add flow.
  • MusicStore -- a music store ported from the classic AvaloniaUI/MusicStoreTutorial, demonstrating an Interaction-hosted search dialog, cover art decoded into Texture2Ds and cached on disk, and selection state coordinated in the ViewModel.

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 ReactiveProperty over 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.

About

ReactiveUI integration for Godot Engine (C#)

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages