Game UI is the thing players look at every single second but only notice when it's bad. A cluttered HUD, a tooltip that blocks the action, a minimap that doesn't update — these kill immersion faster than any bug in your gameplay code. The good news is that Unity's Canvas system is genuinely powerful once you understand how to structure it.
TL;DR: This tutorial covers building a complete game UI system in Unity — Canvas architecture, animated health bars, pooled damage popups, a flexible tooltip system, a render-texture minimap, screen fade transitions, and interaction prompts. Every script is available for free, or grab the full UI Toolkit Kit for a pre-integrated package.
We'll build each UI component independently so you can pick what you need. A survival game might want health bars, tooltips, and a minimap. A platformer might just need damage popups and screen fades. A narrative game might need interaction prompts and transitions. Let's get into it.
Canvas Architecture: Static vs. Dynamic
Before building any UI elements, you need to get your Canvas structure right. The single biggest performance mistake in Unity UI is putting everything on one Canvas. Every time any element on a Canvas changes — a health bar ticks down, a damage number spawns — Unity rebuilds the entire Canvas mesh. With 50 static elements and 1 animated element on the same Canvas, you're rebuilding 51 elements every frame.
The fix is simple: separate your UI into at least two Canvases.
- Static Canvas: Background frames, labels, icons, minimap border — anything that doesn't change during gameplay. This mesh gets built once and stays cached.
- Dynamic Canvas: Health bars, damage numbers, cooldown timers, score displays — anything that updates frequently. Rebuilds are limited to this Canvas only.
- World-Space Canvas (optional): Floating health bars above enemies, interaction prompts, name tags. These live in 3D space and follow their target transforms.
Set both screen-space Canvases to Screen Space - Overlay and use a Canvas Scaler with Scale With Screen Size (reference 1920x1080, match 0.5). This gives you consistent sizing across devices. For more on mobile-specific optimizations, check out our mobile performance guide.
Animated Health Bars
A health bar that just snaps to the new value feels cheap. A health bar that smoothly drains with a delayed "damage preview" trail feels polished. Our Health Bar UI script works with the Health System to create exactly that:
using UnityEngine;
using UnityEngine.UI;
public class AnimatedHealthBar : MonoBehaviour
{
[SerializeField] private Image foregroundFill;
[SerializeField] private Image trailingFill;
[SerializeField] private float trailingSpeed = 2f;
[SerializeField] private float trailingDelay = 0.4f;
private float targetFill = 1f;
private float delayTimer;
public void SetHealth(float current, float max)
{
float newFill = Mathf.Clamp01(current / max);
if (newFill < targetFill)
delayTimer = trailingDelay;
else
trailingFill.fillAmount = newFill;
targetFill = newFill;
foregroundFill.fillAmount = newFill;
}
void Update()
{
if (trailingFill.fillAmount > targetFill)
{
delayTimer -= Time.deltaTime;
if (delayTimer <= 0f)
{
trailingFill.fillAmount = Mathf.MoveTowards(
trailingFill.fillAmount, targetFill, trailingSpeed * Time.deltaTime);
}
}
}
}Use two overlapping Image components set to Filled → Horizontal. The foreground (green/red) snaps to the current value immediately. The trailing fill (yellow or white) catches up after a short delay. This dual-bar technique is used in Street Fighter, Dark Souls, and basically every modern game with visible health.
Pooled Damage Popups
Damage numbers are satisfying, but spawning and destroying TextMeshPro objects every hit generates garbage. When you've got 30 enemies on screen in a wave-based game, that garbage adds up to frame hitches. The fix is object pooling — pre-create a bunch of popup objects and recycle them. Our Damage Popup script handles the visual, and our Object Pool script handles the recycling:
using UnityEngine;
using TMPro;
using System.Collections.Generic;
public class DamagePopupPool : MonoBehaviour
{
[SerializeField] private GameObject popupPrefab;
[SerializeField] private int poolSize = 20;
[SerializeField] private float lifetime = 0.8f;
[SerializeField] private float floatSpeed = 2f;
private Queue<GameObject> pool = new Queue<GameObject>();
void Awake()
{
for (int i = 0; i < poolSize; i++)
{
GameObject obj = Instantiate(popupPrefab, transform);
obj.SetActive(false);
pool.Enqueue(obj);
}
}
public void Spawn(Vector3 worldPos, float damage, bool isCritical)
{
if (pool.Count == 0) return;
GameObject popup = pool.Dequeue();
popup.transform.position = worldPos + Vector3.up * 1.5f;
popup.SetActive(true);
var tmp = popup.GetComponent<TextMeshPro>();
tmp.text = isCritical ? $"{damage:F0}!" : $"{damage:F0}";
tmp.color = isCritical ? Color.yellow : Color.white;
tmp.fontSize = isCritical ? 8f : 5f;
StartCoroutine(AnimatePopup(popup, tmp));
}
private System.Collections.IEnumerator AnimatePopup(GameObject popup, TextMeshPro tmp)
{
float elapsed = 0f;
Vector3 startPos = popup.transform.position;
Color startColor = tmp.color;
float xOffset = Random.Range(-0.5f, 0.5f);
while (elapsed < lifetime)
{
elapsed += Time.deltaTime;
float t = elapsed / lifetime;
popup.transform.position = startPos + new Vector3(xOffset * t, floatSpeed * t, 0);
tmp.color = new Color(startColor.r, startColor.g, startColor.b, 1f - t);
yield return null;
}
popup.SetActive(false);
pool.Enqueue(popup);
}
}With a pool of 20 popups, you can handle rapid-fire combat without any allocations. The random horizontal offset prevents numbers from stacking directly on top of each other. If you need more than 20 simultaneous popups, bump up the pool size — inactive objects have near-zero overhead.
Tooltip System
Tooltips seem simple until you realize they need to follow the mouse, stay on screen, resize to fit content, and appear with a slight delay so they don't flicker during quick mouse movements. Our Tooltip System handles all of this:
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class TooltipManager : MonoBehaviour
{
public static TooltipManager Instance { get; private set; }
[SerializeField] private RectTransform tooltipPanel;
[SerializeField] private TextMeshProUGUI headerText;
[SerializeField] private TextMeshProUGUI bodyText;
[SerializeField] private LayoutElement layoutElement;
[SerializeField] private int maxWidth = 300;
[SerializeField] private float showDelay = 0.3f;
[SerializeField] private Vector2 offset = new Vector2(16, -16);
private float showTimer;
private bool pendingShow;
private Canvas parentCanvas;
private RectTransform canvasRect;
void Awake()
{
Instance = this;
parentCanvas = GetComponentInParent<Canvas>();
canvasRect = parentCanvas.GetComponent<RectTransform>();
Hide();
}
void Update()
{
if (pendingShow)
{
showTimer -= Time.unscaledDeltaTime;
if (showTimer <= 0f)
{
tooltipPanel.gameObject.SetActive(true);
pendingShow = false;
}
}
if (tooltipPanel.gameObject.activeSelf)
PositionTooltip();
}
public void Show(string header, string body)
{
headerText.text = header;
bodyText.text = body;
int longest = Mathf.Max(header.Length, body.Length);
layoutElement.enabled = longest > 40;
layoutElement.preferredWidth = maxWidth;
headerText.gameObject.SetActive(!string.IsNullOrEmpty(header));
showTimer = showDelay;
pendingShow = true;
}
public void Hide()
{
pendingShow = false;
tooltipPanel.gameObject.SetActive(false);
}
private void PositionTooltip()
{
Vector2 mousePos;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvasRect, Input.mousePosition, parentCanvas.worldCamera, out mousePos);
Vector2 pos = mousePos + offset;
Vector2 size = tooltipPanel.sizeDelta;
Vector2 canvasSize = canvasRect.sizeDelta;
if (pos.x + size.x > canvasSize.x * 0.5f)
pos.x = mousePos.x - size.x - offset.x;
if (pos.y - size.y < -canvasSize.y * 0.5f)
pos.y = mousePos.y + size.y + Mathf.Abs(offset.y);
tooltipPanel.anchoredPosition = pos;
}
}To trigger tooltips from any UI element, add an EventTrigger or implement IPointerEnterHandler on your item slots, skill icons, or inventory items. Call TooltipManager.Instance.Show("Iron Sword", "+15 Attack\nDurability: 80/100") on hover and Hide() on exit. The showDelay prevents flickering when the player quickly moves between adjacent elements.
Render-Texture Minimap
A minimap is just a second camera rendering to a texture that you display on a UI RawImage. It's one of those features that sounds complicated but is actually straightforward once you set it up. Our Minimap System script wraps the whole thing:
using UnityEngine;
using UnityEngine.UI;
public class MinimapController : MonoBehaviour
{
[SerializeField] private Camera minimapCamera;
[SerializeField] private RawImage minimapDisplay;
[SerializeField] private Transform player;
[SerializeField] private float height = 40f;
[SerializeField] private float zoomLevel = 30f;
[SerializeField] private bool rotateWithPlayer = false;
private RenderTexture renderTexture;
void Start()
{
int size = Mathf.RoundToInt(minimapDisplay.rectTransform.sizeDelta.x);
renderTexture = new RenderTexture(size, size, 16);
minimapCamera.targetTexture = renderTexture;
minimapDisplay.texture = renderTexture;
minimapCamera.orthographic = true;
minimapCamera.orthographicSize = zoomLevel;
minimapCamera.cullingMask &= ~(1 << LayerMask.NameToLayer("UI"));
}
void LateUpdate()
{
if (player == null) return;
Vector3 camPos = player.position;
camPos.y = height;
minimapCamera.transform.position = camPos;
if (rotateWithPlayer)
minimapCamera.transform.rotation = Quaternion.Euler(90f, player.eulerAngles.y, 0f);
else
minimapCamera.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
}
void OnDestroy()
{
if (renderTexture != null)
renderTexture.Release();
}
}Create a second Camera in your scene, point it straight down, and set its culling mask to only render the layers you want on the minimap (terrain, enemies, key landmarks — skip particle effects and detailed props). The rotateWithPlayer option determines whether north is always up or the map rotates to match the player's facing direction. RPGs typically use fixed north; action games often rotate.
Screen Fade Transitions
Cutting directly between scenes feels jarring. A half-second fade to black between areas makes the experience feel polished and intentional. Our Screen Fader script pairs with the Scene Manager for seamless level transitions:
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
using UnityEngine.SceneManagement;
public class ScreenFader : MonoBehaviour
{
public static ScreenFader Instance { get; private set; }
[SerializeField] private Image fadeImage;
[SerializeField] private float fadeDuration = 0.5f;
private bool isFading;
void Awake()
{
Instance = this;
DontDestroyOnLoad(gameObject);
fadeImage.color = Color.clear;
fadeImage.raycastTarget = false;
}
public void TransitionToScene(string sceneName)
{
if (!isFading)
StartCoroutine(FadeAndLoad(sceneName));
}
private IEnumerator FadeAndLoad(string sceneName)
{
isFading = true;
fadeImage.raycastTarget = true;
yield return StartCoroutine(Fade(0f, 1f));
SceneManager.LoadScene(sceneName);
yield return null;
yield return StartCoroutine(Fade(1f, 0f));
fadeImage.raycastTarget = false;
isFading = false;
}
private IEnumerator Fade(float from, float to)
{
float elapsed = 0f;
Color color = fadeImage.color;
while (elapsed < fadeDuration)
{
elapsed += Time.unscaledDeltaTime;
color.a = Mathf.Lerp(from, to, elapsed / fadeDuration);
fadeImage.color = color;
yield return null;
}
color.a = to;
fadeImage.color = color;
}
}The fade Image should be a full-screen black Image on its own Canvas (highest sort order). Using Time.unscaledDeltaTime ensures the fade works even when Time.timeScale is 0 — important for pause menu transitions. The raycastTarget toggle prevents clicks from passing through the fade overlay during the transition.
Interaction Prompts
The "Press E to interact" prompt is one of those small UI pieces that every game needs. It should appear when the player is near an interactable object and disappear when they walk away. Our Interaction System handles the detection logic — here's a lightweight prompt display that works with it:
using UnityEngine;
using TMPro;
public class InteractionPrompt : MonoBehaviour
{
[SerializeField] private GameObject promptUI;
[SerializeField] private TextMeshProUGUI promptText;
[SerializeField] private float detectionRadius = 2f;
[SerializeField] private LayerMask interactableLayer;
[SerializeField] private KeyCode interactKey = KeyCode.E;
private IInteractable currentTarget;
void Update()
{
Collider[] hits = Physics.OverlapSphere(transform.position, detectionRadius, interactableLayer);
if (hits.Length > 0)
{
float closest = float.MaxValue;
IInteractable best = null;
foreach (var hit in hits)
{
var interactable = hit.GetComponent<IInteractable>();
if (interactable == null) continue;
float dist = Vector3.Distance(transform.position, hit.transform.position);
if (dist < closest)
{
closest = dist;
best = interactable;
}
}
if (best != null)
{
currentTarget = best;
promptUI.SetActive(true);
promptText.text = $"Press {interactKey} - {best.InteractionLabel}";
if (Input.GetKeyDown(interactKey))
best.Interact();
}
}
else
{
currentTarget = null;
promptUI.SetActive(false);
}
}
}
public interface IInteractable
{
string InteractionLabel { get; }
void Interact();
}Any object that implements IInteractable automatically works with this system — treasure chests, NPCs, doors, switches. The prompt displays the object's custom label ("Open Chest", "Talk to Merchant", "Pull Lever"), so players always know what they're about to do. Put the prompt UI panel on your Dynamic Canvas so it doesn't force a rebuild of static elements.
Putting It All Together
Here's a quick reference for everything we built:
- Health Bar UI — Dual-fill animated health bar with trailing damage preview
- Damage Popup + Object Pool — Zero-allocation floating damage numbers
- Tooltip System — Auto-sizing, screen-clamped tooltips with show delay
- Minimap System — Render-texture minimap with optional rotation
- Screen Fader + Scene Manager — Smooth scene transitions
- Interaction System — Context-aware "Press E" prompts
Grab everything in one shot with the UI Toolkit Kit, which includes all these scripts pre-configured with prefabs, a demo scene, and documentation. Your players might not consciously notice good UI — but they'll definitely feel it.