GDKB

Events

Intermediate25 Minutes readUpdated July 21, 20263 related concepts

An event is a restricted, publicly-observable notification built on top of a delegate: any code can subscribe to be notified when the event fires, but only the class that declared the event is allowed to actually fire it. This turns a raw multicast delegate — which any outside code could invoke or overwrite — into a safe broadcast mechanism, letting many independent systems react to something happening without any of them being directly wired together.

On this page

Events

Summary

An event is a restricted, publicly-observable notification built on top of a delegate: any code can subscribe to be notified when the event fires, but only the class that declared the event is allowed to actually fire it. This turns a raw multicast delegate — which any outside code could invoke or overwrite — into a safe broadcast mechanism, letting many independent systems react to something happening without any of them being directly wired together.

Why This Matters

A raw public delegate field can be invoked or reassigned by any code that can see it — outside code could accidentally call health.OnDamaged(9999) to fake damage, or wipe out every existing subscriber with health.OnDamaged = null. A health system that needs to notify a UI bar, an achievement tracker, and a sound system when the player takes damage shouldn't have to trust every one of those systems not to misuse that access. Events close that gap: outside code can subscribe and unsubscribe, but only Health itself can fire OnDamaged.

Mental Model

An event is a newsletter, not a group chat. Subscribing to a newsletter lets you receive it whenever the publisher sends one, but subscribers can't write the newsletter themselves or force the publisher to send it early — only the publisher decides when and what goes out. A delegate field without the event keyword is closer to a shared group chat where anyone in it can post as if they were the publisher; the event keyword removes that ability from everyone except the class that owns it.

Core Explanation

  • event restricts a delegate to subscribe/unsubscribe from the outside. public event Action<int> OnDamaged; allows external code to do health.OnDamaged += Handler; but not health.OnDamaged(10) or health.OnDamaged = null — only code inside Health itself can invoke or reassign it.
  • Publisher and subscribers are decoupled. Health never references HealthBar, AchievementTracker, or AudioManager by name — each of those subscribes independently, and Health stays unaware of how many subscribers exist or what they do.
  • An event fires synchronously, in subscription order. Every subscribed handler runs, one after another, on the same call stack as the code that raised the event — an event is not automatically deferred or run on a separate thread/frame.
  • Subscribers must unsubscribe to avoid leaks. An object that subscribes to an event but is destroyed without unsubscribing leaves a dangling reference inside the publisher, the same failure mode as an unmanaged delegate (see [[CON-0023]]'s Common Mistakes).

Syntax / Theory

C# (Unity)

csharp
public class Health : MonoBehaviour
{
    public event Action<int> OnDamaged; // 'event' — outside code can only += / -=.

    public void TakeDamage(int amount)
    {
        currentHealth -= amount;
        OnDamaged?.Invoke(amount); // Only Health itself can raise this.
    }
}
csharp
public class HealthBar : MonoBehaviour
{
    [SerializeField] private Health target;

    void OnEnable()  => target.OnDamaged += UpdateBar;
    void OnDisable() => target.OnDamaged -= UpdateBar; // Prevents a leaked reference.

    void UpdateBar(int amount) => fillImage.fillAmount = target.CurrentHealthRatio;
}

C++ (Unreal — multicast delegates)

cpp
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnDamaged, int32, Amount);

UCLASS()
class AHealthComponent : public UActorComponent
{
public:
    UPROPERTY(BlueprintAssignable)
    FOnDamaged OnDamaged;

    void TakeDamage(int32 Amount)
    {
        CurrentHealth -= Amount;
        OnDamaged.Broadcast(Amount);
    }
};

Unreal Blueprint

UPROPERTY(BlueprintAssignable) exposes the event directly in the Blueprint editor as a bindable output pin — designers can drag off "On Damaged" and wire up a response without writing C++, exactly matching the subscribe-only access a public event grants in C#.

Practical Example

A save system needs to know whenever anything in the game changes persistent state, without hard-coding a list of every system that might change it.

csharp
public static class SaveManager
{
    public static event Action OnDirty;

    public static void MarkDirty() => OnDirty?.Invoke();
}
csharp
public class Inventory
{
    public void AddItem(Item item)
    {
        items.Add(item);
        SaveManager.MarkDirty(); // Inventory doesn't know who's listening.
    }
}
csharp
public class AutoSaveIndicator : MonoBehaviour
{
    void OnEnable() => SaveManager.OnDirty += ShowUnsavedIcon;
}

Inventory never references AutoSaveIndicator, and any future system that needs to react to a dirty save state just subscribes — SaveManager and Inventory never change.

Common Mistakes

  • Exposing a raw delegate field instead of an event. public Action<int> OnDamaged; without the event keyword lets any outside code invoke it directly or wipe out all subscribers with = null — always use event for anything meant to be observed rather than controlled.
  • Not unsubscribing in OnDisable/OnDestroy. A destroyed MonoBehaviour that never called -= leaves the publisher holding a reference to a dead object — the next Invoke either throws or silently calls into a destroyed object's state.
  • Doing expensive work directly inside an event handler that fires every frame. An event that fires on every Update (e.g. "position changed") and triggers heavy work per-subscriber can silently tank performance; consider throttling or batching instead.
  • Assuming subscriber order is meaningful or guaranteed by design. Handlers run in subscription order, but code shouldn't depend on which handler runs first — that coupling defeats the purpose of decoupling publisher from subscribers.

Game Development Example

A boss fight needs multiple independent systems — music, camera, UI — to react the moment the boss's health crosses a phase threshold, without the boss script knowing any of them exist.

csharp
public class BossHealth : MonoBehaviour
{
    public event Action<int> OnPhaseChanged;
    private int currentPhase = 1;

    public void TakeDamage(int amount)
    {
        currentHealth -= amount;
        int newPhase = CalculatePhase(currentHealth);
        if (newPhase != currentPhase)
        {
            currentPhase = newPhase;
            OnPhaseChanged?.Invoke(currentPhase);
        }
    }
}

Music, camera-shake, and UI systems each subscribe to OnPhaseChanged independently — adding a fourth reactive system later requires no change to BossHealth at all.

Best Practices

  • Always declare public notifications with event, never a bare public delegate field — reserve the bare form for private, internal use only.
  • Pair every += with a -= in the matching lifecycle method (OnEnable/OnDisable or OnDestroy).
  • Keep event handlers small and fast; if a handler needs to do significant work, have it kick off that work rather than performing it inline during the broadcast.
  • Name events for what happened, not what to do (OnDamaged, not UpdateHealthBar) — the publisher shouldn't know or care what subscribers do in response.

Exercises

  1. Add a public event Action OnDefeated; to a BossHealth class, invoke it when health reaches zero, and subscribe two independent handlers (a log message and a hypothetical "play victory music" call).
  2. Take the HealthBar example and deliberately remove the OnDisable unsubscription, then explain, in terms of what Health is holding a reference to, what goes wrong when the HealthBar GameObject is destroyed.
  3. You're deciding between many small, specific events (OnDamaged, OnHealed, OnDied) versus one general OnHealthChanged(int oldValue, int newValue) event. Argue for one approach for a UI-heavy game and justify the trade-off.
  4. Research the Observer design pattern and explain, in your own words, how C# events are a language-level implementation of that pattern — and name one thing a hand-rolled Observer implementation could do that a C# event cannot.
  • Delegates — events are a restricted-access wrapper around a multicast delegate; understanding delegates first is necessary to see what the event keyword is restricting.
  • Access Modifiers — the event keyword applies the same publish-vs-consume access split that access modifiers apply more generally to fields and methods.
  • Functions — event handlers are ordinary methods matching the event's delegate signature; nothing about subscribing to an event requires new function syntax.

Further Reading

Related Concepts