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.
1
Unity Basics & Interface
Editor overview, assets, prefabs, architecture
2
C# Scripting Fundamentals
MonoBehaviour, coroutines, input systems, patterns
3
GameObjects & Components
Transforms, renderers, custom components
4
Physics & Collisions
Rigidbody, colliders, raycasting, forces
5
UI Systems
Canvas, uGUI, UI Toolkit, responsive design
You Are Here
6
Animation & State Machines
Animator, blend trees, IK, Timeline
7
Audio & Visual Effects
AudioSource, particles, VFX Graph, post-processing
8
Building & Publishing
Build pipeline, optimization, platforms, monetization
9
Rendering Pipelines
URP, HDRP, Shader Graph, lighting systems
10
Data-Oriented Tech Stack
ECS, Jobs System, Burst Compiler
11
AI & Gameplay Systems
NavMesh, FSMs, behavior trees, procedural gen
12
Multiplayer & Networking
Netcode, RPCs, latency, prediction
13
Tools & Editor Scripting
Custom editors, debug tools, CI/CD
14
Architecture & Clean Code
Service locators, DI, ScriptableObject architecture
15
Performance Optimization
CPU/GPU profiling, memory, object pooling
16
Production & Industry Practices
Git, Agile, asset pipelines, debugging at scale
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;
}
}
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();
}
}
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:
- Title screen with Play, Settings, Credits, and Quit buttons
- Settings panel with volume slider, resolution dropdown, fullscreen toggle, and a Back button
- Use Canvas Scaler set to "Scale With Screen Size" with a 1920x1080 reference resolution
- Add keyboard/gamepad navigation so the entire menu is usable without a mouse
- 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:
- Create a ScriptableObject for item data (name, icon, rarity, description)
- Build an item slot prefab with icon, name, quantity, and rarity-colored border
- Dynamically populate a 6-column grid from a list of ScriptableObjects
- Add a tooltip that appears on hover showing the item description
- Implement drag-and-drop to rearrange items between slots
Exercise 3
World Space Health Bars
Create floating health bars above game characters:
- Create a World Space Canvas attached to each character
- Build a health bar with a fill Image using the Filled image type
- Make the health bar always face the camera (billboard effect)
- Add smooth lerp animation when health changes
- Color-code the bar: green above 60%, yellow 30-60%, red below 30%
Exercise 4
Reflective Questions
- Why should you split static and dynamic UI into separate Canvases? What happens if you don't?
- When would you choose World Space Canvas over Screen Space Overlay? Give three examples.
- Explain the difference between uGUI's event interfaces (IPointerClickHandler) and EventTrigger. When would you use each?
- A mobile game needs to support both portrait and landscape orientation. How would you design your Canvas Scaler and layout strategy?
- Compare UI Toolkit's USS styling approach with uGUI's component-based styling. What advantages does each offer for a large team?
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.
Continue the Series
Part 6: Animation & State Machines
Master Animator Controllers, blend trees, IK, state machines for character behavior, and Timeline for cutscenes.
Read Article
Part 7: Audio & Visual Effects
AudioSource, particle systems, VFX Graph, post-processing, and creating immersive audiovisual experiences.
Read Article
Part 4: Physics & Collisions
Rigidbodies, colliders, raycasting, forces, joints, and building physics-driven gameplay.
Read Article