Signal
Data-oriented signal system
Overview
The system provides a robust foundation for type-safe, event-driven communication between components while maintaining clean memory management and thread safety.
Project Settings
Signal Getter Timeout
Getter will be retry automatically until timeout. default: 1.5s
Prewarm Providers
Build signals after assembly ready.
Why Use It
Consider a UI component that displays a player's life: traditionally, this would require direct references to the Player or Life components. However, such tight coupling makes the UI component less reusable and more difficult to maintain. With Signal, the UI component simply subscribes to life-related signals, making it project-agnostic and highly reusable.
While there are numerous dependency injection and reference management solutions available, many of these packages introduce unnecessary complexity. Unlike engines such as Godot that have built-in signal systems, Unity's traditional GameObject-Component architecture doesn't include a native signal system. However, Unity's flexible architecture allows us to implement custom solutions, which is where this Signal system comes in.
Signals can be created and accessed from anywhere in your codebase, enabling flexible and decoupled system design. This flexibility does come with a responsibility: while powerful, signals should be used thoughtfully and systematically to avoid creating complex, hard-to-debug signal chains.
Key Benefits:
Decoupled component communication
Improved code reusability
Lightweight implementation
Flexible system design
API
// base API
Signal
// Signal Builder
Signal.Builder().ActionsChain();
// Signal Getter
ISignal signal = Signal.Get("Signal ID");
ISignal<T> signal = Signal<T>.Get(Enum.SignalId);
IReadonlySignal<T> signal = Signal<T>.GetAsReadonly(Enum.SignalId);
ISignalListener<T> signal = Signal<T>.GetAsListener(Enum.SignalId);
ISignalTrigger<T> signal = Signal<T>.GetAsTrigger(Enum.SignalId);
// Signal Getter Async
// see sample and Package Task Utils
Signal.GetAsync("Signal ID");
Signal.GetAsReadonlyAsync("Signal ID");
Signal.GetAsListenerAsync("Signal ID");
Signal.GetAsTriggerAsync("Signal ID");How to Use
Signal provides a robust event-driven value management system with built-in observer pattern support and type safety.
// create a signal using builder pattern
ISignal signal = Signal.Builder()
.WithId(MyEnum.Id)
.Build();
// create a signal with value
ISignal<int> signal = Signal.Builder()
.WithId("PlayerLife")
.WithValue(100) // optional
.Build();
// create a signal and force to get readonly
ISignal<float> signal = Signal.Builder()
.WithId(MyEnum.Id)
.WithValue(50f) // optional
.BuildReadonly(); // force observers get readonly
// add or remove Listener
signal.AddListener(OnLifeChanged);
signal.RemoveListener(OnLifeChanged);
void OnLifeChanged(int life) {}
// modify value
// this will auto trigger event
signal.Value = 75;
// trigger event without value changing
lifeSignal.Trigger();
// get an exist signal
ISignal deathSignal = Signal.Get("PlayerDead");
ISignal<int> lifeSignal = Signal<int>.Get("PlayerLife");
IReadonlySignal<int> readonlyLife = Signal<int>.GetAsReadonly("PlayerLife");
ISignalListener<int> lifeListener = Signal<int>.GetAsListener("PlayerLife");
ISignalTrigger<int> lifeTrigger = Signal<int>.GetAsTrigger("PlayerLife");
// get an signal but not confirm wheather created yet
ISignal deathSignal = await Signal.GetAsync("PlayerDead");
ISignal<int> lifeSignal = await Signal<int>.GetAsync("PlayerLife");
IReadonlySignal<int> readonlyLife = await Signal<int>.GetAsReadonlyAsync("PlayerLife");
ISignalListener<int> readonlyLife = await Signal<int>.GetAsListenerAsync("PlayerLife");
ISignalTrigger<int> readonlyLife = await Signal<int>.GetAsTriggerAsync("PlayerLife");
// turn a Signal<T> to ReadonlySignal<T>
// not revertable
// allowed: get value, add or remove listener, trigger event
IReadonlySignal<int> lifeReadonlySignal = lifeSignal.AsReadonly()
// turn a Signal or Signal<T> to SignalListerner or SignalListerner<T>
// not revertable
// allowed: add or remove listener, trigger event
ISignalLister deathSignalListener = deathSignal.AsListener();
ISignalLister<int> lifeSignalListener = lifeSignal.AsListener();
// turn a Signal or Signal<T> to SignalTrigger
// not revertable
// allowed: trigger event
ISignalTrigger deathSignalListener = deathSignal.AsTrigger();
ISignalTrigger<int> lifeSignalListener = lifeSignal.AsTrigger();
// dispose a signal
signal.Dispose();Key Features:
Builder Pattern
Fluent interface for signal creation
Optional ID assignment (string or enum)
Initial value setting
Readonly flag for observer protection
Value Management
Type-safe value storage and modification
Automatic listener notification on value changes
Implicit conversion to value type
Value comparison support
Observer System
Add/remove listeners dynamically
Optional immediate execution on listener addition
Thread-safe observer notifications
Protection against null listeners
Signal Registry
Global signal management
Async getter with timeout protection
Type validation
Automatic cleanup through disposal pattern
Signal Permissions
Different type of signal defines the permissions. Select the correct signal for observers.
ISignal<T>
ISignal
IReadonlySignal<T>
ISignalListener<T>
ISignalListener
ISignalTrigger<T>
ISignalTrigger
Type Conversion
Conversion between different signal type is possible. However conversion from ISignal to other types is inversable.
ISignal
IReadonlySignal
ISignalListener
ISignalTrigger
Recommended Usage
In any system, regardless of its size, proper signal management is crucial for maintaining code clarity and preventing runtime issues. Even in small applications, you'll likely find yourself handling dozens of signals across multiple objects, making a well-organized structure essential.
Below is our recommended approach to signal management, which we use in production. This pattern offers several advantages:
Type Safety: Using enums for signal IDs prevents typos and enables IDE auto-completion
State Management: Integration with Promise/Task utilities ensures safe asynchronous operations and prevents common runtime issues like timing problems during initialization
Code Organization: Clear structural patterns make the system easy to maintain and scale
Create Signal IDs by Enum(s)
public enum SignalId
{
HP = 10,
MP = 20,
}Build the Player Life component
using AceLand.EventDriven.EventSignal;
using UnityEngine;
public class PlayerLife : MonoBehaviour
{
[SerializeField] private int hp;
[SerializeField] private int mp;
private Signal<int> hpSignal;
private Signal<int> mpSignal;
private void Start()
{
// build signals of states
// force observer get readonly
hpSignal = Signal.Builder()
.WithId(SignalId.HP)
.WithValue(hp)
.BuildReadonly();
mpSignal = Signal.Builder()
.WithId(SignalId.MP)
.WithValue(mp)
.BuildReadonly();
}
private void OnDestroy()
{
hpSignal?.Dispose();
mpSignal?.Dispose();
}
}Dynamic method to create an UI Text Updater with signal.
using AceLand.EventDriven.EventSignal;
using AceLand.TaskUtils;
using TMPro;
using UnityEngine;
// Generic Signal Type can fulfill any type of Signal
[RequireComponent(typeof(TextMeshProUGUI))]
public class UiTextUpdateWithSignal : UIBehaviour
{
// Select Signal ID in Inspector.
[SerializeField] private SignalId signalId;
// require only listener
private ISignalListener<int> _signal;
private TextMeshProUGUI _label;
protected override void Awake()
{
InitialComponents();
InitialSignal();
}
protected override void OnEnable()
{
_signal?.AddListener(OnSignalUpdate, true);
}
protected override void OnDisable()
{
_signal?.RemoveListener(OnSignalUpdate);
}
private void InitialComponents()
{
_label = GetComponent<TextMeshProUGUI>();
}
private void InitialSignal()
{
// Get Readonly Signal with Promise Awaiter
Signal<T>.GetAsListenerAsync(signalId)
.Then(signal =>
{
signal.AddListener(OnSignalUpdate, true);
_signal = signal;
})
.Catch(e => Debug.LogError(e, this));
}
private void OnSignalUpdate(int value)
{
_label.text = ValueToStringProvider(value);
}
// custom label display by override Provider.
protected virtual string ValueToStringProvider(int value)
{
return value.ToString();
}
}Prewarm Provider
Signals almost use on creating in runtime, and the lifetime should be same as GameObject or Scene. On the case of long-term signals, creating prewarm providers is the best options.
Prewarm Provider is a scriptable object. System will build the signal after assembly ready state. When the scene starts to load, all signals are already ready. This can also prevent from GetAsync process.
using AceLand.EventDriven.Profiles;
using AceLand.Test;
using UnityEngine;
[CreateAssetMenu(fileName = "Signal Prewarm", menuName = "Profiles/Signal Prewarm")]
public class SignalPrewarm : SignalPrewarmProvider<SignalId, int>
{
public override void PrewarmSignal()
{
base.PrewarmSignal();
}
public override void Dispose()
{
base.Dispose();
}
}
public enum SignalId
{
HP, MP, SP
}
Exceptions
On GetAsync or GetReadonlyAsync function, exceptions will be returned on errors.
SignalNotFoundException
When signal is not found with given ID
SignalTypeErrorException
When signal with wrong given value type - only on Signal with value
SignalReadonlyAlertException
When signal is built as Readonly but using GetAsync - only on Signal with value
Signal<int>.GetAsync("ID")
.Then(OnGetSignal)
.Catch<SignalNotFoundException>(OnSignalNotFound)
.Catch<SignalTypeErrorException>(OnSignalTypeError)
.Catch<SignalReadonlyAlertException>(OnSignalReadonly)
.Catch(Debug.LogError);Advanced Use
An example of runtime instantiated a set of UI Elements and a control of Element Group.
Element is Tag Element. Tags will be a button and will be instantiated different amount in runtime.
Group is Tags Group for instantiating and controlling all tag elements.
Tag Element is controlling a single Tag. By received a pointer click to trigger a tag selection for Tags Group to do further actions.
Tag Element should control the UI behaviour only. UI Behaviour should not contains business logics. Tags Group contains business logics and wait for UI behaviours - on pointer clicked event.
In this case, when Tags Group instantiates a Tag Element, it will build a new Signal with signal id as value and record in tag elements collection. This signal will pass to Tag Element and will be triggered on pointer clicked.
using AceLand.EventDriven.EventSignal;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
[DisallowMultipleComponent]
// us UIBehaviour for UI Element
public sealed class TagElement : UIBehaviour, IPointerClickHandler
{
[SerializeField] private TextMeshProUGUI label;
private bool selected;
private ISignalTrigger<string> onSelectSignal;
protected override void Awake()
{
if (label) return;
Debug.LogWarning("Tag Control: TextMesh not found.", this);
enabled = false;
}
// initial tag with Signal from Tags Group
public void Initial(ISignalTrigger<string> selectSignal, string tagName)
{
if (!label) return;
onSelectSignal = selectSignal;
label.text = string.IsNullOrEmpty(tagName) ? "Anonymous" : tagName;
}
// handle pointer clicked event
public void OnPointerClick(PointerEventData eventData)
{
if (!label || selected) return;
Select();
}
// trigger signal
public void Select()
{
onSelectSignal?.Trigger();
}
// stuffs for unselect
public void Unselect()
{
// stuffs
}
}
Last updated