A reactive signals library for Unity that provides observable values with automatic change notifications.
- Value Signals: Observable primitive and struct types with equality-based change detection
- Reference Signals: Observable reference types with reference equality checking
- Computed Signals: Automatically recalculate based on dependencies
- Transactions: Batch multiple signal updates with a single notification wave
- Multiple Observer Types: Support for delegates, actions, and observer objects
- Type Safety: Strongly-typed signal values
- Memory Management: Proper disposal and dead dependency tracking
// Value signals for primitives
var health = new IntegerValueSignal(100);
var speed = new FloatValueSignal(5.0f);
var playerName = new StringValueSignal("Player");
// Reference signals for objects
var currentWeapon = new ReferenceSignal<Weapon>();// Using delegates
health.AddObserver((sender, oldValue, newValue) => {
Debug.Log($"Health changed from {oldValue} to {newValue}");
});
// Using actions (new value only)
health.AddObserver((newValue) => {
UpdateHealthBar(newValue);
});
// Using events
health.SignalValueChanged += OnHealthChanged;health.Value = 75; // Using property
health.SetValue(75); // Using methodvar maxHealth = new IntegerValueSignal(100);
var currentHealth = new IntegerValueSignal(80);
var healthPercentage = new ComputedSignal<float>(() =>
(float)currentHealth.Value / maxHealth.Value
);
// Automatically recalculates when dependencies change
currentHealth.Value = 50;
Debug.Log(healthPercentage.Value); // 0.5A transaction holds back change notifications while you update several signals, then delivers them once when the scope ends. Every signal change inside the scope counts, whether it comes from your code, a method you call, or a collection mutation.
using (SignalTransaction.Begin())
{
position.Value = new Vector3(10, 0, 5);
rotation.Value = 45f;
inventory.Add(sword);
} // Observers are notified here- Values change immediately: reading a signal inside the scope returns its new value, and computed signals recalculate on read.
- Notifications are deferred: each changed signal notifies its observers once when the outermost scope ends, in the order the signals were first changed.
- Original old values: observers receive the value from before the transaction and the final value.
- No-op changes are skipped: a signal that ends where it started (100 → 50 → 100) does not notify.
- Collection events replay:
ItemAdded,ItemRemoved,ItemReplaced,ClearedandCollectionChangedare replayed in their original order, followed by one signal notification.
var score = new IntegerValueSignal(0);
score.AddObserver((sender, oldValue, newValue) => {
Debug.Log($"Score: {oldValue} -> {newValue}");
});
using (SignalTransaction.Begin())
{
score.Value = 10;
score.Value = 20;
score.Value = 30;
Debug.Log(score.Value); // Prints: 30
}
// Output: "Score: 0 -> 30"- Nested scopes merge: an inner
Begin()does nothing extra; everything is delivered when the outermost scope ends. - Cascades are queued: if an observer changes another signal during delivery, that change is delivered after the current wave finishes rather than interrupting it. Delivery gives up with an
InvalidOperationExceptionafterSignalTransaction.MaxDeliveryWaveswaves, which usually means observers are changing each other in a cycle. - Throwing observers don't block others: every pending notification is still delivered, then the exception is rethrown (an
AggregateExceptionif several observers threw). - No rollback: if the scope exits with an exception, the values stay changed and notifications are still delivered.
- Disposed signals are skipped: a signal disposed before the scope ends does not notify.
ObservableList.AddRange and ReplaceAll run inside their own transaction, so signal observers are notified once per call.
var firstName = new StringValueSignal("John");
var lastName = new StringValueSignal("Doe");
var fullName = new ComputedSignal<string>(() =>
$"{firstName.Value} {lastName.Value}"
);
fullName.AddObserver((name) => Debug.Log($"Full name: {name}"));
using (SignalTransaction.Begin())
{
firstName.Value = "Bob";
Debug.Log(fullName.Value); // "Bob Doe"
lastName.Value = "Johnson";
} // Triggers once: "Full name: Bob Johnson"// Remove specific observer
void OnHealthChanged(int newValue) { }
health.AddObserver(OnHealthChanged);
health.RemoveObserver(OnHealthChanged);
// Clear all observers
health.ClearObservers();// Properly dispose signals when done
health.Dispose();
// Disposed signals mark themselves as dead and clear observersBooleanValueSignalIntegerValueSignalFloatValueSignalDoubleValueSignalLongValueSignalByteValueSignalDecimalValueSignalCharValueSignalStringValueSignal
Vector2ValueSignalVector3ValueSignal
ValueSignal<T>- For any value typeReferenceSignal<T>- For any reference typeComputedSignal<T>- For derived values