Skip to main content

โค๏ธ 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.Lerp so 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:

A game HUD layout mockup A mockup of a game screen. In the top-left corner is a red health bar about seventy percent full inside a dark frame, with a heart icon and the label 75 slash 100. In the top-right is a score readout reading SCORE 12,450. In the bottom-right is an ammo readout reading 24 slash 120 with a bullet icon. The centre shows a faint gameplay scene behind the HUD. โค๏ธ 75 / 100 SCORE 12,450 24 / 120 ๐Ÿ”ซ Each element anchored to its corner (dashed) so it stays pinned at any resolution
Figure 1: A HUD layout mockup. Health top-left, score top-right, ammo bottom-right โ€” each anchored to its corner. The health bar itself is a single Filled Image whose fill we'll drive from code.

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:

The Image component set to Filled in the Inspector A recreation of the Unity Inspector for an Image component. The Source Image is a white bar sprite, Color is red, Image Type is set to Filled, Fill Method is Horizontal, Fill Origin is Left, and the Fill Amount slider sits at 0.75. A preview at the bottom shows a red bar filled three-quarters from the left. Inspector โ–พ Image ๐Ÿ–ผ Source Image UISprite (white bar) Color Image Type Filled โ–พ Fill Method Horizontal Fill Origin Left Fill Amount 0.75 Result in the Game view: fillAmount = 0.75 โ†’ the bar draws 75% of the sprite, anchored from the Left origin.
Figure 2: The Image component set to Filled (faithfully recreated). Fill Method Horizontal + Origin Left gives a classic left-to-right bar; 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 โ€” the Health component doesn't even know the UI exists. You could add a screen-shake or a sound to the same event later without touching Health at 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:

flowchart LR A["Player takes damage"] --> B["Health.TakeDamage()"] B --> C["OnHealthChanged event
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.

  1. 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 / 100 TMP label.
  2. Put the Health component on your player and the SmoothHealthBar (plus a label updater) on the bar; wire the references.
  3. Add a temporary test: a key press that calls health.TakeDamage(10) via an Input Action.
  4. 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 (-= in OnDisable) 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.Lerp toward a target each frame โ€” the one place a per-frame Update earns 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.