Priority events

Patterns, Performance, And API

Use priority-event recipes, metrics, pitfalls, performance notes, defaults, and quick reference.

  • Patterns
  • Metrics
  • Pitfalls
  • API reference

Use this page for recipes, pitfalls, capacity tuning, metrics, and signatures after the priority model is clear.

Capacity and Memory Management

Each priority event exposes:

int count = evt.Count;
int capacity = evt.Capacity;
bool hasSubscribers = evt.HasSubscribers;

evt.EnsureCapacity(128);
evt.TrimExcess();
evt.Clear();

EnsureCapacity

EnsureCapacity(capacity) reserves capacity in every priority slot.

CombatBus.Tick.EnsureCapacity(256);

Use it when you know the expected subscriber count and want to reserve storage before gameplay dispatch.

TrimExcess

TrimExcess asks all slots to compact their storage.

CombatBus.Tick.TrimExcess();

Unlike plain AzEvent.TrimExcess, the current priority slot implementation does not intentionally reassign all live token identities during trim. Still, treat trim as a maintenance operation, not a per-frame habit.

Clear

Clear removes all subscriptions from all 9 slots.

CombatBus.Tick.Clear();

Use it only when the event owner is resetting the whole event. Component owners should normally dispose their own tokens.

Metrics

Priority events implement IAzEventMetricsProvider and aggregate metrics across all 9 slots.

AzEventMetrics metrics = CombatBus.Tick.GetMetrics();

Debug.Log(metrics.ToString());
Debug.Log($"Total subscribers: {metrics.TotalSubscribers}");
Debug.Log($"Active subscribers: {metrics.ActiveSubscribers}");
Debug.Log($"Paused subscribers: {metrics.PausedSubscribers}");
Debug.Log($"Capacity: {metrics.Capacity}");
Debug.Log($"Fragmentation: {metrics.Fragmentation:P1}");
Debug.Log($"Invocations: {metrics.TotalInvocations}");
Debug.Log($"Listeners called: {metrics.TotalListenersCalled}");
Debug.Log($"Exceptions: {metrics.TotalExceptions}");

Reset all metrics:

CombatBus.Tick.ResetMetrics();

Reset runtime counters while keeping structural state:

CombatBus.Tick.ResetRuntimeMetrics();

Metrics are useful for spotting:

  • too many subscribers
  • paused subscriptions that were never resumed
  • fragmentation after high churn
  • exception-heavy event chains
  • excessive off-thread operation processing

Common Patterns

Ordered Gameplay Phases

public static readonly AzPriorityEvent<CombatContext> CombatResolved = new();

private void OnEnable()
{
    CombatBus.CombatResolved.Subscribe(Validate, slot: 1).AddTo(_subscriptions);
    CombatBus.CombatResolved.Subscribe(ApplyDamage, slot: 4).AddTo(_subscriptions);
    CombatBus.CombatResolved.Subscribe(UpdateUi, slot: 8).AddTo(_subscriptions);
    CombatBus.CombatResolved.Subscribe(RecordAnalytics, slot: 9).AddTo(_subscriptions);
}

Early-Only Dispatch

CombatBus.Tick.InvokeSlots(AzPriorityMasks.UpTo(3));

Late-Only Dispatch

CombatBus.Tick.InvokeSlots(AzPriorityMasks.From(7));

Debugging Handler Failures

CombatBus.Tick.ExceptionMode = AzExceptionMode.Isolate;

AzInvokeResult result = CombatBus.Tick.InvokeSafe();

if (result.HasExceptions)
{
    Debug.LogError(result.ToDetailedString(includeStackTraces: true));
}

Pooled Listener

private readonly AzEventTokenBag _subscriptions = new();

private void Awake()
{
    CombatBus.Tick.Subscribe(OnTick, slot: 5).AddTo(_subscriptions);
}

private void OnEnable()
{
    _subscriptions.ResumeAll();
}

private void OnDisable()
{
    _subscriptions.PauseAll();
}

private void OnDestroy()
{
    _subscriptions.Dispose();
}

Common Pitfalls

Using Add When You Need Cleanup

Bad for MonoBehaviours:

private void OnEnable()
{
    CombatBus.Tick.Add(OnTick, slot: 5);
}

Better:

private void OnEnable()
{
    CombatBus.Tick.Subscribe(OnTick, slot: 5).AddTo(_subscriptions);
}

private void OnDisable()
{
    _subscriptions.DisposeAll();
}

Depending on Same-Slot Order

Bad:

evt.Subscribe(Prepare, slot: 5);
evt.Subscribe(ConsumePreparation, slot: 5);

Better:

evt.Subscribe(Prepare, slot: 4);
evt.Subscribe(ConsumePreparation, slot: 5);

Forgetting That Slot Values Are Strict

Slots are 1..9. Slot 0 and slot 10 throw.

evt.Subscribe(Handler, slot: 0);  // invalid
evt.Subscribe(Handler, slot: 10); // invalid

Storing an Off-Thread Subscribe Token

Off-thread Subscribe returns AzEventToken.Invalid.

Bad:

Task.Run(() =>
{
    CombatBus.Tick.Subscribe(OnTick, slot: 5).AddTo(_subscriptions);
});

Better:

private void OnEnable()
{
    CombatBus.Tick.Subscribe(OnTick, slot: 5).AddTo(_subscriptions);
}

Or, if off-thread queuing is intentional, do not expect a usable token from that call.

Invoking from a Worker Thread

Subscription operations can be queued from worker threads. Invocation should be main-thread owned.

Bad:

Task.Run(() => CombatBus.Tick.Invoke());

Better:

private void Update()
{
    CombatBus.Tick.Invoke();
}

Using InvokeFast With Untrusted Handlers

InvokeFast is for trusted hot paths. If handler exceptions need to be isolated or reported through AzInvokeResult, use InvokeSafe.

// Trusted hot path
evt.InvokeFast();

// Diagnostic or untrusted path
AzInvokeResult result = evt.InvokeSafe();

Overusing InvokeSafe in Hot Paths

InvokeSafe is valuable, but it is not the first choice for high-frequency trusted events.

// Diagnostics
AzInvokeResult result = evt.InvokeSafe();

// Hot path
evt.InvokeFast();

Calling Dispose on a Reusable Bag in OnDisable

Bad:

private void OnDisable()
{
    _subscriptions.Dispose();
}

Good:

private void OnDisable()
{
    _subscriptions.DisposeAll();
}

Use Dispose() when the bag itself is done forever.

Performance Notes

The priority backend is designed for zero-allocation hot invocation after warmup.

Good defaults:

  1. Create events up front.
  2. Subscribe during setup or lifecycle transitions.
  3. Use slot 5 for normal priority.
  4. Use lower slots only when earlier execution matters.
  5. Use higher slots for observers and late reactions.
  6. Use Invoke as the normal all-slot path when handlers are trusted.
  7. Use InvokeFast for measured hot paths with trusted handlers.
  8. Use InvokeSafe for diagnostics, validation, and exception isolation.
  9. Use token bags for lifecycle cleanup.
  10. Use pause/resume for pooled objects.
  11. Avoid per-frame subscribe/dispose churn.

Selective invocation can be cheaper than invoking all slots when only some phases need to run:

evt.InvokeSlots(AzPriorityMasks.UpTo(3));

Use masks for fixed slot groups. Use InvokeWhere only when you need dynamic predicate logic.

API Quick Reference

AzPriorityMasks

public const ushort Slot1;
public const ushort Slot2;
public const ushort Slot3;
public const ushort Slot4;
public const ushort Slot5;
public const ushort Slot6;
public const ushort Slot7;
public const ushort Slot8;
public const ushort Slot9;
public const ushort OddSlots;
public const ushort EvenSlots;
public const ushort None;
public const ushort All;

public static ushort Build(int start, int end);
public static ushort UpTo(int end);
public static ushort From(int start);
public static bool ContainsSlot(ushort mask, int slot);
public static ushort Combine(params ushort[] masks);

AzPriorityEvent

public AzExceptionMode ExceptionMode { get; set; }

public int Count { get; }
public int Capacity { get; }
public bool HasSubscribers { get; }

public void EnsureCapacity(int capacity);
public void Clear();
public void TrimExcess(int minCapacity = 0);

public void Add(Action h);
public void Add(Action h, byte slot);
public bool AddUnique(Action h);
public bool AddUnique(Action h, byte slot);

public AzEventToken Subscribe(Action h);
public AzEventToken Subscribe(Action h, byte slot);
public IDisposable SubscribeUnmanaged(Action h, byte slot = 5);

public bool Remove(in AzEventToken tok);
public bool Pause(in AzEventToken tok);
public bool Resume(in AzEventToken tok);

public void Invoke(AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public void InvokeFast(AzCancelMode cancelMode = AzCancelMode.Soft, CancellationToken ct = default);
public AzInvokeResult InvokeSafe(AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public void Invoke(out AzCancelInfo cancelInfo, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public AzInvokeResult InvokeSafe(out AzCancelInfo cancelInfo, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public void InvokeSlots(ushort slotMask, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public AzInvokeResult InvokeSafeSlots(ushort slotMask, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public void InvokePriorityAtLeast(byte minPriority, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public AzInvokeResult InvokeSafePriorityAtLeast(byte minPriority, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public void InvokePriorityAtMost(byte maxPriority, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public AzInvokeResult InvokeSafePriorityAtMost(byte maxPriority, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public void InvokeSlot(byte slot, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public AzInvokeResult InvokeSafeSlot(byte slot, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public void InvokeOnly(ReadOnlySpan<byte> slots, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public AzInvokeResult InvokeSafeOnly(ReadOnlySpan<byte> slots, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public void InvokeWhere(Func<byte, bool> slotPredicate, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public AzInvokeResult InvokeSafeWhere(Func<byte, bool> slotPredicate, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public AzEventMetrics GetMetrics();
public void ResetMetrics();
public void ResetRuntimeMetrics();

AzPriorityEvent<T>

public AzExceptionMode ExceptionMode { get; set; }

public int Count { get; }
public int Capacity { get; }
public bool HasSubscribers { get; }

public void EnsureCapacity(int capacity);
public void Clear();
public void TrimExcess(int minCapacity = 0);

public void Add(Action<T> h);
public void Add(Action<T> h, byte slot);
public bool AddUnique(Action<T> h);
public bool AddUnique(Action<T> h, byte slot);

public AzEventToken Subscribe(Action<T> h);
public AzEventToken Subscribe(Action<T> h, byte slot);
public void Subscribe(Action<T> h, out AzEventToken tok, byte slot = 5);
public IDisposable SubscribeUnmanaged(Action<T> h, byte slot = 5);
public void SubscribeUnmanaged(Action<T> h, out IDisposable d, byte slot = 5);

public bool Remove(in AzEventToken tok);
public bool Pause(in AzEventToken tok);
public bool Resume(in AzEventToken tok);

public void Invoke(T arg, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public void InvokeFast(T arg, AzCancelMode cancelMode = AzCancelMode.Soft, CancellationToken ct = default);
public AzInvokeResult InvokeSafe(T arg, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public void Invoke(T arg, out AzCancelInfo cancelInfo, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public AzInvokeResult InvokeSafe(T arg, out AzCancelInfo cancelInfo, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public void InvokeSlots(ushort slotMask, T arg, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public AzInvokeResult InvokeSafeSlots(ushort slotMask, T arg, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public void InvokeSlot(byte slot, T arg, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public AzInvokeResult InvokeSafeSlot(byte slot, T arg, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public AzEventMetrics GetMetrics();
public void ResetMetrics();
public void ResetRuntimeMetrics();

AzPriorityEventRef<T>

public sealed class AzPriorityEventRef<T>;

public AzExceptionMode ExceptionMode { get; set; }

public int Count { get; }
public int Capacity { get; }
public bool HasSubscribers { get; }

public void EnsureCapacity(int capacity);
public void Clear();
public void TrimExcess(int minCapacity = 0);

public void Add(AzRefHandler<T> h);
public void Add(AzRefHandler<T> h, byte slot);
public bool AddUnique(AzRefHandler<T> h);
public bool AddUnique(AzRefHandler<T> h, byte slot);

public AzEventToken Subscribe(AzRefHandler<T> h);
public AzEventToken Subscribe(AzRefHandler<T> h, byte slot);
public IDisposable SubscribeUnmanaged(AzRefHandler<T> h, byte slot = 5);

public bool Remove(in AzEventToken tok);
public bool Pause(in AzEventToken tok);
public bool Resume(in AzEventToken tok);

public void Invoke(in T arg, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public void InvokeFast(in T arg, AzCancelMode cancelMode = AzCancelMode.Soft, CancellationToken ct = default);
public AzInvokeResult InvokeSafe(in T arg, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public void Invoke(in T arg, out AzCancelInfo cancelInfo, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public AzInvokeResult InvokeSafe(in T arg, out AzCancelInfo cancelInfo, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public void InvokeSlots(ushort slotMask, in T arg, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public AzInvokeResult InvokeSafeSlots(ushort slotMask, in T arg, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public void InvokeSlot(byte slot, in T arg, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);
public AzInvokeResult InvokeSafeSlot(byte slot, in T arg, AzCancelMode cancelMode = AzCancelMode.Soft, bool perHandlerChecks = false, CancellationToken ct = default);

public AzEventMetrics GetMetrics();
public void ResetMetrics();
public void ResetRuntimeMetrics();

Supporting Types

public enum AzExceptionMode : byte
{
    Isolate = 0,
    PropagateAggregate = 1,
    Abort = 2,
    Silent = 3
}

public enum AzCancelMode : byte
{
    None = 0,
    Soft = 1,
    Hard = 2
}

public readonly struct AzCancelInfo
{
    public readonly bool WasCanceled;
    public readonly int SlotIndex;
    public readonly int HandlerIndex;
}

Recommended Defaults for New Users

Use these defaults until you have a reason to specialize:

  • Use AzPriorityEvent<T> for most payload events.
  • Use AzPriorityEventRef<T> only for large struct payloads.
  • Put ordinary gameplay handlers in slot 5.
  • Use slots 1..3 for earlier critical work.
  • Use slots 7..9 for UI, analytics, and late observers.
  • Prefer Subscribe(...).AddTo(bag) over Add(...).
  • Use DisposeAll() in OnDisable for normal components.
  • Use PauseAll() and ResumeAll() for pooled objects that enroll once.
  • Use Invoke first; move to InvokeFast for measured hot paths.
  • Use InvokeSafe when you need result details or exception diagnostics.
  • Keep invocation on the main thread.
  • Create subscriptions on the main thread unless you intentionally need queued off-thread subscription behavior.
  • Do not rely on same-slot ordering for correctness.

Complete Example

using Azkar.Eda;
using UnityEngine;

public static class BattleBus
{
    public static readonly AzPriorityEvent<BattleFrame> BattleFrameResolved = new();
}

public readonly struct BattleFrame
{
    public readonly int FrameId;
    public readonly int PlayerHealth;

    public BattleFrame(int frameId, int playerHealth)
    {
        FrameId = frameId;
        PlayerHealth = playerHealth;
    }
}

public sealed class BattleDriver : MonoBehaviour
{
    private int _frameId;
    private int _playerHealth = 100;

    private void Awake()
    {
        BattleBus.BattleFrameResolved.ExceptionMode = AzExceptionMode.Isolate;
    }

    private void Update()
    {
        var frame = new BattleFrame(_frameId++, _playerHealth);
        BattleBus.BattleFrameResolved.InvokeFast(frame);
    }
}

public sealed class BattleSystems : MonoBehaviour
{
    private readonly AzEventTokenBag _subscriptions = new();

    private void OnEnable()
    {
        BattleBus.BattleFrameResolved.Subscribe(ValidateFrame, slot: 1).AddTo(_subscriptions);
        BattleBus.BattleFrameResolved.Subscribe(ApplyGameplay, slot: 5).AddTo(_subscriptions);
        BattleBus.BattleFrameResolved.Subscribe(UpdateHud, slot: 8).AddTo(_subscriptions);
        BattleBus.BattleFrameResolved.Subscribe(RecordAnalytics, slot: 9).AddTo(_subscriptions);
    }

    private void OnDisable()
    {
        _subscriptions.DisposeAll();
    }

    private void ValidateFrame(BattleFrame frame)
    {
        // Runs before gameplay.
    }

    private void ApplyGameplay(BattleFrame frame)
    {
        // Normal priority.
    }

    private void UpdateHud(BattleFrame frame)
    {
        // Late presentation.
    }

    private void RecordAnalytics(BattleFrame frame)
    {
        // Last observer.
    }
}

This example shows the basic priority-event ecosystem:

  • BattleBus owns the event.
  • BattleDriver invokes it.
  • BattleSystems subscribes multiple handlers to different slots.
  • Slot order expresses phase order.
  • AzEventTokenBag owns cleanup.
  • InvokeFast is used in a hot frame path after setup.