Skip to content

Reactive programming

Work that runs when a value changes can be written on the dependent side rather than at the call site. When a field carrying the attribute changes, the work watching that field runs.

AttributesAttached toWhen it runs
[Reactive]FieldWhat gets tracked for changes. Make it private
[Computed]PropertiesA value derived from other fields. Updated when a source changes
[Effect]MethodsIt runs when a dependency changes. It also runs once at startup, so use this when you want the display correct from the moment it starts
[On(...)]MethodsIt runs the instant the trigger changes. It doesn’t run at startup, so use this when you only want the moment of change

A field whose changes are tracked, and the work that watches it. Touching it changes the field, and the work that paints the color runs. No call is written anywhere.

using UnityEngine;
using Tsukimi;
public class ReactiveFieldAndEffect : TsukimiBehaviour
{
// A field tracked for changes. Make it private.
[Reactive] private int charge = 100;
// Runs when a dependency changes. Also runs once at startup.
[Effect]
private void ApplyLook()
{
Renderer r = GetComponent<Renderer>();
if (r != null) r.material.color = charge > 0 ? Color.cyan : Color.gray;
}
public override void Interact()
{
charge = charge - 20;
if (charge < 0) charge = 0;
}
}

Lit is derived from two fields. The effect watches Lit, so charge going from 100 to 80 leaves Lit unchanged and no repaint happens.

using UnityEngine;
using Tsukimi;
public class ReactiveComputed : TsukimiBehaviour
{
[Reactive] private int charge = 100;
[Reactive] private bool power = true;
// A value derived from other fields. Updated when a source changes.
[Computed] private bool Lit => power && charge > 0;
[Effect]
private void ApplyLook()
{
Renderer r = GetComponent<Renderer>();
if (r != null) r.material.color = Lit ? Color.cyan : Color.gray;
}
public void TogglePower()
{
power = !power;
}
}

Parameters are optional. If you write them, write all of them in the same order and with the same types as the triggers. What you receive is the value from just before the change.

using UnityEngine;
using Tsukimi;
public class ReactiveOnTrigger : TsukimiBehaviour
{
[Reactive] private int charge = 100;
// Runs the moment a trigger changes. Doesn't run at startup.
// Parameters are optional; if you write them, write all of them in the same order and types as the triggers.
[On(nameof(charge))]
private void OnCharge(int prevCharge)
{
Debug.Log(prevCharge + " -> " + charge);
}
public override void Interact()
{
charge = charge - 20;
}
}

List names in the parentheses and only those are watched. A change to unrelated doesn’t run it.

using UnityEngine;
using Tsukimi;
public class ReactiveEffectDependencies : TsukimiBehaviour
{
[Reactive] private int width = 2;
[Reactive] private int height = 3;
[Reactive] private int unrelated;
// Writing the dependencies makes it watch only what you wrote. Moving unrelated doesn't make it run.
[Effect(nameof(width), nameof(height))]
private void ShowArea()
{
Debug.Log(width * height);
}
public void Grow()
{
width = width + 1;
unrelated = unrelated + 1;
}
}

[Effect] can also be placed on a class. The method named in Calls becomes the effect, and what it points at is an ordinary method, so nothing is written on the declaration itself.

using UnityEngine;
using Tsukimi;
// the effect is written on the class. Calls points at an ordinary method, and nothing is written on the declaration
// dependencies can be written too: [Effect(nameof(level), Calls = nameof(ShowLevel))]
// naming a method that does not exist is an error
[Effect(Calls = nameof(ReactiveClassEffect.ShowLevel))]
public class ReactiveClassEffect : TsukimiBehaviour
{
[Reactive] private int level;
public void ShowLevel()
{
Debug.Log(level);
}
public override void Interact()
{
level = level + 1;
}
}
TypesCheck
Numbers, booleans, stringsCompared directly
Types carried by value (Unity’s value types, plus System.DateTime, decimal, and the like)Compared with the typed Equals. Unlike ==, it doesn’t swallow floating-point error
Reference typesJudged by whether it points to the same instance
Quaternion and ColorComparing components goes through float’s Equals, so NaN is considered equal to NaN (other value types compare components with ==, so a NaN makes them change forever)
Circular dependenciesA cycle within the same Behaviour is a compile-time error. Writing back to your own trigger counts as one
How to fix itSplit the raw value and the rounded value into separate fields. Nothing cycles as long as the target you write to isn’t a trigger
[Reactive] private int hp;
// This errors. It reads hp and writes hp, so it is circular.
[Effect]
private void Clamp()
{
if (hp > 100) hp = 100;
}

The following 4 don’t stop compilation and don’t error when run (each is a form that isn’t doing what you wrote, so the warning is the only clue).

NumberFormWhen it appears
TUKI0119An effect that runs only once, at startupAppears on an effect with [Effect] when no entry point that writes a dependency can be found. The one run at startup does happen, so from the writer’s side it looks like it’s working
TUKI0113An effect that nothing runsAppears on an effect with [On(...)] when its trigger doesn’t change in any entry point
TUKI0115An attribute on a synced fieldPropagation is inserted only in the entry point that writes the value, so a value that arrives over the network doesn’t run the effect. This is a warning about the shape of the declaration, so writing a path that runs it on the receiving side doesn’t clear it. If you keep the attribute, call the same update from OnDeserialization; if you drop it, remove the attribute from the synced field and write the update yourself
TUKI0111Propagation can’t be builtAppears when the attribute was read but not one piece of change-propagating code could be placed. The effect isn’t run automatically
FormWhat happens now
A dependency cycle spanning BehavioursNot detected (it only looks inside the same Behaviour)
Using a synced field as a triggerThe value on the receiving side may not match the sender’s. The sync layer has no “changed” check