A reactivity system for s&box. It makes writing code that runs in response to state changes easy.
public class MyComponent : ReactiveComponent
{
[Property, Reactive]
public float Speed { get; set; } = 50f;
[Reactive, Derived(nameof(_tintColor))]
public Color TintColor { get; } = Color.Blue;
private Color _tintColor()
{
return Math.Abs(Speed) <= 100f ? Color.Blue : Color.Red;
}
protected override void OnActivate()
{
Effect(() =>
{
var go = new GameObject();
var model = go.AddComponent<ModelRenderer>();
model.Model = Model.Cube;
// Tint the model whenever it changes
Effect(() => model.Tint = TintColor);
// Spin the object every frame while the component is enabled
Frame(() =>
{
var angles = go.LocalRotation.Angles();
angles.yaw += Speed * Time.Delta;
go.LocalRotation = angles;
});
// Destroy the object when the component is disabled
return () => go.Destroy();
});
}
}- Add the library to your project via the editor
- Add
global using static Sandbox.Reactivity.Reactive;somewhere in your project - usuallyAssembly.cs
You might be familiar with this paradigm if you've done any modern web frontend development within the last several years. Turns out it maps fairly well to gameplay programming. The core concepts revolve around creating reactive state that can be read by an effect to run some code when state changes.
You can use the State method to create a simple reactive value that your code can react to when it changes. The value can be read/assigned via the Value property.
var count = State(1);
count.Value++;
Log.Info($"Count is {count.Value}");
// Output: Count is 2If you want to take some existing state and transform it into a new value, you can use the Derived method to create a reactive value that uses a function to compute its current value. The value can be read using the Value property.
Whenever a reactive value that the derived function reads to compute its value changes (either a State or another Derived), it will run that function again to get a new up-to-date value and store it until the next time it computes.
var count = State(10);
var doubled = Derived(() => count.Value * 2);
Log.Info($"Doubled count is {doubled.Value}");
// Output: Doubled count is 20
count.Value = 15;
Log.Info($"Doubled count is {doubled.Value}");
// Output: Doubled count is 30Tip
You can avoid unnecessary computation by utilizing derived states. Instead of checking a reactive value directly inside an effect/derived state and branching on it directly, consider using a derived state to prevent the function from running at all unless the condition actually changes.
Derived states are computed lazily: they won't run when their values change until you actually read them. This makes it a good place to do expensive work that you only want to do if the result is actually being used.
var count = State(10);
var doubled = Derived(() => count.Value * 2);
// Derived hasn't run its compute function yet
count.Value = 15;
count.Value = 20;
// Still hasn't run
Log.Info($"Doubled count is {doubled.Value}");
// Ran only once, even though its dependency changed a few timesYou can assign a value to a derived state to override its current value. The assigned value will remain until the next time the derived state recomputes and updates its value. If the derived state recomputes to the assigned value, reactivity won't occur since nothing actually changed.
This allows for optimistically updating a derived state's value when you know what its dependencies might be in the future. For example, updating a derived state to its expected value before sending a server RPC that would eventually update it. If the server changes some value that makes the derived state compute to the same value, nothing happens. If it computes to a different value, then it will update and reactivity will occur for the correct value.
Now that you have some reactive state, you'll probably want to make something happen when it changes. This is done by using an effect. An effect is a function that tracks what reactive values were read during its execution, and run again whenever any of the values change.
Effects are created with the Effect method, and accept an Action parameter to run. When you create an effect, the given function is run immediately to check for dependencies.
var count = State(1);
Effect(() =>
{
Log.Info($"Count is {count.Value}")
});
// Output: Count is 1
count.Value++;
// Output: Count is 2An effect only runs when the reactive values read during its execution change. This means that if any reactive values that are not read due to something like returning early or being short-circuited in an if statement, they won't be added as dependencies and will not cause the effect to re-run if they change.
var shouldPrint = State(true);
var count = State(1);
Effect(() =>
{
if (!shouldPrint.Value)
{
return;
}
Log.Info($"Count is {count.Value}");
});
// Effect runs with output: Count is 1
count.Value++;
// Effect runs with output: Count is 2
shouldPrint.Value = false;
// Effect runs with no output
count.Value++;
// Effect doesn't run at all since `count` is no longer a dependencyEffect functions can optionally return an Action to run when the effect is about to re-run due to a dependency changing, or when the current reactivity scope is about to be disposed. This teardown function is a good opportunity to do some cleanup with the old values.
var shouldCreateObject = State(true);
Effect(() =>
{
if (shouldCreateObject.Value)
{
var go = new GameObject();
return () =>
{
Log.Info($"Changing from {shouldCreateObject.Value}");
go.Destroy();
};
}
// No teardown here since we didn't create the object
return null;
});
// The game object is created immediately
shouldCreateObject.Value = false;
// Output: Changing from True
// The game object has been destroyed
// Effect runs again but no game object is createdEffects can be created inside other effects. This results in the nested effect being run inside a new reactivity scope that will be disposed when the parent effect re-runs or is being disposed. Reactive values read inside a nested effect don't cause their parent effect to re-run (unless the parent also reads that value). This unlocks some pretty powerful control over what code runs when.
var showLight = State(true);
var lightColor = State(Color.White);
Effect(() =>
{
if (!showLight.Value)
{
return null;
}
var go = new GameObject();
var light = go.AddComponent<PointLight>();
Effect(() =>
{
// Changing the light color inside an effect means that the game
// object won't be re-created every time the color changes
light.Color = lightColor.Value;
});
return () => go.Destroy();
});
// A game object with a white light is created
lightColor.Value = Color.Red;
// The existing light changes to red. The parent effect doesn't re-run and will
// not unnecessarily re-create the game object
showLight.Value = false;
// The game object is destroyed
showLight.Value = true;
// A game object with a red light is created since we haven't changed the colorIf you need to read a reactive value in an effect without adding the state as a dependency, you can use the Untrack method to suppress reactivity tracking.
var count = State(1);
var otherCount = State(1);
Effect(() =>
{
Log.Info($"Count is {count.Value}");
using (Untrack())
{
// Anything inside this scope will not become a dependency if read
Log.Info($"Other count is {otherCount.Value}");
}
// Or you can give it a function to call without reactivity tracking, and return the result
var other = Untrack(() => otherCount.Value);
Log.Info($"Other count is {other}");
// This effect only has a dependency on `count`
});
// Effect runs with output: Count is 1, Other count is 1
otherCount.Value++;
// Effect does not runTip
You can check if the currently executing code is inside an effect by using the IsTracking method, allowing you to customize the behaviour of a method based on whether it's in a reactive context.
When a reactive value that an effect depends on changes, the effect is not immediately run and is instead scheduled to run at the end of the frame. This prevents unnecessary effect runs if you're changing multiple values that an effect depends on at the same time.
This is fine for most cases, but you might find yourself needing effects to run immediately after changing a value. The Flush method can be used to immediately run any effects that have been scheduled to run at the end of the frame (i.e. "flush" the reactivity system).
Note
Creating an effect always executes the function immediately in order to track its dependencies.
var count = State(1);
Effect(() =>
{
Log.Info($"Count is {count.Value}");
});
// Effect runs immediately with output: Count is 1
count.Value++;
// Effect doesn't run and is scheduled to run at the end of the frame
count.Value++;
// Effect still hasn't run; it's waiting until the end of the frame
Flush();
// Effect runs with output: Count is 3
// Effect is up-to-date and no longer scheduled to run at the end of the frameNote
Some examples in this document would require a Flush call to actually execute in order, but were omitted for brevity.
While effects are synchronous, you can still call async code within them like you would anywhere else by discarding the returned task. If your async code should only run while an effect's reactivity scope exists (i.e. while the effect hasn't been disposed), you can use the GetEffectCancelToken method to cancel your task.
This returns a cancellation token that will be cancelled when the currently executing effect is about to re-run or be disposed. When called outside of an executing effect, it will return a default cancellation token that will never cancel.
var count = State(3);
async Task MyAsyncTask(int total, CancellationToken token)
{
// Perform an async task like sending an HTTP request, or do
// things that would be more tedious to achieve with the timer
// methods like running some code X times every Y seconds, etc.
for (var i = 0; i < total; i++)
{
await Task.Delay(1000, token);
Log.Info($"hello world {i + 1}/{total}");
}
}
Effect(() =>
{
var token = GetEffectCancelToken();
_ = MyAsyncTask(count.Value, token);
});
// Effect runs and starts the async task
// ...one second later
// Effect runs with output: hello world 1/3
count.Value = 2;
// The running async task is cancelled
// Effect runs again and starts a new async task
// ...one second later
// Effect runs with output: hello world 1/2
// ...one second later
// Effect runs with output: hello world 2/2Important
You should avoid reading reactive values (or any mutable data for that matter) in async code. Always prefer copying the needed data - usually by passing a value as a parameter - to your async function. This helps to avoid nasty bugs that can occur when the data is changed between await calls.
All effects must be created inside of a reactivity scope. This is a "grouping" of reactive code that can be disposed of when it's no longer needed. If you're not using a reactive component, you can create a root reactivity scope with EffectRoot instead.
Effect roots are not reactive; they are only meant to contain other effects that can be disposed of later.
var count = State(1);
var root = EffectRoot(() =>
{
Effect(() =>
{
Log.Info($"Count is {count.Value}");
});
});
// When you no longer need the code in the effect root to run, dispose it
root.Dispose();Note
Some examples in this document omit the required effect root for brevity.
Subclassing ReactiveComponent makes it easier to create reactive properties and effects for your components.
Effects can be created by overriding the OnActivate method and using any of the reactivity methods. This method is run inside an effect root when the component is enabled, and disposed of when the component is disabled.
Use the [Reactive] attribute on your component properties to make them reactive. You can then use the properties in effects like you would any other state.
public class MyComponent : ReactiveComponent
{
[Reactive]
public string MyProperty { get; set; } = "hello";
protected override void OnActivate()
{
Effect(() =>
{
Log.Info($"Property value is {MyProperty}");
return () =>
{
// This will run just before MyProperty changes, or when the component is about to be disabled
Log.Info($"Property value changing from {MyProperty}");
};
});
}
}Properties that only have a getter can be made into a derived state using the [Derived] attribute, and specifying which method on the component to use as the computation function.
public class MyComponent : ReactiveComponent
{
[Reactive]
public int Count { get; set; } = 1;
[Reactive, Derived(nameof(_doubled))]
public int Doubled { get; } = 1;
private int _doubled()
{
return Count * 2;
}
protected override void OnActivate()
{
Effect(() =>
{
Log.Info($"Doubled is {Doubled}");
});
}
}Note
You can replace the example usages of the State and Derived methods in this document with a corresponding reactive property; they'll work the same.
Using the same reactivity attributes on static properties works as you'd expect.
public static class MySingleton
{
[Reactive]
public static int Count { get; set; } = 1;
[Reactive]
[Derived(nameof(_isEven))]
public static bool IsEven { get; }
private static bool _isEven()
{
return Count % 2 == 0;
}
}Note
Static reactive properties on generic types are not currently supported. In these cases, you can create a State or Derived backing field and corresponding property accessors.
By default, a reactive component's OnActivate method is called during the OnEnabled component lifecycle method. If your component needs to access data on another component on the same game object that is populated during OnEnabled, you can change your component's activation stage to ActivationStage.OnStart.
This changes your component's OnActivate method to be called during the OnStart component lifecycle method. The effect root will still dispose as usual if the component is disabled.
// Pass the desired activation stage to the base constructor
public class MyComponent() : ReactiveComponent(ActivationStage.OnStart)
{
protected override void OnActivate()
{
// Called during OnStart instead of OnEnabled. This is a good place
// to fetch some data from another component on the same game object
}
}Note
After activating during OnStart for the first time, any subsequent disable/re-enables will activate the component during OnEnabled since OnStart is only ever called once per component. See the sbox docs for more details.
It's likely you'll want to call some code after a delay, or at regular intervals. There are various timer methods that can help you accomplish this.
Every timer method is tied to its current reactivity scope, which means they will stop running when the scope is disposed (e.g. a parent effect is about to re-run, a parent component is being disabled, etc). A function called by a timer is a plain function that has no reactivity whatsoever - a call to a timer function is treated as a fire-and-forget operation.
Runs a function after a delay.
var shouldRun = State(true);
Effect(() =>
{
if (!shouldRun.Value)
{
return;
}
Timeout(() =>
{
Log.Info("Timeout executed after one second!");
}, TimeSpan.FromSeconds(1));
});
// Changing `shouldRun` to `false` before one second elapses prevents
// the function from ever being calledRuns a function at a regular interval.
var shouldRun = State(true);
Effect(() =>
{
if (!shouldRun.Value)
{
return;
}
Interval(() =>
{
Log.Info("Called every second!")
}, TimeSpan.FromSeconds(1));
});
// Changing `shouldRun` to `false` will stop the timer and prevent
// the enqueued interval function from ever being calledRuns a function during every fixed update (i.e. game tick). Useful when used in a reactive component.
public class MyComponent : ReactiveComponent
{
[Reactive]
public bool ShouldRun { get; set; } = true;
protected override void OnActivate()
{
Effect(() =>
{
if (!ShouldRun)
{
return;
}
Tick(() =>
{
// ...do something every fixed update
});
});
}
}
// Changing `ShouldRun` to `false` will stop the tick function from running.Runs a function during every update (i.e. frame). Useful when used in a reactive component.
public class MyComponent : ReactiveComponent
{
[Reactive]
public bool ShouldRun { get; set; } = true;
protected override void OnActivate()
{
Effect(() =>
{
if (!ShouldRun)
{
return;
}
Frame(() =>
{
// ...do something every update
});
});
}
}
// Changing `ShouldRun` to `false` will stop the frame function from running.You can run some code for every item added to a collection by using the Each method. This tracks the collection by index, meaning that reactivity occurs when an item at any index in the collection changes its value, or when the collection grows/shrinks (i.e. adds or removes an item).
Item functions are tied to the current reactivity scope, meaning they will run their teardown functions (if given) when the scope is disposed. Item functions are considered effect roots: they are not reactive on their own, but you can create effects inside them.
Important
Mutating a collection is not supported for now; reactivity occurs only when a new collection is assigned to a reactive state.
public class MyComponent : ReactiveComponent
{
[Reactive]
public Color Tint { get; set; } = Color.Blue;
[Reactive]
public Model[] Models { get; set; } = [Model.Cube, Model.Sphere];
protected override void OnActivate()
{
// Creates a game object for each model in the array
Each(
() => Models,
(model, i) =>
{
var go = new GameObject();
go.LocalPosition = new Vector3(i * 50f, 0, 0);
var renderer = go.AddComponent<ModelRenderer>();
renderer.Model = model;
// Update the model's tint every time it's changed
Effect(() => renderer.Tint = Tint);
// Destroy the game object if this index changes
return () => go.Destroy();
}
);
}
}
// Component creates 2 game objects with cube/sphere model, respectively
// Add an item
component.Models = [..component.Models, Model.Plane];
// Creates a new game object with a plane model
// Remove an item
component.Models = component.Models[..^1];
// Destroys the game object with the plane model
// Update an item
component.Models = [Model.Sphere, component.Models[1]];
// Destroys the game object with the cube model in index 0
// Creates a new game object in the first position with a sphere model in index 0
// Nothing happens for game object in index 1
component.Tint = Color.Red;
// All game objects are now tinted red without having been destroyed/re-createdYou'll most likely need to run some code in response to an external event that isn't necessarily reactive - player inputs, RPC calls, etc. This can be accomplished with scene events.
Scene events are plain objects that a reactive component can receive and do something with. There's no required type for scene events to be dispatched, but simple records are recommended.
Reactive components can opt into receiving events that their owning game object is given with the OnEvent method. Like everything else, event functions are tied to the current reactivity scope. If the owning component is disabled or a parent effect is disposed, the event function will not run even if the game object receives an event that the component is interested in.
public class MyComponent : ReactiveComponent
{
// Define a simple type that can be used as a scene event
public record PrintEvent(string Message);
[Reactive]
public Color Tint { get; set; } = Color.Blue;
[Reactive]
public Model[] Models { get; set; } = [Model.Cube, Model.Sphere];
protected override void OnActivate()
{
// Run some code whenever this event is received while the component is active
OnEvent<PrintEvent>(e =>
{
Log.Info($"Message: {e.Message}");
return false; // More on this later...
});
}
}Scene events are dispatched at the game object level. Each game object that receives a scene event will send it to all of the components directly attached to it if they have registered to do so with OnEvent. Scene events can be propagated up and down the game object hierarchy.
Components can stop propagation of a scene event to other game objects if they return true in their event functions. This does not stop other components on the same game object from receiving the event, however.
The SendUp method on a game object sends a scene event to that game object and any ancestor game objects until it either hits the root scene objects, or is stopped by a component's event function.
gameObject.SendUp(new PrintEvent("hello world"));flowchart TD
Scene(Scene)
A1(GameObject)
A2(GameObject)
B1(GameObject)
B2(GameObject)
C1(GameObject)
C2(GameObject)
Scene ~~~ A1 Link3@--x|Stopped by component on previous GameObject| Scene
Scene --- A2
A1 ~~~ B1 Link2@==> A1
B1 ~~~ A1
A1 --- B2
B1 --- C1
B1 ~~~ C2 Link1@==> B1
classDef EventObject stroke: orchid;
classDef EventLink stroke: orchid;
classDef EventStop font-size: 8pt;
class C2,B1,A1 EventObject
class Link1,Link2 EventLink
class Link3 EventStop
Link1@{ animate: true }
Link2@{ animate: true }
The SendDown method on a game object sends a scene event to that game object and any descendant game objects until there are no more objects in that branch, or is stopped by a component's event function.
gameObject.SendDown(new PrintEvent("hello world"));flowchart TD
Scene(Scene)
A1(GameObject)
A2(GameObject)
B1(GameObject)
B2(GameObject)
C1(GameObject)
C2(GameObject)
Scene Link1@==> A1
Scene Link2@==> A2
A1 Link3@==> B1
A1 Link4@==> B2
B1 Link5@--x|Stopped by component on previous GameObject| C1
B1 Link6@--x|Stopped by component on previous GameObject| C2
classDef EventObject stroke: orchid;
classDef EventLink stroke: orchid;
classDef EventStop font-size: 8pt;
class Scene,A1,A2,B1,B2 EventObject
class Link1,Link2,Link3,Link4 EventLink
class Link5,Link6 EventStop
Link1@{ animate: true }
Link2@{ animate: true }
Link3@{ animate: true }
Link4@{ animate: true }
The SendDirect method on a game object sends a scene event directly to that game object and nowhere else. Components that stop propagation have no effect (because there's nothing to propagate).
gameObject.SendDirect(new PrintEvent("hello world"));You can open up the debugger in the editor by clicking View > Reactivity Debugger. The debugger shows a tree of all active effects that can be inspected to view their current state. This includes what kind of effect it is, where it was defined, its dependencies, what reactive properties were used, what game object created it, etc.
It wouldn't be very useful if every active effect was only displayed as "Effect" in the debugger. Most effects that are created outside of your own code (i.e. internal effects) are given a name to make them easier to identify. If you'd like to assign a name to your own effects, you can use the SetEffectName method.
Effect(() =>
{
// Sets the display name of this effect to "My Effect"
SetEffectName("My Effect");
});
public class MyComponent : ReactiveComponent
{
protected override void OnActivate()
{
// Since this method is run inside of an effect root, it will overwrite
// the default "My Component" name
SetEffectName("My Cool Effect ๐");
}
}Note
Effect names do not exist in non-debug builds. As such, calls to SetEffectName are removed in non-debug builds and you don't need to remove them or wrap them in #if DEBUG compilation conditions.
Reactive values created with the State or Derived methods can also be named by passing it as a parameter. Reactive values created by reactive properties will automatically inherit all the display information from the property.
// Shows up as "My State" when displayed as an effect dependency
var myState = State(false, "My State");
public class MyComponent : ReactiveComponent
{
// Shows up as "My Reactive Property" when displayed as
// an effect dependency
[Reactive]
public bool MyReactiveProperty { get; set; } = true;
}It's possible for effects to re-run indefinitely if they modify one of their own dependencies (i.e. reading and writing to the same reactive value). When more than 1000 effects run during a flush, it's assumed that an infinite loop is occurring and an exception will be thrown.
The error that's logged to the console can be clicked to inspect the list of effects that ran before the infinite loop was broken.