← All posts

Creating Event-Based Architecture in Unity with Event Args

· Updated October 2, 2026

UnityScriptingEvent-based architecture
Event-Based Architecture in Unity

Sooner or later every Unity project hits the same wall. The player’s health changes, and five things need to know about it: the health bar, the damage flash, the audio, the achievement tracker and, eventually, the analytics someone adds a month before launch. If the health code calls each of those directly, it ends up knowing about half the game, and every new feature means editing it again.

Events get you out of that. The health code announces that something happened, anything interested reacts, and the health code never learns who was listening. Below, I build a small health system that way, using C# events with a custom EventArgs class and a lightweight EventBus, and point out the traps that catch most teams the first time.

What event-based architecture means in Unity

An event is a notification that something happened, plus whatever data goes with it. Components that care subscribe to it, and when it fires, each subscriber runs its own handler. The sender never holds a reference to its listeners, so you can add or remove listeners without touching the sender at all.

Unity uses the idea everywhere, from UI button clicks to animation events. For simple cases, ScriptableObject event assets work well, and I cover those in 10 Ways to Use Scriptable Objects in Unity. When events need to carry structured data across many systems, I prefer plain C# events with typed arguments, which is what we’ll build here.

The plan: a health system that doesn’t know about its UI

There are four pieces:

  • HealthChangedEventArgs carries the new health values.
  • EventBus is a static hub that passes events from senders to listeners.
  • HealthSystem owns the health and announces every change.
  • HealthUI listens for changes and updates the text on screen.

The health system and the UI never reference each other. Both only talk to the bus.

Carrying data with EventArgs

using System;

public class HealthChangedEventArgs : EventArgs
{
    public int CurrentHealth { get; }
    public int MaxHealth { get; }

    public HealthChangedEventArgs(int currentHealth, int maxHealth)
    {
        CurrentHealth = currentHealth;
        MaxHealth = maxHealth;
    }
}

Inheriting from EventArgs is a .NET convention rather than a requirement, but it keeps every event in the project shaped the same way, and the bus below uses it as a constraint so only proper event types go through it. I’ve added MaxHealth since the original version of this post, because nearly every health bar needs both numbers.

The EventBus

The bus keeps one delegate per event type. C# delegates can hold any number of handlers at once (they’re “multicast”), so subscribing simply combines the new handler into the delegate that’s already there:

using System;
using System.Collections.Generic;
using UnityEngine;

public static class EventBus
{
    private static readonly Dictionary<Type, Delegate> handlers = new Dictionary<Type, Delegate>();

    public static void Subscribe<T>(Action<object, T> handler) where T : EventArgs
    {
        handlers.TryGetValue(typeof(T), out Delegate existing);
        handlers[typeof(T)] = Delegate.Combine(existing, handler);
    }

    public static void Unsubscribe<T>(Action<object, T> handler) where T : EventArgs
    {
        if (!handlers.TryGetValue(typeof(T), out Delegate existing))
            return;

        Delegate remaining = Delegate.Remove(existing, handler);
        if (remaining == null)
            handlers.Remove(typeof(T));
        else
            handlers[typeof(T)] = remaining;
    }

    public static void Raise<T>(object sender, T args) where T : EventArgs
    {
        if (handlers.TryGetValue(typeof(T), out Delegate handler))
            ((Action<object, T>)handler).Invoke(sender, args);
    }

    // Static fields survive between Play sessions when domain reload is off.
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    private static void ResetOnPlay() => handlers.Clear();
}

This replaces the bus I first published, which stored a List<Delegate>, called each handler with DynamicInvoke inside a lock, and skipped any handler whose Target was null. That version had three problems:

  • DynamicInvoke goes through reflection, which is slow and allocates memory on every call.
  • Static methods have a null Target, so static handlers silently never ran.
  • Removing a handler while an event was being raised shifted the list mid-loop, which could skip another handler.

A combined delegate avoids all three. It’s invoked directly, and because a delegate never changes once it’s created, a handler that unsubscribes during Raise doesn’t disturb the call already in progress.

I also dropped the lock. Most of Unity’s API only works on the main thread, so in practice every event here is raised there. If you do raise events from worker threads, lock around the dictionary, but call the handlers outside the lock.

The last method deals with a trap in Unity’s faster Play mode option. With domain reload turned off, static fields keep their values between Play sessions, so handlers from your previous run would still be subscribed. SubsystemRegistration runs before the first scene loads, which makes it the right moment to clear them.

The HealthSystem

using UnityEngine;

public class HealthSystem : MonoBehaviour
{
    [SerializeField] private int maxHealth = 100;
    private int currentHealth;

    public int CurrentHealth => currentHealth;
    public int MaxHealth => maxHealth;

    private void Start()
    {
        SetHealth(maxHealth);
    }

    public void TakeDamage(int amount) => SetHealth(currentHealth - amount);
    public void Heal(int amount) => SetHealth(currentHealth + amount);

    private void SetHealth(int value)
    {
        int clamped = Mathf.Clamp(value, 0, maxHealth);
        if (clamped == currentHealth)
            return;

        currentHealth = clamped;
        EventBus.Raise(this, new HealthChangedEventArgs(currentHealth, maxHealth));
    }
}

Two fixes from the original are in here. The old version declared its own C# event, but the UI subscribed through the EventBus, and nothing ever raised anything on the bus, so the health bar never moved. Now the health system raises its change through the bus. The old setter also compared values before clamping them, so taking damage at zero health still raised a “changed” event with the same number. Clamping first means listeners only hear about real changes.

The HealthUI

using TMPro;
using UnityEngine;

public class HealthUI : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI healthText;

    private void OnEnable() => EventBus.Subscribe<HealthChangedEventArgs>(OnHealthChanged);
    private void OnDisable() => EventBus.Unsubscribe<HealthChangedEventArgs>(OnHealthChanged);

    private void OnHealthChanged(object sender, HealthChangedEventArgs e)
    {
        healthText.SetText($"Health: {e.CurrentHealth}/{e.MaxHealth}");
    }
}

The UI subscribes in OnEnable and unsubscribes in OnDisable. Pairing them like that means a hidden health bar stops listening, and a destroyed one is always unsubscribed, because Unity calls OnDisable before it destroys a component. The original subscribed in Start and unsubscribed in OnDestroy. That works too, but the UI keeps listening while it’s disabled, and the two halves of the pair are easier to get out of step.

The original also called FindObjectOfType to grab the health system and then never used it. Delete that kind of lookup on principle: the whole point of the bus is that the UI doesn’t need to find anything.

One timing detail to watch. Within a single scene this works because Unity calls every OnEnable before any Start, so the UI is listening by the time the health system announces its starting value. A UI object that’s enabled later, or loaded with another scene, misses that first event. Have it read CurrentHealth from the player when it appears, then listen for changes from there.

Where this pays off

The payoff arrives the first time you add a feature. A damage flash, a low-health heartbeat sound or an achievement for surviving on one hit point is each a new listener, and the health system doesn’t change at all. Removing one is just as clean.

The cost is that the flow of control is harder to follow, because you can’t step from the health system into the UI with “go to definition”. So name events clearly and keep the bus small. I use events for facts several systems care about, and plain method calls for everything else.

Decoupled code like this pays for itself most in complex projects, multiplayer games especially, where events crossing the network boundary punish tightly coupled code.

Common questions

What is event-based architecture in Unity?

It is a pattern where components communicate by raising and listening to events instead of calling each other directly. When something happens, like a character taking damage, every subscribed listener gets notified and reacts on its own. Unity projects commonly use it for UI updates, animation triggers, and input-driven game mechanics.

How do I pass data with events in Unity?

Create a class that inherits from EventArgs and give it properties for the data you want to send, such as a CurrentHealth value. Construct a new instance when you raise the event, and subscribers read the values straight off the args parameter in their handler.

What is an EventBus and how does it work?

An EventBus is a static class that acts as a central hub for events. It maps each event type to a delegate holding every subscribed handler, with Subscribe and Unsubscribe methods to change that delegate and a Raise method that invokes it. Components talk to the bus instead of holding references to each other.

Why do I need to unsubscribe from events in Unity?

Because the bus holds a reference to every subscribed handler, and through it to the object that owns the handler. If a component is destroyed without unsubscribing, the bus keeps it reachable and still calls into it, which leaks memory and throws errors on dead objects. Subscribe in OnEnable and unsubscribe in OnDisable so the two always match.

Can I use ScriptableObjects instead of an EventBus?

Yes, ScriptableObject-based events are a lighter-weight alternative for decoupling. An event asset with a small listener component covers simple cases well, while a typed EventBus with EventArgs scales better when events need to carry structured data across many systems.