Skip to main content

🔄 Lesson 9.2: Data Binding & UI Events from C++

A HUD is only useful when it reflects the game. This lesson makes your UI live — driven by the gameplay events you built back in Module 5 — and handles input flowing the other way, when the player clicks a button. The secret to clean UI: events, not polling.

🎯 Learning Objectives

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

  • Update UMG widgets in response to gameplay delegate broadcasts
  • Subscribe in NativeConstruct and unsubscribe in NativeDestruct
  • Bind a UButton's OnClicked event to a C++ handler
  • Explain why event-driven UI beats polling every frame in NativeTick
  • Wire the health bar to the UHealthComponent from earlier modules

Estimated Time: 60 minutes

Engine Version: Unreal Engine 5.8

In This Lesson

Events vs Polling

There are two ways a health bar could stay current. Only one is good.

graph TD A["Keep the health bar current"] --> P["Poll: read health every frame in NativeTick"] A --> E["Event: update only when health CHANGES (OnHealthChanged)"] P --> Pc["Runs 60+ times/sec even when nothing changed"] E --> Ec["Runs only on actual changes — efficient & clean"]
Figure 1: Polling in NativeTick works but wastes work every frame. Event-driven updates fire only when the value actually changes — the pattern the whole course has favored (Lesson 3.5, 5.4).

📖 This is the delegate lesson paying off

Back in Lesson 5.4 you added OnHealthChanged to UHealthComponent precisely so listeners could react without polling. The HUD is that listener. The component broadcasts a change; the widget updates once. No Tick, no wasted frames, and the component still knows nothing about the UI. Event-driven UI is the same decoupling you've applied everywhere — now closing the loop.

Subscribing to Gameplay Events

In NativeConstruct (the widget's init hook, Lesson 9.1), find the player's health component and subscribe. In NativeDestruct, unsubscribe — the symmetric cleanup discipline from Lessons 2.6 and 5.4.

// MyHUDWidget.h
UFUNCTION()
void HandleHealthChanged(float NewHealth, float MaxHealth);   // matches the delegate (5.4)

UPROPERTY()
TObjectPtr<UHealthComponent> TrackedHealth;   // GC-safe reference (Lesson 2.5)
// MyHUDWidget.cpp
void UMyHUDWidget::NativeConstruct()
{
    Super::NativeConstruct();

    // Reach the player pawn and its health component (Modules 3-4).
    if (APawn* Pawn = GetOwningPlayerPawn())
    {
        TrackedHealth = Pawn->FindComponentByClass<UHealthComponent>();
        if (TrackedHealth)
        {
            // Subscribe — HandleHealthChanged runs whenever health changes.
            TrackedHealth->OnHealthChanged.AddDynamic(this, &UMyHUDWidget::HandleHealthChanged);

            // Prime the bar with the current value so it's correct immediately.
            HandleHealthChanged(TrackedHealth->GetCurrentHealth(),
                                TrackedHealth->GetMaxHealth());
        }
    }
}

void UMyHUDWidget::NativeDestruct()
{
    // Always unbind what we bound (Lesson 5.4 discipline).
    if (TrackedHealth)
    {
        TrackedHealth->OnHealthChanged.RemoveDynamic(this, &UMyHUDWidget::HandleHealthChanged);
    }
    Super::NativeDestruct();
}

✅ Prime after subscribing

Notice we call the handler once right after subscribing, with the current value. Subscriptions only fire on future changes, so without priming, the bar would show its placeholder until the first health change. "Subscribe, then prime with the current state" is the idiom for any event-driven display.

Updating the Widgets

The handler is where bound widgets (Lesson 9.1) get their new values. It runs only on real changes.

void UMyHUDWidget::HandleHealthChanged(float NewHealth, float MaxHealth)
{
    if (HealthBar)
    {
        // Progress bars take a 0..1 percent.
        const float Percent = (MaxHealth > 0.0f) ? (NewHealth / MaxHealth) : 0.0f;
        HealthBar->SetPercent(Percent);
    }

    if (HealthText)
    {
        // Build display text (FText for player-facing, Lesson 2.4).
        const FText Label = FText::FromString(
            FString::Printf(TEXT("%.0f / %.0f"), NewHealth, MaxHealth));
        HealthText->SetText(Label);
    }
}

📖 Every module shows up here

This tiny handler uses BindWidget pointers (9.1), a delegate subscription (5.4), the health component (3.3), FindComponentByClass (3.5), FText for display (2.4), and null-guarding (2.5). The HUD is a microcosm of the whole course — which is the point of reaching Module 9: you're assembling, not learning isolated pieces.

Handling Button Clicks

Input flows the other way too: the player clicks a button, and C++ reacts. A UButton's OnClicked is a dynamic multicast delegate (Lesson 5.4) — bind to it exactly like any other event. The handler for OnClicked takes no parameters.

// Header: a bound button + a no-arg UFUNCTION handler.
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> RestartButton;

UFUNCTION()
void OnRestartClicked();   // OnClicked passes no arguments
void UMyMenuWidget::NativeConstruct()
{
    Super::NativeConstruct();

    if (RestartButton)
    {
        RestartButton->OnClicked.AddDynamic(this, &UMyMenuWidget::OnRestartClicked);
    }
}

void UMyMenuWidget::OnRestartClicked()
{
    UE_LOG(LogTemp, Log, TEXT("Restart pressed"));
    // ...restart the level, e.g. via a subsystem or GameMode...
    RemoveFromParent();   // close this menu (Lesson 9.1)
}

⚠️ OnClicked handlers take no parameters — and need UFUNCTION()

Unlike overlap or health handlers, UButton::OnClicked passes nothing — your handler must be a no-arg UFUNCTION() or the AddDynamic bind fails. (There are related events — OnPressed, OnHovered, OnReleased — with their own signatures.) It's the same UFUNCTION-required, signature-must-match rule you learned for all dynamic delegates in Module 5.

The Full Picture

Here's the complete data flow of a live HUD, tying gameplay and UI together:

graph TD HC["UHealthComponent
ApplyDamage() changes health"] -->|"OnHealthChanged.Broadcast"| W["HUD widget's HandleHealthChanged"] W -->|"SetPercent / SetText"| Bar["HealthBar & HealthText update"] Btn["Player clicks RestartButton"] -->|"OnClicked"| H["OnRestartClicked() in C++"] H -->|"restart / close"| Game["Gameplay reacts"]
Figure 2: Data flows up from gameplay to UI via events; input flows down from UI to gameplay via button delegates. Neither side polls the other — everything is event-driven.

✅ Clean in both directions

Gameplay → UI is a broadcast the widget subscribes to; UI → gameplay is a button delegate C++ handles. The health component never references the HUD; the button never reaches into gameplay guts beyond calling a clean entry point. This bidirectional, decoupled flow is what a well-built Unreal UI looks like — and it's built entirely from delegates (5.4) and the widget hooks (9.1).

Hands-on Exercise & Quiz

🏋️ Exercise: A live ammo counter

Objective: Drive a second widget from a second event.

  1. Add an AmmoText BindWidget to the HUD.
  2. In NativeConstruct, find the equipped weapon's OnAmmoChanged event (Lesson 6.4's AWeaponBase) and subscribe a HandleAmmoChanged(int32 NewAmmo).
  3. Prime it with the current ammo, and unsubscribe in NativeDestruct.
  4. Why subscribe to OnAmmoChanged instead of reading CurrentAmmo in NativeTick?
✅ Answer to Q4

Ammo changes only when you fire or reload — a handful of times, not every frame. Subscribing means the text updates exactly on those changes; polling in NativeTick would re-read and re-set the text 60+ times a second for no reason, and couples the widget to per-frame execution. Event-driven is both cheaper and cleaner (Figure 1).

🎯 Quick Quiz

Question 1: The clean way to keep a health bar current is to:

Question 2: After subscribing to OnHealthChanged in NativeConstruct, you should also:

Question 3: A UButton::OnClicked handler must be:

Summary

🎉 Key Takeaways

  • Drive UI with events, not polling: subscribe to gameplay delegates (OnHealthChanged, OnAmmoChanged) so widgets update only on real changes.
  • Subscribe in NativeConstruct, unsubscribe in NativeDestruct, and prime the display with the current value after subscribing.
  • Update bound widgets in the handler with SetPercent, SetText, etc.; use FText for player-facing text.
  • Handle input with UButton::OnClicked.AddDynamic → a no-argument UFUNCTION().
  • Data flows up (gameplay → UI via broadcasts) and input flows down (UI → gameplay via button delegates) — decoupled in both directions.

📚 Additional Resources

🚀 What's Next?

Module 9 is complete — your game has a live, code-driven UI. Module 10 tackles the biggest remaining topic: networking & replication — making your gameplay work in multiplayer, where "authority" and "who owns what" become critical.

🎉 Module 9 complete!

Your UI breathes with the game. Now let's make it multiplayer.