Back to Gaming

Unity Game Engine Series Part 5: UI Systems

March 31, 2026 Wasil Zafar 40 min read

Build polished, responsive game interfaces using Unity's powerful UI systems. From the battle-tested uGUI Canvas system to the modern UI Toolkit, master RectTransforms, layout groups, the Event System, and advanced responsive design techniques that scale across every screen size.

Table of Contents

  1. The Canvas System
  2. uGUI Elements
  3. Event System & Input
  4. UI Toolkit (Modern)
  5. Advanced UI Techniques
  6. History: OnGUI to UI Toolkit
  7. Exercises & Self-Assessment
  8. UI Design Spec Generator
  9. Conclusion & Next Steps

Introduction: The Player's Window into Your Game

Series Overview: This is Part 5 of our 16-part Unity Game Engine Series. We focus on building professional UI systems — from health bars and inventory screens to dialogue systems and HUDs — using both the established uGUI system and the modern UI Toolkit.

A game's user interface (UI) is the critical bridge between the player and the game world. It communicates health, inventory, quests, scores, menus, dialogues, maps, and every piece of information the player needs. A great UI is invisible — the player absorbs information effortlessly without being pulled out of the experience. A bad UI frustrates, confuses, and can ruin an otherwise excellent game.

Unity offers two distinct UI systems: the mature, battle-tested uGUI (Unity UI) system introduced in Unity 4.6, and the modern UI Toolkit that brings web-style development (CSS/HTML-like) to Unity. Understanding both is essential for professional game development, as each excels in different scenarios.

Key Insight: UI is often the last thing developers think about and the first thing players judge. Studies show players form their initial impression of game quality within the first 30 seconds — before any gameplay — based entirely on the menu UI. Investing in polished UI pays massive dividends.

1. The Canvas System

The Canvas is the foundation of Unity's uGUI system. Every UI element must be a child of a Canvas component. Think of the Canvas as a transparent sheet of glass placed in front of (or within) your game world — all UI elements are painted onto this glass.

1.1 Canvas Render Modes

Unity provides three render modes that control how the Canvas is drawn relative to the game world:

Render Mode Description Use Cases Performance
Screen Space - Overlay UI rendered on top of everything, sized to screen. No camera needed. HUD, menus, health bars, score displays Fastest — no camera calculations
Screen Space - Camera UI rendered at a specified distance from a camera. Affected by camera settings. UI with post-processing effects, 3D perspective on UI, particle effects behind UI Moderate — requires camera reference
World Space Canvas behaves like a 3D object in the scene. Can be positioned, rotated, scaled. In-game screens, floating nameplates, VR/AR interfaces, diegetic UI (monitors, signs) Varies — rendered with scene geometry
using UnityEngine;
using UnityEngine.UI;

public class CanvasSetupExample : MonoBehaviour
{
    [Header("Canvas Configuration")]
    [SerializeField] private Canvas mainCanvas;
    [SerializeField] private Camera uiCamera;

    private void Start()
    {
        // Screen Space - Overlay (simplest, most common)
        mainCanvas.renderMode = RenderMode.ScreenSpaceOverlay;
        mainCanvas.sortingOrder = 10; // Higher = renders on top

        // Screen Space - Camera (for post-processing on UI)
        // mainCanvas.renderMode = RenderMode.ScreenSpaceCamera;
        // mainCanvas.worldCamera = uiCamera;
        // mainCanvas.planeDistance = 10f;

        // World Space (for in-game screens, VR interfaces)
        // mainCanvas.renderMode = RenderMode.WorldSpace;
        // RectTransform rt = mainCanvas.GetComponent<RectTransform>();
        // rt.sizeDelta = new Vector2(400, 300);
        // rt.localScale = Vector3.one * 0.01f; // Scale down for world units
    }
}
Pro Tip: Use multiple Canvases to optimize performance. Static UI (like a background panel) and dynamic UI (like a health bar updating every frame) should be on separate Canvases. When any element on a Canvas changes, the entire Canvas mesh is rebuilt. Splitting static and dynamic elements prevents unnecessary rebuilds.

1.2 Canvas Scaler Strategies

The Canvas Scaler component controls how UI elements scale across different screen resolutions. This is the single most important component for multi-device UI:

Scale Mode Behavior Best For
Constant Pixel Size UI elements stay the same pixel size regardless of screen resolution Pixel-art games, fixed-resolution targets
Scale With Screen Size UI scales proportionally with screen dimensions. Reference resolution defines the baseline. Most games — mobile, desktop, cross-platform
Constant Physical Size UI elements maintain the same physical size (mm/cm) across devices using DPI Accessibility-focused apps, enterprise tools
using UnityEngine;
using UnityEngine.UI;

public class CanvasScalerSetup : MonoBehaviour
{
    private void SetupResponsiveScaler()
    {
        CanvasScaler scaler = GetComponent<CanvasScaler>();

        // Scale With Screen Size — the gold standard for most games
        scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
        scaler.referenceResolution = new Vector2(1920, 1080);

        // Match Width Or Height: 0 = match width, 1 = match height, 0.5 = balanced
        // For landscape games: lean toward height (0.5-1.0)
        // For portrait games: lean toward width (0.0-0.5)
        scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
        scaler.matchWidthOrHeight = 0.5f; // Balanced scaling

        // Reference Pixels Per Unit affects how sprites map to UI
        scaler.referencePixelsPerUnit = 100;
    }
}

1.3 RectTransform Deep Dive

Every UI element uses a RectTransform instead of a regular Transform. RectTransform adds anchoring, pivots, and size concepts that make UI layout possible. Think of it as a rubber band system — you pin corners of your UI element to relative positions on the parent, and the element stretches or repositions accordingly.

Property Description Analogy
Anchors (Min/Max) Normalized positions (0-1) on the parent that the element's edges are tied to Thumbtacks pinning a poster to a corkboard
Pivot The point around which the element rotates and scales (0,0 = bottom-left, 1,1 = top-right) The nail a picture frame hangs on
sizeDelta The size difference between the element and its anchor rectangle How much bigger or smaller the poster is than the space between the tacks
anchoredPosition Position relative to the anchor point How far the poster hangs from the tacks
offsetMin / offsetMax Distance from anchor corners to the element's corners (left-bottom / right-top) Margins around the poster
using UnityEngine;

public class RectTransformExamples : MonoBehaviour
{
    [SerializeField] private RectTransform healthBar;
    [SerializeField] private RectTransform minimap;
    [SerializeField] private RectTransform notificationPanel;

    private void Start()
    {
        // Health bar: anchored to top-left, fixed size
        healthBar.anchorMin = new Vector2(0f, 1f);     // Top-left
        healthBar.anchorMax = new Vector2(0f, 1f);     // Same point = fixed size
        healthBar.pivot = new Vector2(0f, 1f);         // Pivot at top-left corner
        healthBar.sizeDelta = new Vector2(300f, 40f);  // 300x40 pixels
        healthBar.anchoredPosition = new Vector2(20f, -20f); // 20px from edges

        // Minimap: anchored to bottom-right corner
        minimap.anchorMin = new Vector2(1f, 0f);
        minimap.anchorMax = new Vector2(1f, 0f);
        minimap.pivot = new Vector2(1f, 0f);
        minimap.sizeDelta = new Vector2(200f, 200f);
        minimap.anchoredPosition = new Vector2(-15f, 15f);

        // Notification panel: stretches full width at top
        notificationPanel.anchorMin = new Vector2(0f, 1f);  // Left edge, top
        notificationPanel.anchorMax = new Vector2(1f, 1f);  // Right edge, top
        notificationPanel.pivot = new Vector2(0.5f, 1f);
        notificationPanel.sizeDelta = new Vector2(0f, 60f); // 0 width delta = stretch to anchors
        notificationPanel.anchoredPosition = Vector2.zero;
    }

    // Animate a health bar fill (common pattern)
    public void SetHealthBarFill(float normalizedHealth)
    {
        // Scale the fill image from 0 to 1 on the X axis
        healthBar.localScale = new Vector3(
            Mathf.Clamp01(normalizedHealth), 1f, 1f
        );
    }
}
Common Mistake: Confusing sizeDelta with actual size. When anchors are separated (stretch mode), sizeDelta represents the difference from anchor spacing, not the actual element size. A sizeDelta of (0,0) with spread anchors means the element fills the anchor rectangle exactly. Use rect.width and rect.height to get the actual rendered size.

2. uGUI Elements

2.1 Core UI Elements

Unity's uGUI provides a comprehensive set of ready-to-use UI components:

Element Component Common Uses
Button Button + Image + Text Menu options, actions, confirmations
Text / TMP Text or TextMeshProUGUI Labels, scores, dialogues, descriptions
Image Image (with Sprite) Icons, backgrounds, frames, health bar fills
Slider Slider + Fill + Handle Volume controls, progress bars, character stats
Toggle Toggle + Checkmark Image Settings on/off, inventory selection, filters
Dropdown TMP_Dropdown Resolution selection, difficulty, language choice
InputField TMP_InputField Player name entry, chat, console commands
ScrollView ScrollRect + Mask + Content Inventory lists, chat logs, level select, leaderboards
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class MainMenuUI : MonoBehaviour
{
    [Header("Menu Buttons")]
    [SerializeField] private Button playButton;
    [SerializeField] private Button settingsButton;
    [SerializeField] private Button quitButton;

    [Header("Settings Panel")]
    [SerializeField] private GameObject settingsPanel;
    [SerializeField] private Slider volumeSlider;
    [SerializeField] private TMP_Dropdown resolutionDropdown;
    [SerializeField] private Toggle fullscreenToggle;
    [SerializeField] private TextMeshProUGUI volumeLabel;

    private void Start()
    {
        // Wire up button click events
        playButton.onClick.AddListener(OnPlayClicked);
        settingsButton.onClick.AddListener(OnSettingsClicked);
        quitButton.onClick.AddListener(OnQuitClicked);

        // Wire up settings controls
        volumeSlider.onValueChanged.AddListener(OnVolumeChanged);
        fullscreenToggle.onValueChanged.AddListener(OnFullscreenToggled);
        resolutionDropdown.onValueChanged.AddListener(OnResolutionChanged);

        // Populate resolution dropdown
        PopulateResolutions();

        // Load saved settings
        volumeSlider.value = PlayerPrefs.GetFloat("Volume", 0.75f);
        fullscreenToggle.isOn = Screen.fullScreen;
    }

    private void OnPlayClicked()
    {
        Debug.Log("Starting game...");
        UnityEngine.SceneManagement.SceneManager.LoadScene("Level_01");
    }

    private void OnSettingsClicked()
    {
        settingsPanel.SetActive(!settingsPanel.activeSelf);
    }

    private void OnQuitClicked()
    {
        #if UNITY_EDITOR
        UnityEditor.EditorApplication.isPlaying = false;
        #else
        Application.Quit();
        #endif
    }

    private void OnVolumeChanged(float value)
    {
        AudioListener.volume = value;
        volumeLabel.text = $"Volume: {value * 100:F0}%";
        PlayerPrefs.SetFloat("Volume", value);
    }

    private void OnFullscreenToggled(bool isFullscreen)
    {
        Screen.fullScreen = isFullscreen;
    }

    private void PopulateResolutions()
    {
        Resolution[] resolutions = Screen.resolutions;
        resolutionDropdown.ClearOptions();

        var options = new System.Collections.Generic.List<string>();
        int currentIndex = 0;

        for (int i = 0; i < resolutions.Length; i++)
        {
            string option = $"{resolutions[i].width} x {resolutions[i].height} @ {resolutions[i].refreshRateRatio}Hz";
            options.Add(option);

            if (resolutions[i].width == Screen.currentResolution.width &&
                resolutions[i].height == Screen.currentResolution.height)
                currentIndex = i;
        }

        resolutionDropdown.AddOptions(options);
        resolutionDropdown.value = currentIndex;
    }

    private void OnResolutionChanged(int index)
    {
        Resolution res = Screen.resolutions[index];
        Screen.SetResolution(res.width, res.height, Screen.fullScreen);
    }

    private void OnDestroy()
    {
        // Clean up listeners to prevent memory leaks
        playButton.onClick.RemoveAllListeners();
        settingsButton.onClick.RemoveAllListeners();
        quitButton.onClick.RemoveAllListeners();
        volumeSlider.onValueChanged.RemoveAllListeners();
    }
}

2.2 TextMeshPro

TextMeshPro (TMP) is Unity's advanced text rendering solution and has completely replaced the legacy Text component. TMP uses Signed Distance Field (SDF) rendering, which produces crisp text at any size and resolution without blurring:

Key Insight: Always use TextMeshProUGUI instead of the legacy Text component. TMP provides rich text tags, better performance, SDF-based crisp rendering, material-based effects (outline, shadow, glow), and support for custom fonts. The legacy Text component is essentially deprecated.
using TMPro;
using UnityEngine;

public class DamageNumberPopup : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI damageText;
    [SerializeField] private float floatSpeed = 1f;
    [SerializeField] private float fadeSpeed = 1f;
    [SerializeField] private float lifetime = 1.5f;

    public void Initialize(int damage, bool isCritical)
    {
        // Rich text tags for styling
        if (isCritical)
        {
            damageText.text = $"<color=#FF4444><size=150%><b>CRIT! {damage}</b></size></color>";
        }
        else
        {
            damageText.text = $"<color=#FFFFFF>{damage}</color>";
        }

        Destroy(gameObject, lifetime);
    }

    private void Update()
    {
        // Float upward
        transform.Translate(Vector3.up * floatSpeed * Time.deltaTime);

        // Fade out
        Color c = damageText.color;
        c.a -= fadeSpeed * Time.deltaTime;
        damageText.color = c;
    }
}

2.3 Layout Groups & Fitters

Layout components automatically arrange child elements, eliminating the need for manual positioning:

Layout Component Behavior Use Case
Horizontal Layout Group Arranges children in a row (left to right) Toolbar buttons, item slots in a row, tab bar
Vertical Layout Group Arranges children in a column (top to bottom) Settings list, chat messages, menu items
Grid Layout Group Arranges children in a grid with fixed cell sizes Inventory grid, level select, card displays
Content Size Fitter Auto-resizes the element to fit its content Dynamic text boxes, auto-sizing buttons, tooltips
Aspect Ratio Fitter Maintains a specific width-to-height ratio Character portraits, video players, card aspect ratios
using UnityEngine;
using UnityEngine.UI;

public class InventoryGridSetup : MonoBehaviour
{
    [Header("Grid Configuration")]
    [SerializeField] private GridLayoutGroup grid;
    [SerializeField] private GameObject inventorySlotPrefab;
    [SerializeField] private int columns = 6;
    [SerializeField] private int totalSlots = 24;

    private void Start()
    {
        // Configure the grid layout
        grid.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
        grid.constraintCount = columns;
        grid.cellSize = new Vector2(80, 80);
        grid.spacing = new Vector2(8, 8);
        grid.padding = new RectOffset(10, 10, 10, 10);
        grid.childAlignment = TextAnchor.UpperLeft;

        // Populate slots
        for (int i = 0; i < totalSlots; i++)
        {
            GameObject slot = Instantiate(inventorySlotPrefab, grid.transform);
            slot.name = $"Slot_{i}";
        }
    }
}

// Auto-sizing tooltip that adjusts to text content
public class TooltipController : MonoBehaviour
{
    [SerializeField] private RectTransform tooltipPanel;
    [SerializeField] private TMPro.TextMeshProUGUI tooltipText;
    [SerializeField] private ContentSizeFitter sizeFitter;
    [SerializeField] private float maxWidth = 300f;

    public void ShowTooltip(string text, Vector2 screenPosition)
    {
        tooltipText.text = text;

        // Configure size fitter for dynamic content
        sizeFitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
        sizeFitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;

        // Clamp width to max
        tooltipPanel.sizeDelta = new Vector2(
            Mathf.Min(tooltipText.preferredWidth + 20, maxWidth),
            tooltipText.preferredHeight + 16
        );

        // Position near cursor, clamped to screen
        tooltipPanel.position = ClampToScreen(screenPosition);
        tooltipPanel.gameObject.SetActive(true);
    }

    private Vector2 ClampToScreen(Vector2 pos)
    {
        float w = tooltipPanel.sizeDelta.x;
        float h = tooltipPanel.sizeDelta.y;
        pos.x = Mathf.Clamp(pos.x, w * 0.5f, Screen.width - w * 0.5f);
        pos.y = Mathf.Clamp(pos.y, h * 0.5f, Screen.height - h * 0.5f);
        return pos;
    }

    public void HideTooltip()
    {
        tooltipPanel.gameObject.SetActive(false);
    }
}

3. Event System & Input

3.1 Event Triggers & Interfaces

The Event System is the backbone of Unity UI input handling. It processes mouse clicks, touch inputs, and keyboard/gamepad navigation, routing events to the correct UI elements. There are two main approaches to handling UI events:

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

// Approach 1: Implement pointer interfaces directly
// Gives you fine-grained control over every interaction type
public class InteractiveUIElement : MonoBehaviour,
    IPointerEnterHandler, IPointerExitHandler,
    IPointerClickHandler, IPointerDownHandler, IPointerUpHandler,
    IBeginDragHandler, IDragHandler, IEndDragHandler
{
    [SerializeField] private Image targetImage;
    [SerializeField] private Color normalColor = Color.white;
    [SerializeField] private Color hoverColor = new Color(0.9f, 0.9f, 1f);
    [SerializeField] private Color pressColor = new Color(0.7f, 0.7f, 0.9f);

    private Vector2 dragOffset;
    private RectTransform rectTransform;
    private Canvas parentCanvas;

    private void Awake()
    {
        rectTransform = GetComponent<RectTransform>();
        parentCanvas = GetComponentInParent<Canvas>();
    }

    public void OnPointerEnter(PointerEventData eventData)
    {
        targetImage.color = hoverColor;
        // Show tooltip, play hover sound, etc.
    }

    public void OnPointerExit(PointerEventData eventData)
    {
        targetImage.color = normalColor;
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        Debug.Log($"Clicked with {eventData.button} button");
        if (eventData.button == PointerEventData.InputButton.Right)
        {
            // Right-click context menu
            ShowContextMenu(eventData.position);
        }
    }

    public void OnPointerDown(PointerEventData eventData)
    {
        targetImage.color = pressColor;
    }

    public void OnPointerUp(PointerEventData eventData)
    {
        targetImage.color = hoverColor;
    }

    // Drag-and-drop support (e.g., inventory item dragging)
    public void OnBeginDrag(PointerEventData eventData)
    {
        RectTransformUtility.ScreenPointToLocalPointInRectangle(
            rectTransform, eventData.position, parentCanvas.worldCamera, out dragOffset
        );
    }

    public void OnDrag(PointerEventData eventData)
    {
        Vector2 localPoint;
        RectTransformUtility.ScreenPointToLocalPointInRectangle(
            parentCanvas.GetComponent<RectTransform>(),
            eventData.position, parentCanvas.worldCamera, out localPoint
        );
        rectTransform.localPosition = localPoint - dragOffset;
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        // Snap to grid, drop in slot, etc.
        Debug.Log("Drag ended at: " + eventData.position);
    }

    private void ShowContextMenu(Vector2 position)
    {
        Debug.Log("Context menu at: " + position);
    }
}

3.2 UI Navigation & Controller Support

For gamepad and keyboard navigation, Unity's Navigation system allows players to move between UI elements using directional input:

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;

public class MenuNavigationController : MonoBehaviour
{
    [SerializeField] private Selectable firstSelected;
    [SerializeField] private Selectable[] menuItems;

    private void OnEnable()
    {
        // Set the first selected element when menu opens
        if (firstSelected != null)
        {
            EventSystem.current.SetSelectedGameObject(firstSelected.gameObject);
        }
    }

    private void Start()
    {
        // Configure explicit navigation for precise control
        for (int i = 0; i < menuItems.Length; i++)
        {
            Navigation nav = new Navigation();
            nav.mode = Navigation.Mode.Explicit;

            // Up goes to previous, down goes to next (wrapping)
            nav.selectOnUp = menuItems[(i - 1 + menuItems.Length) % menuItems.Length];
            nav.selectOnDown = menuItems[(i + 1) % menuItems.Length];

            menuItems[i].navigation = nav;
        }
    }

    private void Update()
    {
        // Re-select if nothing is selected (player clicked empty space)
        if (EventSystem.current.currentSelectedGameObject == null)
        {
            EventSystem.current.SetSelectedGameObject(firstSelected.gameObject);
        }
    }
}
Accessibility Tip: Always support keyboard/gamepad navigation alongside mouse input. Many players prefer or require non-mouse input. Test your UI by unplugging your mouse and navigating entirely with keyboard arrows and Enter/Escape. If any screen is unreachable, your navigation graph has gaps.

4. UI Toolkit (Modern System)

UI Toolkit represents Unity's next-generation UI framework, inspired by web technologies. If you've worked with HTML/CSS, UI Toolkit will feel familiar. It separates structure (UXML), style (USS), and logic (C#) — a pattern proven by decades of web development.

4.1 USS & UXML

UXML defines the structure of your UI (like HTML), while USS defines the visual styling (like CSS):

<!-- MainMenu.uxml — Structure Definition -->
<ui:UXML xmlns:ui="UnityEngine.UIElements">
    <ui:VisualElement class="menu-container">
        <ui:Label text="GAME TITLE" class="game-title" />

        <ui:VisualElement class="button-group">
            <ui:Button name="play-button" text="Play" class="menu-button primary" />
            <ui:Button name="settings-button" text="Settings" class="menu-button" />
            <ui:Button name="credits-button" text="Credits" class="menu-button" />
            <ui:Button name="quit-button" text="Quit" class="menu-button danger" />
        </ui:VisualElement>

        <ui:VisualElement name="settings-panel" class="settings-panel hidden">
            <ui:Slider name="volume-slider" label="Volume" low-value="0" high-value="100" value="75" />
            <ui:DropdownField name="resolution-dropdown" label="Resolution" />
            <ui:Toggle name="fullscreen-toggle" label="Fullscreen" />
        </ui:VisualElement>

        <ui:Label text="v1.0.0" class="version-label" />
    </ui:VisualElement>
</ui:UXML>
/* MainMenuStyles.uss — Visual Styling */
.menu-container {
    flex-grow: 1;
    justify-content: center;
    align-items: center;
    background-color: rgba(0, 0, 0, 0.85);
    padding: 40px;
}

.game-title {
    font-size: 64px;
    color: rgb(255, 255, 255);
    -unity-font-style: bold;
    margin-bottom: 60px;
    letter-spacing: 8px;
    text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}

.button-group {
    flex-direction: column;
    align-items: center;
    width: 300px;
}

.menu-button {
    width: 100%;
    height: 50px;
    margin: 8px 0;
    font-size: 20px;
    color: rgb(220, 220, 220);
    background-color: rgba(255, 255, 255, 0.1);
    border-width: 2px;
    border-color: rgba(255, 255, 255, 0.3);
    border-radius: 8px;
    transition-duration: 0.2s;
}

.menu-button:hover {
    background-color: rgba(255, 255, 255, 0.2);
    border-color: rgba(100, 200, 255, 0.8);
    scale: 1.05;
}

.menu-button:active {
    background-color: rgba(100, 200, 255, 0.3);
    scale: 0.98;
}

.menu-button.primary {
    background-color: rgba(59, 151, 151, 0.6);
    border-color: rgb(59, 151, 151);
}

.menu-button.danger {
    border-color: rgba(255, 80, 80, 0.5);
}

.settings-panel {
    margin-top: 30px;
    padding: 20px;
    background-color: rgba(255, 255, 255, 0.05);
    border-radius: 12px;
    width: 400px;
}

.hidden {
    display: none;
}

.version-label {
    position: absolute;
    bottom: 20px;
    right: 20px;
    font-size: 12px;
    color: rgba(255, 255, 255, 0.4);
}

4.2 Runtime UI Binding

using UnityEngine;
using UnityEngine.UIElements;

public class UIToolkitMenuController : MonoBehaviour
{
    [SerializeField] private UIDocument uiDocument;

    private Button playButton;
    private Button settingsButton;
    private Button quitButton;
    private VisualElement settingsPanel;
    private Slider volumeSlider;
    private Toggle fullscreenToggle;

    private void OnEnable()
    {
        var root = uiDocument.rootVisualElement;

        // Query elements by name (like document.getElementById)
        playButton = root.Q<Button>("play-button");
        settingsButton = root.Q<Button>("settings-button");
        quitButton = root.Q<Button>("quit-button");
        settingsPanel = root.Q<VisualElement>("settings-panel");
        volumeSlider = root.Q<Slider>("volume-slider");
        fullscreenToggle = root.Q<Toggle>("fullscreen-toggle");

        // Register callbacks (like addEventListener in JavaScript)
        playButton.RegisterCallback<ClickEvent>(evt => OnPlayClicked());
        settingsButton.RegisterCallback<ClickEvent>(evt => ToggleSettings());
        quitButton.RegisterCallback<ClickEvent>(evt => OnQuitClicked());

        volumeSlider.RegisterValueChangedCallback(evt =>
        {
            AudioListener.volume = evt.newValue / 100f;
        });

        fullscreenToggle.RegisterValueChangedCallback(evt =>
        {
            Screen.fullScreen = evt.newValue;
        });

        // Add hover animations via C# (alternative to USS :hover)
        playButton.RegisterCallback<MouseEnterEvent>(evt =>
        {
            playButton.AddToClassList("button-glow");
        });
        playButton.RegisterCallback<MouseLeaveEvent>(evt =>
        {
            playButton.RemoveFromClassList("button-glow");
        });
    }

    private void OnPlayClicked()
    {
        UnityEngine.SceneManagement.SceneManager.LoadScene("Level_01");
    }

    private void ToggleSettings()
    {
        settingsPanel.ToggleInClassList("hidden");
    }

    private void OnQuitClicked()
    {
        Application.Quit();
    }
}
uGUI vs UI Toolkit Comparison

When to Use Which System?

Factor uGUI UI Toolkit
Maturity Battle-tested since 2014 Rapidly improving, stable in Unity 6
World Space UI Excellent — native support Limited — better for screen-space
Complex HUDs Good with manual layout Excellent — flexbox, auto-layout
Editor Extensions Not designed for this Primary tool for custom editor UI
Skill Transfer Unity-specific Web dev skills transfer directly
uGUI UI Toolkit Decision Guide

5. Advanced UI Techniques

5.1 Responsive Multi-Resolution Design

Shipping a game on mobile (720p), desktop (1080p-4K), and console (dynamic resolution) demands a UI that adapts gracefully. Here are the key strategies:

using UnityEngine;
using UnityEngine.UI;

public class ResponsiveUIManager : MonoBehaviour
{
    [Header("Layout References")]
    [SerializeField] private RectTransform mobileLayout;
    [SerializeField] private RectTransform desktopLayout;
    [SerializeField] private CanvasScaler canvasScaler;

    [Header("Breakpoints")]
    [SerializeField] private float mobileMaxWidth = 768f;
    [SerializeField] private float tabletMaxWidth = 1024f;

    private float lastAspectRatio;

    private void Start()
    {
        AdaptLayout();
    }

    private void Update()
    {
        // Check for resolution changes (window resizing, orientation change)
        float currentAspect = (float)Screen.width / Screen.height;
        if (Mathf.Abs(currentAspect - lastAspectRatio) > 0.01f)
        {
            lastAspectRatio = currentAspect;
            AdaptLayout();
        }
    }

    private void AdaptLayout()
    {
        float screenWidth = Screen.width;
        float aspectRatio = (float)Screen.width / Screen.height;

        if (screenWidth <= mobileMaxWidth || aspectRatio < 1f) // Portrait or small screen
        {
            ActivateMobileLayout();
        }
        else if (screenWidth <= tabletMaxWidth)
        {
            ActivateTabletLayout();
        }
        else
        {
            ActivateDesktopLayout();
        }

        // Adjust Canvas Scaler match based on orientation
        canvasScaler.matchWidthOrHeight = aspectRatio > 1f ? 0.5f : 0f;
    }

    private void ActivateMobileLayout()
    {
        mobileLayout.gameObject.SetActive(true);
        desktopLayout.gameObject.SetActive(false);
        Debug.Log("Mobile layout activated");
    }

    private void ActivateTabletLayout()
    {
        // Tablet: show desktop layout but with adjusted spacing
        mobileLayout.gameObject.SetActive(false);
        desktopLayout.gameObject.SetActive(true);
    }

    private void ActivateDesktopLayout()
    {
        mobileLayout.gameObject.SetActive(false);
        desktopLayout.gameObject.SetActive(true);
    }
}

5.2 Dynamic UI Generation from Data

Hard-coding UI for every item, quest, or skill is unsustainable. Professional games generate UI elements dynamically from data:

using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Collections.Generic;

// Data-driven inventory system
[System.Serializable]
public class ItemData
{
    public string itemName;
    public string description;
    public Sprite icon;
    public int quantity;
    public enum Rarity { Common, Uncommon, Rare, Epic, Legendary }
    public Rarity rarity;
}

public class DynamicInventoryUI : MonoBehaviour
{
    [Header("Prefab & Container")]
    [SerializeField] private GameObject itemSlotPrefab;
    [SerializeField] private Transform slotContainer;

    [Header("Rarity Colors")]
    [SerializeField] private Color commonColor = Color.gray;
    [SerializeField] private Color uncommonColor = Color.green;
    [SerializeField] private Color rareColor = Color.blue;
    [SerializeField] private Color epicColor = new Color(0.6f, 0.2f, 0.8f);
    [SerializeField] private Color legendaryColor = new Color(1f, 0.65f, 0f);

    private List<GameObject> activeSlots = new List<GameObject>();

    public void PopulateInventory(List<ItemData> items)
    {
        ClearInventory();

        foreach (var item in items)
        {
            GameObject slot = Instantiate(itemSlotPrefab, slotContainer);

            // Configure the slot from data
            var icon = slot.transform.Find("Icon").GetComponent<Image>();
            var nameLabel = slot.transform.Find("Name").GetComponent<TextMeshProUGUI>();
            var quantityLabel = slot.transform.Find("Quantity").GetComponent<TextMeshProUGUI>();
            var border = slot.transform.Find("Border").GetComponent<Image>();

            icon.sprite = item.icon;
            nameLabel.text = item.itemName;
            quantityLabel.text = item.quantity > 1 ? $"x{item.quantity}" : "";
            border.color = GetRarityColor(item.rarity);

            // Add click handler with captured item reference
            var button = slot.GetComponent<Button>();
            var capturedItem = item; // Closure capture
            button.onClick.AddListener(() => OnItemClicked(capturedItem));

            activeSlots.Add(slot);
        }
    }

    private Color GetRarityColor(ItemData.Rarity rarity)
    {
        switch (rarity)
        {
            case ItemData.Rarity.Common:    return commonColor;
            case ItemData.Rarity.Uncommon:  return uncommonColor;
            case ItemData.Rarity.Rare:      return rareColor;
            case ItemData.Rarity.Epic:      return epicColor;
            case ItemData.Rarity.Legendary: return legendaryColor;
            default: return commonColor;
        }
    }

    private void OnItemClicked(ItemData item)
    {
        Debug.Log($"Selected: {item.itemName} ({item.rarity})");
    }

    private void ClearInventory()
    {
        foreach (var slot in activeSlots)
        {
            Destroy(slot);
        }
        activeSlots.Clear();
    }
}

5.3 UI Performance Optimization

Performance Warning: UI can be a significant performance bottleneck. A single Canvas with 500 elements will rebuild its entire mesh whenever any child changes. On mobile, this can cause frame drops and drain battery. The following optimizations are essential for shipping a performant game.
Optimization Problem Solution
Canvas Splitting Entire Canvas rebuilds when any child changes Separate static UI (backgrounds, labels) from dynamic UI (health bars, timers) into different Canvases
Raycast Targets Every Image/Text with Raycast Target enabled adds to input processing cost Disable Raycast Target on non-interactive elements (decorative images, labels)
Overdraw Transparent UI elements stack, causing GPU to draw the same pixel multiple times Minimize overlapping transparent panels; use opaque backgrounds where possible
Layout Thrashing Frequent layout recalculations from changing text, adding/removing children Batch layout changes; disable layout groups after initial setup if layout is static
Object Pooling Instantiate/Destroy for temporary UI (damage numbers, notifications) causes GC spikes Pool UI elements and recycle them instead of creating/destroying
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;

// UI Object Pool for damage numbers, notifications, etc.
public class UIObjectPool : MonoBehaviour
{
    [SerializeField] private GameObject prefab;
    [SerializeField] private Transform poolContainer;
    [SerializeField] private int initialSize = 20;

    private Queue<GameObject> pool = new Queue<GameObject>();

    private void Awake()
    {
        // Pre-instantiate objects
        for (int i = 0; i < initialSize; i++)
        {
            var obj = Instantiate(prefab, poolContainer);
            obj.SetActive(false);
            pool.Enqueue(obj);
        }
    }

    public GameObject Get()
    {
        GameObject obj;
        if (pool.Count > 0)
        {
            obj = pool.Dequeue();
        }
        else
        {
            obj = Instantiate(prefab, poolContainer);
        }
        obj.SetActive(true);
        return obj;
    }

    public void Return(GameObject obj)
    {
        obj.SetActive(false);
        pool.Enqueue(obj);
    }
}

// Optimized health bar that minimizes Canvas rebuilds
public class OptimizedHealthBar : MonoBehaviour
{
    [SerializeField] private Image fillImage;
    [SerializeField] private float smoothSpeed = 5f;

    private float targetFill = 1f;
    private float currentFill = 1f;
    private bool isDirty = false;

    public void SetHealth(float normalizedHealth)
    {
        targetFill = Mathf.Clamp01(normalizedHealth);
        isDirty = true;
    }

    private void Update()
    {
        if (!isDirty) return;

        currentFill = Mathf.Lerp(currentFill, targetFill, Time.deltaTime * smoothSpeed);
        fillImage.fillAmount = currentFill;

        // Stop updating when close enough (prevents perpetual Canvas rebuilds)
        if (Mathf.Abs(currentFill - targetFill) < 0.001f)
        {
            currentFill = targetFill;
            fillImage.fillAmount = currentFill;
            isDirty = false;
        }
    }
}

6. History: OnGUI to UI Toolkit

Unity's UI journey has been a fascinating evolution from programmer-only to designer-friendly systems:

Era System Approach Limitations
2005-2014 IMGUI (OnGUI) Immediate-mode: draw UI elements every frame in code. No visual editor, no prefabs. Extremely tedious for complex UI. Poor performance. No design tools. Still used for editor scripts.
2014-Present uGUI (Unity UI) Retained-mode: visual Canvas system with GameObjects, components, and the Inspector. Performance issues with large/dynamic UI. No CSS-like styling. Difficult to maintain consistent styling across screens.
2021-Present UI Toolkit Web-inspired: UXML structure, USS styling, retained-mode Visual Element tree. Still maturing for runtime use. Limited World Space support. Smaller community/asset ecosystem.
Case Study

Hades — Dynamic UI That Tells a Story

Supergiant Games' Hades is renowned for its UI design. The Boon selection screen, weapon upgrades, and NPC dialogue interfaces seamlessly integrate into the game's visual style. Key lessons:

  • Contextual information density — shows exactly what the player needs, nothing more
  • Animated transitions — UI panels slide, fade, and scale with satisfying easing curves
  • Color-coded systems — each god's boons use distinct color palettes for instant recognition
  • Controller-first design — the entire game is navigable with a gamepad, with mouse as a bonus
Hades Supergiant Games Roguelike Dynamic UI
Case Study

Among Us — Simple UI, Maximum Effectiveness

Innersloth's Among Us proves that UI does not need to be complex to be effective. With a minimal art budget, the team created an interface that:

  • Works across platforms — the same UI functions on mobile (touch), PC (mouse), and console (controller)
  • Large, clear buttons — designed for mobile touch targets (minimum 48dp), which also work perfectly for mouse and controllers
  • Minimal text, maximum icons — reduces localization burden and cognitive load
  • Consistent color language — red for danger/impostor, green for safe/tasks, yellow for alerts
Among Us Innersloth Cross-Platform Minimalist UI

Exercises & Self-Assessment

Exercise 1

Build a Complete Main Menu

Create a fully functional main menu with the following screens:

  1. Title screen with Play, Settings, Credits, and Quit buttons
  2. Settings panel with volume slider, resolution dropdown, fullscreen toggle, and a Back button
  3. Use Canvas Scaler set to "Scale With Screen Size" with a 1920x1080 reference resolution
  4. Add keyboard/gamepad navigation so the entire menu is usable without a mouse
  5. Test at 1920x1080, 1280x720, and 720x1280 (portrait) to verify responsive scaling
Exercise 2

Dynamic Inventory System

Build a data-driven inventory using Grid Layout Group:

  1. Create a ScriptableObject for item data (name, icon, rarity, description)
  2. Build an item slot prefab with icon, name, quantity, and rarity-colored border
  3. Dynamically populate a 6-column grid from a list of ScriptableObjects
  4. Add a tooltip that appears on hover showing the item description
  5. Implement drag-and-drop to rearrange items between slots
Exercise 3

World Space Health Bars

Create floating health bars above game characters:

  1. Create a World Space Canvas attached to each character
  2. Build a health bar with a fill Image using the Filled image type
  3. Make the health bar always face the camera (billboard effect)
  4. Add smooth lerp animation when health changes
  5. Color-code the bar: green above 60%, yellow 30-60%, red below 30%
Exercise 4

Reflective Questions

  1. Why should you split static and dynamic UI into separate Canvases? What happens if you don't?
  2. When would you choose World Space Canvas over Screen Space Overlay? Give three examples.
  3. Explain the difference between uGUI's event interfaces (IPointerClickHandler) and EventTrigger. When would you use each?
  4. A mobile game needs to support both portrait and landscape orientation. How would you design your Canvas Scaler and layout strategy?
  5. Compare UI Toolkit's USS styling approach with uGUI's component-based styling. What advantages does each offer for a large team?

UI Design Specification Generator

Generate a professional UI design specification document. Download as Word, Excel, PDF, or PowerPoint.

Draft auto-saved

All data stays in your browser. Nothing is sent to or stored on any server.

Conclusion & Next Steps

You now have a comprehensive understanding of Unity's UI systems. Here are the key takeaways from Part 5:

  • The Canvas system is the foundation — choose the right render mode (Overlay/Camera/World Space) and configure Canvas Scaler for multi-resolution support
  • RectTransform anchors, pivots, and sizeDelta control how elements position and resize — mastering these is essential for responsive layouts
  • uGUI elements (Button, TextMeshPro, Image, Slider, ScrollView) cover all common UI needs with minimal code
  • Layout Groups (Horizontal, Vertical, Grid) and Content Size Fitter automate element arrangement
  • The Event System handles input routing — implement pointer interfaces for precise control, configure navigation for gamepad support
  • UI Toolkit brings web-style development (USS/UXML) to Unity — ideal for complex menus, editor tools, and teams with web experience
  • Performance requires splitting Canvases, disabling unnecessary raycast targets, minimizing overdraw, and pooling dynamic UI objects

Next in the Series

In Part 6: Animation & State Machines, we'll bring your game to life with Unity's powerful Animator system — animation clips, Animator Controllers, blend trees for fluid movement, state machines for character behavior, Inverse Kinematics for realistic body interaction, and Timeline for cinematic sequences.

Gaming