Skip to main content

📣 Lesson 5.4: Delegates & Events

A delegate is a "call whoever's listening" mechanism — the loosest coupling on the spectrum from Module 3.5. When a health component hits zero, it announces death; a score manager, a spawner, and a UI all hear it, and the component never needs to know they exist. This is how professional Unreal code stays decoupled.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what a delegate is and how multicast & dynamic variants differ
  • Declare a dynamic multicast delegate with the DECLARE_ macros
  • Expose an event as a UPROPERTY(BlueprintAssignable) and Broadcast it
  • Subscribe from C++ with AddDynamic and unsubscribe with RemoveDynamic
  • Refactor the UHealthComponent to fire an OnDeath event

Estimated Time: 60 minutes

Engine Version: Unreal Engine 5.8

In This Lesson

What Is a Delegate?

A delegate is a variable that holds one or more function references and can call them all. The object that owns the delegate broadcasts; other objects subscribe. Crucially, the broadcaster doesn't know or care who's listening — it just fires.

graph TD HC["UHealthComponent
(broadcaster)"] -->|"OnDeath.Broadcast()"| D["OnDeath delegate"] D --> S1["ScoreManager.AddKill()"] D --> S2["Spawner.OnEnemyDied()"] D --> S3["QuestTracker.Notify()"]
Figure 1: One broadcast, many listeners. The health component fires OnDeath; three unrelated systems react. The component has zero references to any of them — the inversion of control from Module 3.5.

📖 The inversion that matters

Casting and component lookup are "caller reaches into callee." Delegates flip it: the callee announces, and callers subscribe. That means you can add a fourth listener (an achievement system) without touching the health component at all. This is why events are the go-to for "one thing happened, many care."

Delegate Flavors

Unreal has several delegate types. Two axes describe them:

AxisOptions
How many listeners?Single-cast (one) vs Multicast (many)
Blueprint-visible?Dynamic (yes, by name, serializable) vs non-dynamic (C++ only, faster)

✅ The one to default to

For gameplay events that Blueprint and multiple systems should hear, use a Dynamic Multicast Delegate — the type behind OnComponentBeginOverlap and every BlueprintAssignable event. It's slightly slower than a C++-only delegate but integrates with Blueprint and the editor, which is almost always worth it for gameplay. Reach for non-dynamic delegates only in hot, C++-only paths where the Blueprint integration isn't needed.

Declaring & Broadcasting

You declare a delegate type with a macro whose name encodes its parameter count, then make a UPROPERTY of that type. The macro goes above the class, in the header.

// HealthComponent.h — declare the delegate TYPE (one param: the component).
// The macro name encodes arity: ..._OneParam, _TwoParams, etc.
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(
    FOnDeathSignature, UHealthComponent*, DeadComponent);

UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent))
class MYPROJECT_API UHealthComponent : public UActorComponent
{
    GENERATED_BODY()

public:
    // The event instance. BlueprintAssignable lets Blueprints bind to it too.
    UPROPERTY(BlueprintAssignable, Category = "Health")
    FOnDeathSignature OnDeath;

    // ... existing MaxHealth, CurrentHealth, ApplyDamage ...
};
// HealthComponent.cpp — fire the event when health reaches zero.
bool UHealthComponent::ApplyDamage(float Amount)
{
    if (IsDead() || Amount <= 0.0f) { return false; }

    CurrentHealth = FMath::Clamp(CurrentHealth - Amount, 0.0f, MaxHealth);

    if (IsDead())
    {
        // Announce to every listener. We pass 'this' so they know who died.
        OnDeath.Broadcast(this);
        return true;
    }
    return false;
}

⚠️ Match the macro arity to the parameters

Use DECLARE_DYNAMIC_MULTICAST_DELEGATE for zero params, ..._OneParam for one, ..._TwoParams for two, and so on. Each parameter needs a type and a name in the macro. Dynamic delegate parameters must be reflection-friendly types (UObject pointers, structs, ints, etc.). A mismatch between the macro arity and your Broadcast call is a compile error.

Subscribing & Unsubscribing

A listener binds a UFUNCTION() whose signature matches the delegate. Same AddDynamic you used for collision in Lesson 5.1 — because those collision events are dynamic multicast delegates.

// A ScoreManager listens for a specific enemy's death.
void AScoreManager::WatchEnemy(AEnemy* Enemy)
{
    if (UHealthComponent* Health = Enemy->FindComponentByClass<UHealthComponent>())
    {
        // Subscribe: call HandleDeath when this component broadcasts OnDeath.
        Health->OnDeath.AddDynamic(this, &AScoreManager::HandleDeath);
    }
}

// The handler — signature MUST match the delegate (one UHealthComponent* param).
UFUNCTION()
void AScoreManager::HandleDeath(UHealthComponent* DeadComponent)
{
    Score += 100;
    UE_LOG(LogTemp, Log, TEXT("Kill! Score = %d"), Score);
}

// Later, stop listening (e.g. in EndPlay, Lesson 2.6 cleanup discipline):
void AScoreManager::EndPlay(const EEndPlayReason::Type Reason)
{
    if (WatchedHealth) { WatchedHealth->OnDeath.RemoveDynamic(this, &AScoreManager::HandleDeath); }
    Super::EndPlay(Reason);
}

⚠️ Unsubscribe when you go away

A dangling subscription — a destroyed listener still bound — can lead to a callback into invalid memory. For dynamic delegates, Unreal is fairly robust (it validates the target UObject), but the clean discipline is to RemoveDynamic in EndPlay, mirroring your setup. Symmetric bind/unbind is the same habit as symmetric timer set/clear (5.3) and component setup/teardown (2.6).

Refactoring HealthComponent

Look at what the OnDeath event buys us across the modules. Before, an enemy had to poll Health->IsDead() and everyone needed a reference to the enemy. Now:

Without the eventWith OnDeath
Enemy checks IsDead() in TickComponent broadcasts once, at the moment of death
Score manager needs a list of every enemyScore manager just subscribes to each enemy's event
Adding a new reaction edits the enemyNew reaction = new subscriber, enemy untouched

✅ And Blueprint gets it free

Because OnDeath is BlueprintAssignable, a designer can drag from the health component in a Blueprint and add "On Death" logic — spawn loot, play a sound — with no C++ change. The C++ owns when death happens; Blueprint decides what to do about it. That's the hybrid model (Lesson 1.1) expressed through events, and it's exactly what Module 6 formalizes next.

💡 When NOT to use an event: if exactly one object cares and it already has a direct reference, a plain function call is simpler. Events shine for one-to-many and for decoupling a sender from unknown receivers. Don't add a delegate where a direct call would do — match the tool to the relationship (Lesson 3.5).

Hands-on Exercise & Quiz

🏋️ Exercise: An OnHealthChanged event

Objective: Add a second event for UI.

  1. Declare FOnHealthChangedSignature as a dynamic multicast delegate with two params: float NewHealth, float MaxHealth.
  2. Add a BlueprintAssignable OnHealthChanged property.
  3. Broadcast it at the end of ApplyDamage and Heal (any time health changes).
  4. Describe how a health-bar widget would use it without the component knowing about the widget.
✅ Declaration & usage
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(
    FOnHealthChangedSignature, float, NewHealth, float, MaxHealth);

UPROPERTY(BlueprintAssignable, Category = "Health")
FOnHealthChangedSignature OnHealthChanged;

// After any change:
OnHealthChanged.Broadcast(CurrentHealth, MaxHealth);

The widget subscribes to OnHealthChanged (in C++ or Blueprint) and updates its bar when it fires. The component broadcasts blindly; it never references the widget — so the same component drives a HUD bar, a boss health bar, or nothing at all.

🎯 Quick Quiz

Question 1: What makes a delegate multicast?

Question 2: Which specifier lets Blueprints bind to a C++ event?

Question 3: The big advantage of firing OnDeath vs everyone polling IsDead()?

Summary

🎉 Key Takeaways

  • A delegate holds function references; the owner broadcasts, others subscribe — the broadcaster stays ignorant of listeners.
  • For gameplay events, default to a Dynamic Multicast Delegate (Blueprint-friendly, many listeners).
  • Declare the type with DECLARE_DYNAMIC_MULTICAST_DELEGATE[_NParams]; expose a UPROPERTY(BlueprintAssignable); fire with .Broadcast(...).
  • Subscribe with AddDynamic (handler must be UFUNCTION(), signature matching); RemoveDynamic in EndPlay.
  • Events are for one-to-many and decoupling; use a direct call when a single known object cares.

📚 Additional Resources

🚀 What's Next?

Delegates broadcast "something happened." The last interaction pattern answers "can you do X?" across unrelated types — interfaces. We'll build IInteractable and finally close the coupling spectrum from Module 3.5.

🎉 Lesson complete!

Your objects can announce. Last stop: making them share abilities.