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]FieldThe value whose changes are followed. Assign it only from a method of the class writing its own field (reading it from outside is fine)
[Computed]PropertiesA value derived from other values. It is not stored: the getter is called again each time it is read (also when deciding whether to run an effect)
[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;
}
}
An effect with no names, and [Computed]The dependencies are the [Reactive] fields and [Computed] properties of this class that the body reads directly. A value read inside a method it calls is not a dependency
Values of another instance or another classNot dependencies (next.count, for example). The effect does not run when such a value changes, so warning TUKI0119 is given
An effect with namesThe names written are the whole list of dependencies. A value the body reads but that is not named does not run the effect when it changes, and warning TUKI0119 names it. Add it to the names, or remove all the names and let them be inferred
Names you can writeA [Reactive] field or a [Computed] property of this class. Any other name (including the name of another effect) is TUKI0133
Calls of an [Effect] on a classThe method it points to may be public. With no names written, the dependencies are the values that method reads directly. When two or more attributes point to the same method, all the names written are watched
TypesCheck
Numbers, booleans, enums you declared, and stringsCompared directly
A float holding NaNCompared with !=, so it counts as changed every time an entry point that can write the field ends, whether or not it was written this time
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
Structs and tuples you declaredCompared member by member with the rules in this table. If any member changed, the effect runs once. Assigning a member (cell.count = 1, cell.count++) and assigning the whole value (cell = new Cell()) both count as writes to the field
ColorBlockCompared with its own ==, which treats colors within a very small tolerance as equal, so a very small change to one of its colors does not count as a change
Quaternion and ColorComparing components goes through float’s Equals, so NaN is considered equal to NaN. Writing NaN again to a field that holds NaN does not count as a change (Vector2, Vector3, Vector4, Bounds and Matrix4x4 count it as a change, like a float)
End of the entry pointWhen an entry point that wrote the field ends, the effect runs once if the value differs from what it was when the entry point started. Writing the same value again runs nothing, and writing it several times runs it once. The same holds when the write is inside a private method the entry point calls
A call to another public method of the same classThe called method’s body runs as is, so the effect runs once, at the end of the outer entry point
What counts as a writeBesides assignment, calling a method that changes a struct field (tally.Grow()), setting an indexer or a property of this class, and calling a base method with base. are writes too
Writes from an effectAn effect may write a [Reactive] field. It is the same whether the write is in the body or inside a method it calls, a property setter, an indexer, a base. call, an out or ref argument, or a method that changes a struct field: the effects that read the value run after it in the same update. Writes that go round in a loop are TUKI0112
A public [Effect]Called by the compiler as an effect, it runs like any other effect; called from outside, it is an entry point and runs the effects once at its end
Entry points the compiler addsThe entry point named _onVarChange_ followed by the field name, which VRChat runs when a [FieldChangeCallback] field receives a value through SetProgramVariable or sync (it calls the setter), the entry point that runs the rest of an async FrameTask method after an await, and the setter of a property that another Behaviour assigns all run effects the same way as any other entry point. When a value arrives for a synced field, the effects do not run inside _onVarChange_ (Values that arrive by sync)
SetProgramVariable after writing the field yourselfSetProgramVariable runs the _onVarChange_ entry point on the spot, so the effect runs once inside it and once more at the end of the calling entry point (the same as calling SendCustomEvent on yourself)
Seeing where effects runThe sites of facts.reactive.firing in inspect list the entry points that run the effect. Entry points the compiler added carry a via that says which code they run and when
Test methodsA test method is an entry point too (Testing)
Order of a deliveryVRChat writes the synced fields of one delivery one at a time, runs each field’s [FieldChangeCallback] as soon as that field is written, and runs OnDeserialization after the last one. Inside a setter, the other synced fields can still hold their old values
Running the effectsWhen a value arrives for a field whose setter writes a [Reactive] field, the effects run once, at the end of the delivery (the end of OnDeserialization), and never see the half-written state. An effect that reads two of the arrived fields also runs once and sees both new values
An OnDeserialization you wroteIts body is part of the same update, and the effects run after it. Inside the body the arrived values are already written, but their effects have not run yet. If you did not write OnDeserialization, T# adds one
[On]Gets the values from before the delivery as its old values. It fires on the first delivery too
Telling it apart from SetProgramVariableBoth run the same [FieldChangeCallback], so they are told apart by ownership (the owner is the one that sends the values, so it does not normally receive them). On the owner’s machine, a write by SetProgramVariable runs the effects there and then, at the end of _onVarChange_. On a machine that does not own the object, it is treated like an arriving value, and the effects run at the end of the next OnDeserialization, or one frame later if none comes first
Values restored from storage (isFromStorage)They arrive on the owner’s machine, so they are treated as writes. Each [FieldChangeCallback] runs the effects at its own end
Circular dependenciesA cycle within the same Behaviour is a compile-time error. Writing back to your own trigger (also through a method you call or a setter) 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;
}
Writes that are errorsAssigning a [Reactive] field from another class, assigning it on another instance of the same class (next.count = 1), and writing it by name with SetProgramVariable (also when the class writes its own field that way) are TUKI0134, because only the value would change and the effects would not run
How to fix itWrite a method in that class that assigns the field, and call it from outside (counter.Bump() or counter.SendCustomEvent("Bump"))
A field that also has [FieldChangeCallback]A write by name goes through _onVarChange_ and the setter, so the effects run and it is not an error (also when the name is passed in a variable). An assignment from another class or on another instance does not go through the setter and is still an error
SyntaxNumberHow to fix it
[Effect] and [On] on the same methodTUKI0137Keep one of them ([On] for something that happens on the change itself, [Effect] to apply the current state)
The attribute on an event that Unity or VRChat calls (Start, Update, Interact and the like)TUKI0137The compiler would call that event itself. Move the code into a private method, put the attribute on that method, and call it from the event
The attribute on a virtual, abstract or override methodTUKI0137Make the marked method a private method that is not virtual. Code that a derived class should change goes in a virtual method without the attribute, called from it
The attribute on a method that returns a valueTUKI0137Make it return void. If the value is needed, compute it in a separate method or a [Computed] property
[Effect] on a method that takes parametersTUKI0137Remove the parameters and read the fields directly. If the value from just before the change is needed, use [On(nameof(...))]
Assigning a [Reactive] field inside an [On] method (also inside a method or property setter it calls)TUKI0137[On] runs after [Effect], so an effect that reads that field does not run again for this change. If the value follows from other values, make it [Computed]; if it has to be stored, assign it inside an [Effect]
[On] parameters that do not match its triggersTUKI0137Write no parameters, or write them all in the same order and types as the triggers
A method of one of the shapes above that Calls of an [Effect] on a class points toTUKI0137Point to a method with no parameters and no return value that is neither an event nor virtual
[On] with no nameTUKI0133Write the names of the fields or properties whose change should run it
A name in [On] or [Effect] that is neither a [Reactive] field nor a [Computed] propertyTUKI0133If it is a field, mark it [Reactive]; otherwise correct the name
A Calls in an [Effect] on a class that names no method of that class (including a missing Calls)TUKI0133Name a method of the same class, as in Calls = nameof(Show)
An attribute outside a Behaviour (on a member of a struct, a record or another class, on a local function, or on an accessor)TUKI0133Put the attributes only on the Behaviour’s own fields, properties and methods
using Tsukimi;
public class Bell : TsukimiBehaviour
{
[Reactive] private int rings;
// This errors. [On] runs only on a change and [Effect] also runs at startup, so keep one of them.
[Effect, On(nameof(rings))]
private void Ring() { }
public override void Interact() { rings = rings + 1; }
}

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 does not run again when a value it reads changesGiven for an effect marked [Effect] (or an effect that reads a [Computed]) in any of these cases: no entry point that writes its dependencies is found (it runs once at startup, so to its author it looks like it works); it reads a value of another instance or another class; or an effect with names reads a value it does not name. The message says which
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 arriving over the network doesn’t run the effect. The warning is about how the declaration is written, so calling the same update from OnDeserialization doesn’t clear it. Give the field a [FieldChangeCallback] whose setter assigns the field, and an arriving value goes through the setter, the effect runs, and the warning is not given. Otherwise, keep the attribute and call the same update from OnDeserialization, or 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 (a [Reactive] of a type that cannot be compared, for example; see the table under Forms that don’t compile). The effects of that class aren’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 triggerAn arriving value doesn’t run the effect, so the values the effect sets on the receiving side can differ from the sender’s (TUKI0115). Give the field a [FieldChangeCallback] whose setter assigns the field, and an arriving value runs the effect too
[Reactive] of a type that cannot be compared (an array, DataList, DataDictionary, or a runtime enum such as KeyCode), and [Reactive] of a struct with a member of such a typeNo propagation is placed, and the effects of that class aren’t run automatically. A TUKI0111 warning names the field or member