Skip to content

Reactive

Phawit Pornwattanakul edited this page May 29, 2026 · 1 revision

Sakura have a built-in observable value primitive, we call it Reactive<T>. It used for anything that need to be observed or synchronize across components (e.g. config settings, volume level, window modes and more)

Basic usage

var health = new Reactive<int>(100);

// subscribe to changes
health.ValueChanged += e =>
    Logger.Debug($"Health changed from {e.OldValue} to {e.NewValue}");

health.Value = 80;   // fires the event: OldValue=100, NewValue=80
health.Value = 80;   // no event — value didn't change

Value change event

`ValueChangedEvent carries both the old and new value so you can react to the delta without storing the previous value yourself.

Read the reactive value

Use .Value or use built-in implicit conversion:

int current = health.Value;       // explicit
int current = health;             // implicit — same thing

Default value and reset value

var speed = new Reactive<float>(1.0f);
speed.Value = 2.5f;

bool isDefault = speed.IsDefault;  // false
speed.Value = speed.Default;       // back to 1.0f, fires event

Binding

Binding wires two Reactive<T> objects together so that when one changes, the other automatically follows. This is how the framework synchronises config settings with runtime state — the config manager holds the authoritative value and the runtime system binds to it.

var source = new Reactive<double>(1.0);
var follower = new Reactive<double>(0.0);

follower.BindTo(source); // follower.Value is now 1.0 (immediately updated on bind)

source.Value = 0.5; // follower.Value is now 0.5 automatically

Note : The binding is one-directional -> follower watches source, not the other way around. Changing follower.Value directly does not affect source.

Unbinding

follower.UnbindFrom(source);
follower.UnbindAll();

Why binding matters

When your application or game changes the master volume slider, that slider's Reactive<double> is bound to AudioManager.MasterVolume, which in turn is bound to the config manager's persisted FrameworkSetting.MasterVolume setting. Changing the slider value propagates through the chain automatically — you never manually copy values between layers.

Specialised

The framework provide some subclasses for common primitives:

Type Use case
Reactive Any reference or value type
ReactiveBool Boolean flags (extends Reactive)
ReactiveInt Integer values with min/max clamping
ReactiveFloat Float values with min/max clamping
ReactiveNumber Base for numeric reactives
--- ---

The numeric types add MinValue and MaxValue constraints so the value is automatically clamped when set.

var volume = new ReactiveFloat(1.0f) { MinValue = 0f, MaxValue = 1f };
volume.Value = 1.5f; // clamped to 1.0f
volume.Value = -0.3f; // clamped to 0.0f

Disable reactive

Set Disabled to true make the reactive read-only, all write via .Value, BindTo updates and Parse are silently ignored :

var locked = new Reactive<int>(42);
locked.Disabled = true;
locked.Value = 99; // ignored
Console.WriteLine(locked.Value); // still 42

Adjust framework behavior or your own settings via ConfigManager

Every framework setting is a Reactive<T> retrieved from FrameworkConfigManager (or your own ConfigManager subclass). When you get a setting, you get a live reactive object. Any change to it is automatically saved to disk:

// Getting a setting — always returns the same Reactive<T> for the same key
Reactive<double> masterVolume = Host.FrameworkConfigManager.Get<double>(FrameworkSetting.MasterVolume);

// Changing it writes to disk and fires ValueChanged
masterVolume.Value = 0.8;

The framework internally uses BindWith (BindTo convenience from the config manager) to connect these reactives to the runtime subsystems. In App.Load():

var masterVolume = Host.FrameworkConfigManager.Get<double>(FrameworkSetting.MasterVolume);
AudioManager.MasterVolume.BindTo(masterVolume);

From this point, changing AudioManager.MasterVolume.Value updates the persisted setting, and loading a saved config updates the audio system — both directions flow correctly because the audio manager also subscribes to ValueChanged.

See current available in FrameworkConfigManager

For your own settings :

[SettingSource("mygame.ini")]
public enum MyAppSetting { 
    MusicVolume, 
    SfxVolume, 
    Difficulty,
    CustomSettingSomething
}

public class MyConfigManager : ConfigManager<MyAppSetting>
{
    public MyConfigManager(Storage storage) : base(storage)
    {
        Get(MyAppSetting.MusicVolume, 1.0);
        Get(MyAppSetting.SfxVolume, 1.0);
        Get(MyAppSetting.Difficulty, 1);
        Get(MyAppSetting.CustomSettingSomething, true);
    }
}

// In App.Load():
var myConfig = new MyConfigManager(Storage);
myConfig.Load();

// Bind game audio to your config
AudioManager.TrackVolume.BindTo(myConfig.Get<double>(MyAppSetting.MusicVolume));
AudioManager.SampleVolume.BindTo(myConfig.Get<double>(MyAppSetting.SfxVolume));

// Also adjust custom setting
myConfig.Get<bool>(MyAppSetting.CustomSettingSomething).ValueChanged += e => {
    if (e.NewValue)
        enableSomething();
    else
        disableSomething();
}

Use reactive in your own code

We really recommend you to use the reactive for sync the state for multiple UI component and more components.

public class Player
{
    public Reactive<int> Health { get; } = new ReactiveInt(100) { MinValue = 0, MaxValue = 100 };
    public Reactive<bool> IsDead { get; } = new ReactiveBool(false);

    public Player()
    {
        Health.ValueChanged += e =>
        {
            if (e.NewValue <= 0)
                IsDead.Value = true;
        };
    }
}

// UI sync
player.Health.ValueChanged += e => healthBar.SetValue(e.NewValue);
player.IsDead.ValueChanged += e => { if (e.NewValue) showDeathScreen(); };

// Play sound when hurt
player.Health.ValueChanged += e =>
{
    if (e.NewValue < e.OldValue)
        hurtSound.Play();
};

Note

  • ValueChanged does not fire if the new value equals the current value — equality is checked first. This means you can safely set .Value in a loop without triggering cascading events.
  • Bindings do not prevent the follower from being set independently. If you call follower.Value = x directly, the event fires and the value changes — the binding only means changes from source also propagate. If you want a truly read-only reactive driven by a source, set Disabled = true on the follower after binding.
  • ConfigManager.Get<T> always returns the same Reactive<T> instance for a given key. Call it from multiple places safely — they all get the same object.

Clone this wiki locally