๐ Lesson 4.4: World-Space UI โ Nameplates & Damage Numbers
This is the Module 4 mini-project, and it ties the whole module together. Some UI doesn't belong on the screen โ it belongs in the world: a name hovering over an enemy, a damage number popping off a hit and floating away. You'll build both with World Space canvases (Lesson 4.1), make them billboard to always face the camera, and spawn damage numbers from a pool so a busy fight never stutters.
๐ฏ Learning Objectives
By the end of this mini-project, you will be able to:
- Attach a World Space canvas above a character and size it correctly in meters
- Billboard a canvas so it always faces the camera
- Spawn floating damage numbers that rise, drift, and fade
- Reuse them with an object pool instead of Instantiate/Destroy every hit
- Feed both systems from the health event you built in Lesson 4.3
- Assemble a complete enemy: nameplate + health + pooled damage popups
Estimated Time: 50 minutes ยท Prerequisite: Lessons 4.1โ4.3 (render modes, and the event-driven health bar)
In This Lesson
A Canvas in the World
Back in Lesson 4.1 you met World Space render mode: the canvas becomes a physical object in the scene with a real position and size in meters. That's exactly what a nameplate is โ a little screen floating a couple of meters up, parented to the enemy so it moves along with them.
The setup that trips everyone: a fresh World Space canvas is enormous (its RectTransform is hundreds of units wide, meaning hundreds of meters). You shrink it with the RectTransform's Scale, typically to around 0.01 on each axis, then set a sane width/height in the now-tiny units. Here's the result โ a nameplate hovering above a character:
โ ๏ธ "My nameplate fills the whole screen"
If a new World Space canvas swallows the view, its scale is still 1 (so it's hundreds of meters across). Set the RectTransform Scale to about 0.01, 0.01, 0.01 first, then design at a comfortable size like 200ร80 in the resulting units. Also give it a small width/height rather than the default 100s.
The Enemy Nameplate
A nameplate is just a small World Space canvas parented above the enemy, holding a name label and โ reusing everything from Lesson 4.3 โ a Filled Image health bar. Because it's a real child of the enemy, it follows them around the level automatically. You only need code for two things: setting the name, and driving the bar from the enemy's health event.
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class Nameplate : MonoBehaviour
{
[SerializeField] TMP_Text nameLabel;
[SerializeField] Image healthFill; // Filled Image, exactly like the HUD bar
[SerializeField] Health health; // the enemy's Health (from Lesson 4.3)
public void SetName(string enemyName) => nameLabel.text = enemyName;
void OnEnable() => health.OnHealthChanged += UpdateBar;
void OnDisable() => health.OnHealthChanged -= UpdateBar;
void UpdateBar(int current, int max)
{
healthFill.fillAmount = (float)current / max;
}
}
That's the whole nameplate. Notice it's the same event-driven pattern as the screen HUD โ the only difference is where the canvas lives. One OnHealthChanged event can feed a screen HUD and a world nameplate at once.
โ Pro Tip
Make the whole nameplate a prefab (canvas + label + bar + Nameplate script). Then every enemy prefab just includes one, and you configure the name per enemy. This is the reuse habit the whole module has been building toward โ author once, drop in everywhere.
Billboarding to Face the Camera
A world-space nameplate has a real rotation, so if the enemy turns away, you'd read the text backwards โ or edge-on, as an invisible sliver. Billboarding fixes this: every frame, rotate the canvas to face the camera, so the player always reads it head-on.
The simplest, most robust billboard makes the canvas share the camera's forward direction (so it stays parallel to the screen plane rather than tilting toward a perspective camera at the edges):
using UnityEngine;
public class Billboard : MonoBehaviour
{
Transform cam;
void Start()
{
cam = Camera.main.transform; // cache it once
}
void LateUpdate()
{
// Face the same way the camera faces โ always flat to the screen.
transform.forward = cam.forward;
}
}
Two important choices here:
LateUpdate, notUpdate. The camera may move inUpdate; running the billboard inLateUpdateguarantees we orient after the camera has finished moving this frame, so the plate never lags a frame behind.- Match camera forward, not
LookAt(cam).LookAtaims at the camera's position, which tilts plates at the screen edges under a perspective camera. Copying the camera's forward keeps every plate perfectly flat and parallel โ the look players expect.
๐ Definition
Billboarding: continuously rotating a flat object so it always faces the viewer. Named after roadside billboards, it's how sprites, nameplates, and impostors stay readable in a 3D scene regardless of the camera angle.
โ ๏ธ Cache Camera.main โ don't call it every frame
Camera.main searches the scene for a camera tagged "MainCamera" each time you call it. Calling it in LateUpdate for dozens of nameplates is a real performance drain. Cache it once in Start, as above. (If your camera can change at runtime, refresh the cache on that event instead of polling.)
Floating Damage Numbers
Now the satisfying part: a number that pops off an enemy when it's hit, floats up, and fades out. Each number is a tiny World Space canvas (or a TMP text set to world space) with a script that animates it over a short lifetime, then deactivates itself.
The animation is pure interpolation over a lifetime t: rise on Y, drift a little, and fade the alpha to 0. It also billboards (reuse the component above, or fold it in). When the lifetime ends it returns itself to the pool:
using UnityEngine;
using TMPro;
public class DamageNumber : MonoBehaviour
{
[SerializeField] TMP_Text label;
[SerializeField] float lifetime = 0.9f;
[SerializeField] float riseSpeed = 1.4f; // meters/second upward
DamageNumberPool pool; // who to return to
float age;
Color baseColor;
// Called by the pool when this number is handed out.
public void Play(string text, Vector3 worldPos, DamageNumberPool owner)
{
pool = owner;
label.text = text;
transform.position = worldPos;
age = 0f;
baseColor = label.color;
baseColor.a = 1f;
label.color = baseColor;
}
void Update()
{
age += Time.deltaTime;
// rise upward over time
transform.position += Vector3.up * riseSpeed * Time.deltaTime;
// fade alpha from 1 โ 0 across the lifetime
float k = age / lifetime; // 0..1
Color c = baseColor;
c.a = Mathf.Lerp(1f, 0f, k);
label.color = c;
if (age >= lifetime)
pool.Return(this); // recycle instead of Destroy
}
}
That's a complete, self-contained popup: spawn it at the hit point, it rises and fades, then hands itself back. The only question left is how to spawn them cheaply when a fast weapon is landing five hits a second.
Pooling the Damage Numbers
The naive approach โ Instantiate a damage number on every hit and Destroy it when it fades โ works, but it hammers the garbage collector. Every Destroy leaves memory to clean up later, and in a busy fight that causes frame hitches. The fix is an object pool: create a batch once, then hand out and recycle the same objects forever.
Here's a compact pool built on Unity's simple queue pattern. (Module 12 covers pooling in depth; this is the practical version you need now.)
using System.Collections.Generic;
using UnityEngine;
public class DamageNumberPool : MonoBehaviour
{
[SerializeField] DamageNumber prefab;
[SerializeField] int prewarm = 16;
readonly Queue<DamageNumber> available = new();
void Awake()
{
for (int i = 0; i < prewarm; i++)
available.Enqueue(CreateOne());
}
DamageNumber CreateOne()
{
DamageNumber n = Instantiate(prefab, transform);
n.gameObject.SetActive(false);
return n;
}
// Spawn a number at a world position.
public void Spawn(string text, Vector3 worldPos)
{
DamageNumber n = available.Count > 0 ? available.Dequeue() : CreateOne();
n.gameObject.SetActive(true);
n.Play(text, worldPos, this);
}
// Called by a DamageNumber when its lifetime ends.
public void Return(DamageNumber n)
{
n.gameObject.SetActive(false);
available.Enqueue(n);
}
}
Now wire it to the health event. When the enemy takes damage, the same OnHealthChanged (or a dedicated OnDamaged(int amount) event) tells the pool to spawn a number above the enemy:
// On the enemy: turn a damage event into a popup
[SerializeField] DamageNumberPool pool;
[SerializeField] Transform popupAnchor; // a point above the head
void HandleDamaged(int amount)
{
pool.Spawn(amount.ToString(), popupAnchor.position);
}
๐ก Prewarm to taste. Setprewarmto a bit more than the most numbers you expect on screen at once. If the pool ever runs dry it quietly makes one more (theCreateOne()fallback), so you never break โ you just lose the pooling benefit for that spike. Size it so that rarely happens.
The full lifecycle of one pooled damage number:
Figure 2: The damage-number spawn/fade lifecycle. Numbers cycle between the pool and the scene โ never destroyed, so no GC churn.
โ ๏ธ Reset state on reuse, not just on create
Because a pooled object is reused, anything that changed during its last life must be reset when it's handed out again. That's why Play() resets age, position, and alpha every time โ not in Awake. Forgetting this gives you the classic "recycled objects appear half-faded or in the wrong spot" bug.
Mini-Project: Assemble the Enemy
Time to put it all together into one reusable enemy. Everything below reuses pieces from this module โ nothing new to invent.
- Enemy base: a capsule or your enemy model with a
Healthcomponent (Lesson 4.3) exposingOnHealthChanged, and anOnDamaged(int)event. - Nameplate prefab: a World Space canvas (scale ~0.01), parented above the head, with a name label + Filled health bar + the
NameplateandBillboardscripts. CallSetName("Goblin Scout")on spawn. - Damage pool: one
DamageNumberPoolin the scene, prewarmed, with aDamageNumberprefab (TMP text, world space,Billboard). - Wire the events: the nameplate's bar subscribes to
OnHealthChanged; the enemy'sHandleDamagedcallspool.SpawnonOnDamaged. - Test harness: an Input Action that raycasts from the camera (or just a key) to deal, say, 8 damage to whatever enemy you hit.
Press Play, hit an enemy repeatedly, and you should see: the nameplate always facing you, its bar draining, and a stream of "8" numbers rising and fading above the enemy โ all without a single frame hitch, because the numbers are pooled.
๐ What you just built
This is genuinely production-grade UI plumbing: world-anchored, camera-facing, event-driven, and pooled. Swap the enemy model, retint the numbers, add a crit color โ the architecture doesn't change. That's the payoff of the whole module.
Extend It
๐๏ธ Challenge 1: Critical hits & color
Add an OnDamaged(int amount, bool isCrit) variant. When isCrit is true, spawn a bigger, gold-tinted number (scale the TMP up in Play()) and give it a little extra rise speed. Non-crits stay small and white. Remember to reset the scale and color on every reuse, not just at creation.
๐ก Hint: my crit styling sticks to normal hits afterward
That's the reuse-reset trap. Set both the crit and non-crit styling explicitly at the top of Play(), so whichever number gets recycled always starts from a known state rather than inheriting its previous life.
โ Success check
Crits pop large and gold, normal hits stay small and white, and after a crit fades and its object is reused for a normal hit, the normal number looks correct โ no leftover gold or oversize.
๐๏ธ Challenge 2: Fade the nameplate by distance
Add a script that fades a nameplate's CanvasGroup alpha based on distance from the camera โ fully visible up close, invisible past, say, 25 meters. This declutters a crowded scene and is a staple of real games. Reuse the Camera.main caching lesson and a Mathf.InverseLerp for the distance-to-alpha mapping.
๐ฏ Quick Quiz
Question 1: Why run the billboard rotation in LateUpdate instead of Update?
Question 2: Why pool damage numbers instead of Instantiate/Destroy on each hit?
Question 3: A recycled damage number appears half-faded and in the wrong place. What's wrong?
Summary
๐ Key Takeaways
- World Space canvases put UI into the scene as real objects; scale them to ~
0.01and parent them above the character. - Billboarding in
LateUpdateโ copying the camera's forward โ keeps plates readable and flat; cacheCamera.main, never call it per frame. - Damage numbers are self-animating popups that rise and Lerp their alpha to 0 over a short lifetime.
- Object pooling recycles those popups instead of Instantiate/Destroy, killing GC hitches in busy fights.
- Reset a pooled object's state in its Play/hand-out method, not just at creation.
- Both systems feed off the same health/damage events from Lesson 4.3 โ one event, many listeners.
๐ What's Next?
That wraps Module 4 โ you can now build menus, a pause system, a live HUD, and world-anchored UI. Next we move from a single scene to many. In Lesson 5.1: Loading Scenes โ Single, Async & Loading Screens you'll load levels without freezing the game, show a progress bar while they stream in, and set up the scene flow real games ship with.
๐ UI that lives in the world
Nameplates that face you, damage that pops and fades, all pooled and event-driven. You've built the last piece of a real game's UI layer โ and every technique here reappears the moment you make anything with enemies.