Skip to main content

πŸ“ 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.onClick and 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 fromGameObjects on a Canvas, with componentsUXML markup + USS stylesheets (web-like)
Scripted withUnityEngine.UI + TMProUnityEngine.UIElements
Feels likePlacing objects in the sceneBuilding a web page
Best forWorld-space UI, most in-game HUDs, quick setupComplex/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:

ComponentRoleNamespace
TMP_Text / TextMeshProUGUICrisp text labels (score, timers, dialogue)TMPro
ImageSprites, icons, bars (supports "fill" for progress)UnityEngine.UI
ButtonClickable button with an onClick UnityEventUnityEngine.UI
SliderA 0–1 value with a handle (volume, health)UnityEngine.UI
TMP_InputFieldText 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.

Buttons and Sliders

Input flows the other way here: the UI element tells you when the user interacts. A Button's onClick is a UnityEvent (Lesson 3.2) β€” you can wire it in the Inspector or subscribe in code.

using UnityEngine;
using UnityEngine.UI;

public class PauseMenu : MonoBehaviour
{
    [SerializeField] private Button resumeButton;
    [SerializeField] private Button quitButton;

    void OnEnable()
    {
        // Subscribe in code (mirror the Lesson 1.2 / 3.2 pattern).
        resumeButton.onClick.AddListener(OnResume);
        quitButton.onClick.AddListener(OnQuit);
    }

    void OnDisable()
    {
        resumeButton.onClick.RemoveListener(OnResume);   // always clean up
        quitButton.onClick.RemoveListener(OnQuit);
    }

    private void OnResume() => GameManager.Instance.SetState(GameState.Playing);
    private void OnQuit()   => GameManager.Instance.SetState(GameState.MainMenu);
}

A Slider reports value changes through onValueChanged (also a UnityEvent), passing the new float:

[SerializeField] private Slider volumeSlider;

void OnEnable()  => volumeSlider.onValueChanged.AddListener(SetVolume);
void OnDisable() => volumeSlider.onValueChanged.RemoveListener(SetVolume);

private void SetVolume(float value) => AudioListener.volume = value;   // 0..1

πŸ’‘ Inspector wiring vs code β€” same trade-off as Lesson 3.2

You can also assign a Button's action in its On Click () list in the Inspector β€” no code β€” which is great for designers and simple hooks. Wire in code (as above) when the target is created at runtime or when you want the wiring visible in source and version control. Both use the same underlying UnityEvent.

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}";
}
graph LR G["GameManager
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!");
}
ConceptuGUIUI Toolkit
An element is a…GameObject + componentVisualElement in a tree
You find it with…[SerializeField] referenceroot.Q<T>("name") query
Button click is…onClick (UnityEvent)clicked (C# event)
StylingPer-component in InspectorUSS 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:

  1. Add a Canvas. Add a Text - TextMeshPro for the score and an Image (Image Type = Filled) for the health bar.
  2. Write a HUD script that subscribes to GameManager.Instance.ScoreChanged in OnEnable, unsubscribes in OnDisable, and updates the label β€” plus seeds the value in Start.
  3. Add a Button whose onClick calls GameManager.Instance.SetState(GameState.Paused) β€” wire it in code or the Inspector.
  4. Add a SetHealth(current, max) path that sets the fill Image's fillAmount (cast to float!).
  5. Play: gaining score updates the label only when it changes, and the pause button freezes the game (Time.timeScale = 0 from 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 legacy Text.
  • UI input comes back via UnityEvents: Button.onClick, Slider.onValueChanged β€” AddListener/RemoveListener (mirror in OnEnable/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 in Start.
  • 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

πŸš€ 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.