โค๏ธ Lesson 4.3: HUD โ Health Bars & Live Data
A HUD โ heads-up display โ is the layer of live information glued to your screen: health, score, ammo, a minimap. Unlike a menu, it changes constantly while you play. In this lesson you'll build a health bar from a Filled Image, feed it from a health event (the clean way, connecting back to Module 2), add score and ammo readouts, and smooth the bar with Lerp so it drains gracefully instead of snapping.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Build a health bar from an Image set to Filled and drive it with
fillAmount - Update the HUD from a health event instead of polling every frame
- Display live score and ammo with TextMeshPro
- Smooth the bar's motion with
Mathf.Lerpso it animates toward its target - Understand why an event-driven HUD scales better than one that checks values in
Update
Estimated Time: 45 minutes ยท Prerequisite: Lesson 4.1 (Canvas Scaling) and Module 2 (Events & Delegates)
In This Lesson
Anatomy of a HUD
A HUD sits on an Overlay canvas (Lesson 4.1) with each element anchored to a corner so it stays put at any resolution. Here's the layout we're building โ a health bar top-left, score top-right, ammo bottom-right:
The score and ammo are just TextMeshPro elements whose .text we update. The interesting part is the health bar, so let's build that properly.
The Filled Image
A health bar is not a special component โ it's an ordinary Image with its Image Type set to Filled. In Filled mode, a single float called fillAmount (0 to 1) controls how much of the sprite is shown. Set it to 0.75 and the image draws three-quarters of itself. That's your bar.
Here's the Image component set up as a horizontal health bar in the Inspector:
fillAmount from 0 to 1 is the single value your code drives.Set it up in the Inspector: drop a bar sprite into Source Image, tint the Color red, set Image Type โธ Filled, Fill Method โธ Horizontal, Fill Origin โธ Left. Now dragging the Fill Amount slider empties and fills the bar โ and that same value is what you'll set from code as image.fillAmount.
โ Pro Tip
Put a second, darker Image behind the fill (the "track") so an empty bar still reads as a bar rather than vanishing. The dim red rectangle behind the bright fill in Figure 1 is exactly that background track.
Driving the Bar from a Health Event
Here's the temptation to resist: checking the player's health every frame in the HUD's Update. It works, but it couples your UI tightly to the player and does pointless work on frames where nothing changed. In Module 2 you learned a better pattern โ events. The health component announces when it changes; the HUD listens.
Recall the event-based health component from Module 2. It fires a C# event whenever health changes, passing the current and max values:
using System;
using UnityEngine;
public class Health : MonoBehaviour
{
[SerializeField] int maxHealth = 100;
int current;
// Fired whenever health changes: (current, max)
public event Action<int, int> OnHealthChanged;
void Awake()
{
current = maxHealth;
}
public void TakeDamage(int amount)
{
current = Mathf.Max(current - amount, 0);
OnHealthChanged?.Invoke(current, maxHealth); // announce the change
}
public void Heal(int amount)
{
current = Mathf.Min(current + amount, maxHealth);
OnHealthChanged?.Invoke(current, maxHealth);
}
}
The HUD subscribes to that event and updates the bar only when it fires. No per-frame polling:
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class HealthBar : MonoBehaviour
{
[SerializeField] Health health; // the player's Health component
[SerializeField] Image fillImage; // the Filled Image
[SerializeField] TMP_Text label; // optional "75 / 100" text
void OnEnable()
{
health.OnHealthChanged += UpdateBar;
}
void OnDisable()
{
health.OnHealthChanged -= UpdateBar; // always unsubscribe!
}
void UpdateBar(int current, int max)
{
fillImage.fillAmount = (float)current / max; // 0..1
if (label != null)
label.text = $"{current} / {max}";
}
}
Notice the cast: (float)current / max. Integer division would give you 0 for anything below full health โ a classic bug. Cast to float first so 75 / 100 becomes 0.75, not 0.
โ ๏ธ Always unsubscribe in OnDisable
Every += in OnEnable needs a matching -= in OnDisable. Forget it and you leak subscriptions: destroyed HUDs keep receiving events, throwing null-reference errors, and objects never get garbage-collected. This is the single most common event-system mistake.
๐ก Why events beat polling. A polling HUD asks "did health change?" 60 times a second forever. An event-driven HUD does nothing until health actually changes, then updates once. It's less work, and โ crucially โ theHealthcomponent doesn't even know the UI exists. You could add a screen-shake or a sound to the same event later without touchingHealthat all.
Score & Ammo Text
Score and ammo follow the exact same pattern โ fire an event when the value changes, update a TMP_Text in the handler. A minimal score display:
using UnityEngine;
using TMPro;
public class ScoreDisplay : MonoBehaviour
{
[SerializeField] TMP_Text scoreText;
int score;
public void AddScore(int amount)
{
score += amount;
scoreText.text = $"SCORE {score:N0}"; // N0 โ 12,450 with a comma
}
}
The {score:N0} format string inserts thousands separators, so 12450 reads as 12,450. For ammo, a simple $"{clip} / {reserve}" gives you the classic 24 / 120 readout in Figure 1.
โ Pro Tip
Use TextMeshPro (the TMP_Text / TextMeshProUGUI component), not the legacy Text. TMP is sharper at any scale (it uses signed-distance-field rendering), which matters directly for the Canvas Scaler work from Lesson 4.1 โ the same label stays crisp whether it's on a phone or a 4K TV.
Smoothing the Bar with Lerp
Set fillAmount directly and the bar snaps to the new value the instant you take damage. Polished games make the bar glide to its new level over a fraction of a second. The tool is Mathf.Lerp โ linear interpolation between two values.
The pattern: the event sets a target fill; Update eases the displayed fill toward that target every frame. This is the one place a small amount of per-frame work is worth it โ but only to animate, not to read the source value.
using UnityEngine;
using UnityEngine.UI;
public class SmoothHealthBar : MonoBehaviour
{
[SerializeField] Health health;
[SerializeField] Image fillImage;
[SerializeField] float speed = 4f; // higher = snappier
float targetFill = 1f;
void OnEnable() => health.OnHealthChanged += SetTarget;
void OnDisable() => health.OnHealthChanged -= SetTarget;
void SetTarget(int current, int max)
{
targetFill = (float)current / max; // event just records the goal
}
void Update()
{
// ease the visible fill toward the target each frame
fillImage.fillAmount = Mathf.Lerp(
fillImage.fillAmount, targetFill, speed * Time.deltaTime);
}
}
Because the third argument is speed * Time.deltaTime, the bar moves a fraction of the remaining distance each frame โ fast at first, easing in as it arrives. That gives the smooth, weighty drain players expect from a health bar. Bump speed up for a snappier feel, down for a slower, more dramatic drain.
๐ Definition
Mathf.Lerp(a, b, t): returns a value t of the way from a to b, where t is clamped 0โ1. Called repeatedly with the current value as a and a small t, it produces smooth ease-out motion toward b โ the go-to trick for smoothing bars, camera follows, and color fades.
โ ๏ธ Watch the pause interaction
This bar animates on Time.deltaTime, so it correctly freezes when you pause with Time.timeScale = 0 (Lesson 4.2). That's usually what you want for a HUD. If you ever need a bar to keep animating during a pause, switch to Time.unscaledDeltaTime โ the same choice you made for menu fades.
The full data flow of an event-driven, smoothed HUD:
fires (current, max)"] C --> D["HUD handler sets
targetFill = current/max"] D --> E["Update() Lerps
fillImage.fillAmount โ target"] E --> F["Bar glides to
its new level"]
Figure 3: Damage flows through an event to a target value; only the visual smoothing runs per-frame.
Hands-on Challenge
๐๏ธ Exercise 1: A live, event-driven health bar
Objective: Take damage and watch the bar drain smoothly.
- On an Overlay canvas, build a health bar: a dark track Image with a red Filled Image (Horizontal, Left) on top, anchored top-left. Add a
75 / 100TMP label. - Put the
Healthcomponent on your player and theSmoothHealthBar(plus a label updater) on the bar; wire the references. - Add a temporary test: a key press that calls
health.TakeDamage(10)via an Input Action. - Press Play, deal damage, and watch the bar glide down and the label update โ with no polling in the HUD.
๐ก Hint: the bar jumps to empty or won't move
If it snaps to 0 the moment you take any damage, you likely have integer division โ cast with (float)current / max. If it never moves, confirm the HUD actually subscribed in OnEnable and that the fillImage reference points at the fill Image, not the track.
โ Success check
Each hit smoothly eases the bar to its new level (not an instant snap), the label reads the exact current / max, and the HUD has no Update that reads health directly โ only the Lerp animation.
๐๏ธ Exercise 2: Add score & a color shift
Add a ScoreDisplay anchored top-right and award points on some event (a pickup, an enemy defeat). Then extend the health bar: when targetFill drops below 0.3, tint the fill Image toward a brighter danger red (Lerp its color too). Now the bar communicates urgency by both length and hue โ the kind of small touch that makes a HUD feel finished.
๐ฏ Quick Quiz
Question 1: Which Image setting turns a sprite into a bar you can partially fill from code?
Question 2: Why update the health bar from an event instead of reading health in Update?
Question 3: Your bar snaps straight to empty after one small hit. What's the likely cause?
Summary
๐ Key Takeaways
- A health bar is an Image set to Filled; its
fillAmount(0โ1) is the one value your code drives. - Drive the HUD from a health event (
OnHealthChanged), not per-frame polling โ less work, and the UI stays decoupled from gameplay. - Always unsubscribe (
-=inOnDisable) to avoid leaks and null errors. - Cast before dividing:
(float)current / max, or integer division snaps the bar to 0. - Score/ammo are just TextMeshPro updates; use format strings like
{score:N0}for readable numbers. - Smooth the bar with
Mathf.Lerptoward a target each frame โ the one place a per-frameUpdateearns its keep.
๐ What's Next?
Your HUD is glued to the screen. But some information belongs out in the world โ a name floating above an enemy, damage numbers popping off a hit. In Lesson 4.4: World-Space UI โ Nameplates & Damage Numbers, the module's mini-project, you'll build world-space canvases that billboard to face the camera and pooled damage numbers that spawn, rise, and fade.
โค๏ธ Your HUD is alive
Filled Images, an event-driven feed, and a touch of Lerp turn static UI into a display that breathes with the game. The same three ideas power stamina bars, boss health, cooldown rings โ everything that shows a live value.