🔄 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
NativeConstructand unsubscribe inNativeDestruct - Bind a
UButton'sOnClickedevent to a C++ handler - Explain why event-driven UI beats polling every frame in
NativeTick - Wire the health bar to the
UHealthComponentfrom 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.
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.
The Full Picture
Here's the complete data flow of a live HUD, tying gameplay and UI together:
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"]
✅ 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.
- Add an
AmmoTextBindWidgetto the HUD. - In
NativeConstruct, find the equipped weapon'sOnAmmoChangedevent (Lesson 6.4'sAWeaponBase) and subscribe aHandleAmmoChanged(int32 NewAmmo). - Prime it with the current ammo, and unsubscribe in
NativeDestruct. - Why subscribe to
OnAmmoChangedinstead of readingCurrentAmmoinNativeTick?
✅ 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 inNativeDestruct, and prime the display with the current value after subscribing. - Update bound widgets in the handler with
SetPercent,SetText, etc.; useFTextfor player-facing text. - Handle input with
UButton::OnClicked.AddDynamic→ a no-argumentUFUNCTION(). - 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.