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.

Signal Type
Trigger
Listener
Get Value
Set Value
Dispose

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.

Conversion
ISignal
IReadonlySignal
ISignalListener
ISignalTrigger

ISignal

IReadonlySignal

ISignalListener

ISignalTrigger


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

While this is our recommended approach, feel free to adapt it to your specific needs. The following example demonstrates how we structure signal systems in our own projects.

Create Signal IDs by Enum(s)

public enum SignalId
{
    HP = 10,
    MP = 20,
}

please read Task Utils for details of Promise Awaiter.


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.

Exception
Description

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 System;
using System.Collections.Generic;
using AceLand.EventDriven.EventSignal;
using AceLand.Library.Extensions;
using UnityEngine;
using UnityEngine.EventSystems;
using ZLinq;

[DisallowMultipleComponent]
public sealed class TagsGroup : MonoBehaviour
{
    [Header("Tags Group")]
    [SerializeField] private Transform container;
    [SerializeField] private TagElement prefab;
    
    // record Signals and Tag Elements
    private readonly Dictionary<ISignal<string>, TagElement> tags = new ();

    private void Awake()
    {
        if (!container)
            container = transform;
        
        if (!prefab)
            Debug.LogWarning("Tags Group Control: Prefab is not set.", this);
    }

    public void BuildTags(TagData[] tagsData)
    {
        // build tags from tags data
        foreach (var tagData in tagsData)
        {
            string label = tagData.Label;
            ISignal<string> signal = BuildSignal();
            TagElement tagElement = Instantiate(prefab, container);
            tagElement .Initial(signal.AsTrigger(), label);
            tags[signal] = tagElement ;
        }
        
        // select first tag
        tags.AsValueEnumerable().First().Value.Tag.Select();
    }

    // build a Signal<string>
    // asign own id as value
    // add listener
    private ISignal<string> BuildSignal()
    {
        string id = Guid.NewGuid().ToString();
        ISignal<string> signal = Signal.Builder()
            .WithId(id)
            .WithValue(id)
            .Build();
        signal.AddListener(OnTagSelected);
        return signal.AsReadonly();
    }

    // unselect not selected tags
    // do stuffs for selected tag
    private void OnTagSelected(string id)
    {
        foreach (var (key, tagData) in tags)
        {
            if (key != id)
            {
                tagData.Tag.Unselect();
                continue;
            }
            
            // stuffs on selected tag
        }
    }

    // dispose signals
    // destroy all Tag Elements
    private void RemoveTags()
    {
        foreach (var signal in tags.Keys)
            signal.Dispose();
        
        // Library Extension
        container.DestroyAllChildren();
        tags.Clear();
    }
}

Last updated