π Lesson 4.2: UI Scripting
Your GameManager knows the score and state β now the player needs to see it. This lesson connects game logic to on-screen UI: displaying values with TextMeshPro, reacting to Buttons and Sliders, driving a health bar, and β crucially β updating UI from events rather than polling every frame.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Describe Unity's two UI systems β uGUI and UI Toolkit β and when to use each
- Reference and update UI components (
TMP_Text,Image,Slider) from a script - Handle
Button.onClickand other UI callbacks (UnityEvents, Lesson 3.2) - Drive UI from game events instead of polling in
Update - Build a score label, a health bar, and a pause menu wired to the
GameManager - Recognize the basics of UI Toolkit (
UIDocument,VisualElement, queries)
Estimated Time: 60 minutes
Project: A HUD (score + health bar) and a pause menu that react to the GameManager from Lesson 4.1.
In This Lesson
Two UI Systems
Unity has two built-in UI systems. Knowing which you're using avoids a lot of confusion:
| uGUI (Unity UI) | UI Toolkit | |
|---|---|---|
| Built from | GameObjects on a Canvas, with components | UXML markup + USS stylesheets (web-like) |
| Scripted with | UnityEngine.UI + TMPro | UnityEngine.UIElements |
| Feels like | Placing objects in the scene | Building a web page |
| Best for | World-space UI, most in-game HUDs, quick setup | Complex/large UIs, editor tools, data-heavy screens |
π‘ Which does this lesson use?
We focus on uGUI β it's the most common for gameplay HUDs, works in world space, and its component-and-GameObject model maps directly onto everything you've learned (it's just more MonoBehaviours to reference). We finish with a tour of UI Toolkit so you recognize it. Both ship with Unity; pick per project.
In uGUI, all UI lives under a Canvas, and every UI element uses a RectTransform (a 2D-anchored cousin of the Transform from Lesson 1.1) for layout. From a scripting standpoint, though, UI elements are just components β you reference and drive them exactly like any other.
uGUI Building Blocks
The components you'll script most often:
| Component | Role | Namespace |
|---|---|---|
TMP_Text / TextMeshProUGUI | Crisp text labels (score, timers, dialogue) | TMPro |
Image | Sprites, icons, bars (supports "fill" for progress) | UnityEngine.UI |
Button | Clickable button with an onClick UnityEvent | UnityEngine.UI |
Slider | A 0β1 value with a handle (volume, health) | UnityEngine.UI |
TMP_InputField | Text entry (name, chat) | TMPro |
β Use TextMeshPro, not legacy Text
Unity's old Text component (UnityEngine.UI.Text) is superseded by TextMeshPro (TMPro), which renders far sharper at any size and offers rich formatting. Create text via UI β Text - TextMeshPro and reference it as TMP_Text. Reach for legacy Text only in old projects.
π‘ To script a UI element you need a reference to it β the same wiring skill from Lesson 1.3. Expose a [SerializeField] field of the component's type and drag the UI object into it in the Inspector.
Updating Text & Images
Displaying a value is just: hold a reference to the label, and assign its text when the value changes.
using UnityEngine;
using TMPro; // for TMP_Text
public class ScoreLabel : MonoBehaviour
{
[SerializeField] private TMP_Text label; // drag the TextMeshPro object here
public void Show(int score)
{
label.text = $"Score: {score}"; // update the on-screen text
}
}
An Image set to Filled mode is the simplest health/progress bar β its fillAmount (0β1) controls how much shows:
using UnityEngine;
using UnityEngine.UI; // for Image
public class HealthBar : MonoBehaviour
{
[SerializeField] private Image fill; // an Image with Image Type = Filled
public void SetHealth(int current, int max)
{
fill.fillAmount = (float)current / max; // e.g. 30/100 β 0.3 bar
}
}
β οΈ Don't rebuild strings every frame
Assigning label.text allocates a new string, and building it in Update generates garbage 60 times a second (Lesson 5.1). Update UI text only when the value changes, not every frame β which is exactly what event-driven UI (two sections down) gives you for free. Watch integer division too: current / max with two ints truncates to 0 β cast to float first, as above.
Event-Driven UI
Here's where Modules 3 and 4 pay off together. A naive HUD polls the manager every frame:
// β Wasteful: rebuilds the string 60x/second even when the score never changes.
void Update()
{
scoreLabel.text = $"Score: {GameManager.Instance.Score}";
}
Better: the GameManager already raises events when things change (Lesson 4.1). The UI just subscribes and updates only on change β no Update, no waste, no coupling from the manager's side.
using UnityEngine;
using TMPro;
public class HUD : MonoBehaviour
{
[SerializeField] private TMP_Text scoreLabel;
[SerializeField] private HealthBar healthBar;
void OnEnable()
{
// React to the manager's announcements (Lesson 3.2 subscribe pattern).
GameManager.Instance.ScoreChanged += UpdateScore;
}
void OnDisable()
{
GameManager.Instance.ScoreChanged -= UpdateScore;
}
void Start()
{
UpdateScore(GameManager.Instance.Score); // set the initial value once
}
private void UpdateScore(int score) => scoreLabel.text = $"Score: {score}";
}
AddScore()"] -->|"raises ScoreChanged"| EV["event"] EV -.->|"UpdateScore(score)"| HUD["HUD: set label.text"] style G fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style EV fill:#eff6ff,stroke:#3b82f6,stroke-width:2px
β The professional pattern
Game logic raises events; UI subscribes and updates on change. The manager doesn't know the UI exists (no coupling), the UI does zero per-frame work, and adding a second display (a mini-map score, an end screen) is just another subscriber. This is the same decoupling from Lesson 3.2, now wiring your game to its interface.
π‘ Don't forget the initial value: events fire on change, so a fresh UI shows nothing until the first change. Call the update method once in Start (as above) to seed the current value.
A Look at UI Toolkit
UI Toolkit is Unity's newer, web-inspired UI system. Instead of GameObjects, you define structure in UXML (like HTML) and style it in USS (like CSS), then drive it from C# via the UnityEngine.UIElements API. A UIDocument component renders it in a scene.
using UnityEngine;
using UnityEngine.UIElements; // UI Toolkit API
public class MenuController : MonoBehaviour
{
[SerializeField] private UIDocument document; // holds the UXML tree
void OnEnable()
{
VisualElement root = document.rootVisualElement;
// Query elements by name (#id) or type β like CSS selectors / querySelector.
Button playButton = root.Q<Button>("play-button");
Label scoreLabel = root.Q<Label>("score");
playButton.clicked += OnPlay; // UI Toolkit uses C# events, not UnityEvents
scoreLabel.text = "Score: 0";
}
private void OnPlay() => Debug.Log("Play!");
}
| Concept | uGUI | UI Toolkit |
|---|---|---|
| An element is a⦠| GameObject + component | VisualElement in a tree |
| You find it with⦠| [SerializeField] reference | root.Q<T>("name") query |
| Button click is⦠| onClick (UnityEvent) | clicked (C# event) |
| Styling | Per-component in Inspector | USS stylesheets |
π‘ Which to choose?
For most in-game HUDs and world-space UI, uGUI is simpler and well-supported. For large, complex, or data-driven interfaces β and for custom Editor tools β UI Toolkit scales better and its CSS-like styling is powerful. The core skills transfer: reference an element, react to its events, update it on change. That pattern is identical in both.
Exercise & Quiz
ποΈ Exercise: Wire a HUD to the GameManager
Objective: Build an event-driven score label and a health bar, plus a working pause button β all reacting to the GameManager from Lesson 4.1.
Instructions:
- Add a Canvas. Add a Text - TextMeshPro for the score and an
Image(Image Type = Filled) for the health bar. - Write a
HUDscript that subscribes toGameManager.Instance.ScoreChangedinOnEnable, unsubscribes inOnDisable, and updates the label β plus seeds the value inStart. - Add a
ButtonwhoseonClickcallsGameManager.Instance.SetState(GameState.Paused)β wire it in code or the Inspector. - Add a
SetHealth(current, max)path that sets the fill Image'sfillAmount(cast tofloat!). - Play: gaining score updates the label only when it changes, and the pause button freezes the game (
Time.timeScale = 0from Lesson 4.1).
Starter Code:
using UnityEngine;
using TMPro;
public class HUD : MonoBehaviour
{
[SerializeField] private TMP_Text scoreLabel;
void OnEnable()
{
// TODO: subscribe to GameManager.Instance.ScoreChanged.
}
void OnDisable()
{
// TODO: unsubscribe.
}
// TODO: UpdateScore(int) that sets scoreLabel.text; call it once in Start.
}
π‘ Hint
GameManager.Instance.ScoreChanged += UpdateScore; in OnEnable, -= in OnDisable. UpdateScore(int score) => scoreLabel.text = $"Score: {score}";. In Start, call UpdateScore(GameManager.Instance.Score) so the label shows the current value immediately. For the bar, fill.fillAmount = (float)current / max;.
β Solution
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class HUD : MonoBehaviour
{
[SerializeField] private TMP_Text scoreLabel;
[SerializeField] private Image healthFill;
[SerializeField] private Button pauseButton;
void OnEnable()
{
GameManager.Instance.ScoreChanged += UpdateScore;
pauseButton.onClick.AddListener(Pause);
}
void OnDisable()
{
GameManager.Instance.ScoreChanged -= UpdateScore;
pauseButton.onClick.RemoveListener(Pause);
}
void Start()
{
UpdateScore(GameManager.Instance.Score); // seed initial value
}
private void UpdateScore(int score) => scoreLabel.text = $"Score: {score}";
public void SetHealth(int current, int max) =>
healthFill.fillAmount = (float)current / max;
private void Pause() => GameManager.Instance.SetState(GameState.Paused);
}
The HUD reacts to the manager's events (no polling, no per-frame cost) and commands it back through Instance on a button press β exactly the "broadcast out, command in" balance from Lesson 4.1, now bridging logic and interface.
π― Quick Quiz
Question 1: Why is updating a score label from a game event better than doing it in Update?
Question 2: A Button's onClick is what kind of thing, familiar from Lesson 3.2?
Question 3: Your health bar always shows empty. The code is fill.fillAmount = current / max; with int values. What's wrong?
Summary
π Key Takeaways
- Unity has two UI systems: uGUI (Canvas + GameObjects, great for HUDs) and UI Toolkit (UXML/USS, great for complex/editor UI).
- Script UI by referencing components with
[SerializeField]and assigning their properties βTMP_Text.text,Image.fillAmount, etc. Prefer TextMeshPro over legacyText. - UI input comes back via UnityEvents:
Button.onClick,Slider.onValueChangedβAddListener/RemoveListener(mirror inOnEnable/OnDisable) or wire in the Inspector. - Drive UI from game events, not
Update: subscribe to manager events (Lesson 3.2 / 4.1) and update only on change β no per-frame allocations, no coupling. Seed the initial value inStart. - Watch integer division for fill amounts β cast to
float. - UI Toolkit mirrors the same skills: query an element (
root.Q<T>), react to its C# events, update on change.
π Additional Resources
- Manual β Unity UI (uGUI)
- Manual β TextMeshPro
- Manual β UI Toolkit
- Scripting Reference β Button
π What's Next?
Your game shows its state and takes UI input. The last piece of a complete loop is persistence: Lesson 4.3, Saving and Loading, covers writing game state to disk with JSON serialization and PlayerPrefs, so progress survives between sessions.
π Your game has a face!
Score, health, menus β the player can finally see and steer the systems you built. Logic and interface, cleanly connected by events.