UI System
Create a complete user interface with main menu, HUD, pause screen, and game over panel. Learn Canvas setup, panel management, UI animations with coroutines, and event-driven display updates.
The Role of UI in an Infinite Runner
The user interface is the bridge between your game systems and the player. All the scores, power-ups, and game states we have built in previous chapters are invisible without a UI to display them. A well-designed UI communicates information instantly, responds to player input, and enhances the game's feel with smooth animations.
Our UI system will have four distinct panels, each shown during a specific game state:
- Main Menu — Shown before the game starts. Play button, settings, high score display.
- HUD (Heads-Up Display) — Shown during gameplay. Score, coins, distance, active power-ups.
- Pause Panel — Shown when the game is paused. Resume, restart, main menu buttons.
- Game Over Panel — Shown when the player dies. Final score, high score, restart button.
Unity Canvas Setup
All Unity UI elements live inside a Canvas component. The Canvas determines how UI elements are rendered and how they respond to screen size changes.
Step 1: Create the Canvas
- In the Hierarchy, right-click > UI > Canvas. Unity creates a Canvas with an EventSystem automatically.
- Select the Canvas and find the Canvas component in the Inspector.
- Set Render Mode to Screen Space - Overlay. This renders UI on top of everything, which is what we want.
Step 2: Configure the Canvas Scaler
The Canvas Scaler component determines how UI scales across different screen sizes and resolutions. This is critical for mobile games where screens range from small phones to large tablets.
- On the Canvas object, find the Canvas Scaler component.
- Set UI Scale Mode to Scale With Screen Size.
- Set Reference Resolution to
1080 x 1920(standard portrait mobile resolution). - Set Screen Match Mode to Match Width Or Height.
- Set Match slider to
0.5(balanced between width and height matching).
Constant Pixel Size makes UI the same pixel size on every device, which looks too small on high-DPI phones and too large on low-DPI ones. Scale With Screen Size makes UI proportionally the same size on every screen, which is almost always what you want for games. The Reference Resolution is your design target — you build the UI for that resolution and Unity scales it to fit other screens.
Step 3: Create Panel GameObjects
Our architecture uses one Canvas with child GameObjects for each panel. Showing a panel means activating its GameObject; hiding means deactivating it.
- Right-click the Canvas > Create Empty. Name it
MainMenuPanel. - On MainMenuPanel, set the RectTransform to stretch across the full canvas: hold Alt and click the bottom-right anchor preset (stretch-stretch).
- Repeat for
HUDPanel,PausePanel, andGameOverPanel. - Add a Canvas Group component to each panel. This lets us fade panels in and out by adjusting the
alphaproperty.
A CanvasGroup component has three key properties: Alpha (0-1 opacity), Interactable (can buttons be clicked?), and Blocks Raycasts (does the panel block clicks to things behind it?). When hiding a panel, set alpha to 0 and interactable/blocksRaycasts to false. When showing, set alpha to 1 and the others to true.
The UIManager
The UIManager is the central controller for all UI panels. It listens for game state changes and shows the appropriate panel. It also provides shared functionality like panel transitions with fade animations.
using System.Collections;
using UnityEngine;
using InfiniteRunner.Core;
namespace InfiniteRunner.UI
{
/// <summary>
/// Central UI controller that manages panel visibility based on
/// game state. Handles transitions between panels with fade
/// animations using coroutines (no external dependencies).
///
/// Attach this to the Canvas GameObject. Drag each panel's
/// CanvasGroup into the corresponding Inspector field.
/// </summary>
public class UIManager : MonoBehaviour
{
// ---------------------------------------------------------------
// Singleton
// ---------------------------------------------------------------
public static UIManager Instance { get; private set; }
// ---------------------------------------------------------------
// Inspector Fields
// ---------------------------------------------------------------
[Header("UI Panels")]
[Tooltip("The main menu panel (shown before gameplay).")]
[SerializeField] private CanvasGroup mainMenuPanel;
[Tooltip("The HUD panel (shown during gameplay).")]
[SerializeField] private CanvasGroup hudPanel;
[Tooltip("The pause panel (shown when game is paused).")]
[SerializeField] private CanvasGroup pausePanel;
[Tooltip("The game over panel (shown after death).")]
[SerializeField] private CanvasGroup gameOverPanel;
[Header("Transition Settings")]
[Tooltip("How long panel fade transitions take in seconds.")]
[SerializeField] private float transitionDuration = 0.3f;
// ---------------------------------------------------------------
// Private State
// ---------------------------------------------------------------
// The currently active panel (so we know what to hide).
private CanvasGroup currentPanel;
// Running coroutine reference so we can cancel transitions.
private Coroutine activeTransition;
// ---------------------------------------------------------------
// Unity Lifecycle
// ---------------------------------------------------------------
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
// Start with all panels hidden.
HidePanelImmediate(mainMenuPanel);
HidePanelImmediate(hudPanel);
HidePanelImmediate(pausePanel);
HidePanelImmediate(gameOverPanel);
}
private void Start()
{
// Show the main menu on startup.
ShowPanel(mainMenuPanel);
}
private void OnEnable()
{
GameEvents.OnGameStarted += HandleGameStarted;
GameEvents.OnGameOver += HandleGameOver;
GameEvents.OnGamePaused += HandleGamePaused;
GameEvents.OnGameResumed += HandleGameResumed;
}
private void OnDisable()
{
GameEvents.OnGameStarted -= HandleGameStarted;
GameEvents.OnGameOver -= HandleGameOver;
GameEvents.OnGamePaused -= HandleGamePaused;
GameEvents.OnGameResumed -= HandleGameResumed;
}
// ---------------------------------------------------------------
// Event Handlers
// ---------------------------------------------------------------
private void HandleGameStarted()
{
TransitionToPanel(hudPanel);
}
private void HandleGameOver()
{
// Small delay before showing game over so the death
// animation has time to play.
StartCoroutine(ShowGameOverDelayed(0.8f));
}
private void HandleGamePaused()
{
ShowPanel(pausePanel);
}
private void HandleGameResumed()
{
HidePanel(pausePanel);
}
/// <summary>
/// Waits a moment before showing the game over panel.
/// </summary>
private IEnumerator ShowGameOverDelayed(float delay)
{
yield return new WaitForSecondsRealtime(delay);
TransitionToPanel(gameOverPanel);
}
// ---------------------------------------------------------------
// Panel Management
// ---------------------------------------------------------------
/// <summary>
/// Transitions from the current panel to a new panel with a
/// fade animation. Hides the current panel, then shows the new one.
/// </summary>
/// <param name="targetPanel">The panel to show.</param>
public void TransitionToPanel(CanvasGroup targetPanel)
{
// Cancel any running transition.
if (activeTransition != null)
{
StopCoroutine(activeTransition);
}
activeTransition = StartCoroutine(
TransitionCoroutine(targetPanel)
);
}
/// <summary>
/// Coroutine that fades out the current panel and fades in
/// the target panel sequentially.
/// </summary>
private IEnumerator TransitionCoroutine(CanvasGroup targetPanel)
{
// Fade out the current panel (if one is showing).
if (currentPanel != null)
{
yield return StartCoroutine(
FadePanel(currentPanel, 1f, 0f, transitionDuration)
);
SetPanelInteractable(currentPanel, false);
currentPanel.gameObject.SetActive(false);
}
// Fade in the target panel.
targetPanel.gameObject.SetActive(true);
yield return StartCoroutine(
FadePanel(targetPanel, 0f, 1f, transitionDuration)
);
SetPanelInteractable(targetPanel, true);
currentPanel = targetPanel;
activeTransition = null;
}
/// <summary>
/// Shows a panel with a fade-in animation.
/// Does not hide the current panel (use for overlays like Pause).
/// </summary>
public void ShowPanel(CanvasGroup panel)
{
if (panel == null) return;
panel.gameObject.SetActive(true);
StartCoroutine(FadePanel(panel, 0f, 1f, transitionDuration));
SetPanelInteractable(panel, true);
currentPanel = panel;
}
/// <summary>
/// Hides a panel with a fade-out animation.
/// </summary>
public void HidePanel(CanvasGroup panel)
{
if (panel == null) return;
StartCoroutine(HidePanelCoroutine(panel));
}
private IEnumerator HidePanelCoroutine(CanvasGroup panel)
{
SetPanelInteractable(panel, false);
yield return StartCoroutine(
FadePanel(panel, 1f, 0f, transitionDuration)
);
panel.gameObject.SetActive(false);
}
/// <summary>
/// Immediately hides a panel without animation.
/// Used during initialization.
/// </summary>
private void HidePanelImmediate(CanvasGroup panel)
{
if (panel == null) return;
panel.alpha = 0f;
SetPanelInteractable(panel, false);
panel.gameObject.SetActive(false);
}
// ---------------------------------------------------------------
// Animation Helpers
// ---------------------------------------------------------------
/// <summary>
/// Smoothly fades a CanvasGroup's alpha from one value to another
/// over a specified duration. Uses unscaled time so it works
/// even when the game is paused (Time.timeScale = 0).
/// </summary>
private IEnumerator FadePanel(
CanvasGroup panel,
float fromAlpha,
float toAlpha,
float duration)
{
float elapsed = 0f;
panel.alpha = fromAlpha;
while (elapsed < duration)
{
// Use unscaledDeltaTime so fades work during pause.
elapsed += Time.unscaledDeltaTime;
// Calculate progress (0 to 1).
float t = Mathf.Clamp01(elapsed / duration);
// Apply easing for a smoother feel.
// SmoothStep provides ease-in-ease-out.
float easedT = Mathf.SmoothStep(0f, 1f, t);
panel.alpha = Mathf.Lerp(fromAlpha, toAlpha, easedT);
yield return null;
}
// Ensure we land exactly on the target value.
panel.alpha = toAlpha;
}
/// <summary>
/// Sets whether a panel can receive input and block raycasts.
/// </summary>
private void SetPanelInteractable(CanvasGroup panel, bool interactable)
{
panel.interactable = interactable;
panel.blocksRaycasts = interactable;
}
// ---------------------------------------------------------------
// Scale Animation (for popups and buttons)
// ---------------------------------------------------------------
/// <summary>
/// Animates a RectTransform from zero scale to full scale with
/// an elastic overshoot for a "pop in" effect.
/// Call this when showing game over stats, new high score, etc.
/// </summary>
public void PopIn(RectTransform target, float duration = 0.4f)
{
StartCoroutine(PopInCoroutine(target, duration));
}
private IEnumerator PopInCoroutine(RectTransform target, float duration)
{
float elapsed = 0f;
target.localScale = Vector3.zero;
while (elapsed < duration)
{
elapsed += Time.unscaledDeltaTime;
float t = Mathf.Clamp01(elapsed / duration);
// Custom elastic ease-out curve.
// This creates an overshoot effect: scales past 1.0
// then settles back, like a rubber band.
float easedT = 1f - Mathf.Cos(t * Mathf.PI * 0.5f);
float overshoot = Mathf.Sin(t * Mathf.PI) * 0.15f;
float scale = easedT + overshoot;
target.localScale = new Vector3(scale, scale, scale);
yield return null;
}
target.localScale = Vector3.one;
}
// ---------------------------------------------------------------
// Public API
// ---------------------------------------------------------------
/// <summary>
/// Shows the main menu panel. Called by buttons.
/// </summary>
public void ShowMainMenu()
{
TransitionToPanel(mainMenuPanel);
}
/// <summary>
/// Returns the CanvasGroup for the HUD panel.
/// Other scripts may need this to add power-up indicators.
/// </summary>
public CanvasGroup GetHUDPanel()
{
return hudPanel;
}
}
}When the game is paused, we set Time.timeScale = 0, which stops all physics and Time.deltaTime-based calculations. But our UI transitions still need to animate! Time.unscaledDeltaTime gives us the real elapsed time regardless of timeScale, so our fade animations keep working during pause.
Main Menu Panel
The main menu is the first thing players see. It should be clean, inviting, and get the player into the game quickly.
Building the Main Menu in Unity
- Select
MainMenuPanelin the Hierarchy. - Right-click > UI > Text - TextMeshPro. Name it
TitleText. Set the text to your game title, font size 72, centered. - Right-click MainMenuPanel > UI > Button - TextMeshPro. Name it
PlayButton. Set the button text to "PLAY". - Duplicate the button and rename to
SettingsButton. Set text to "SETTINGS". - Right-click MainMenuPanel > UI > Text - TextMeshPro. Name it
HighScoreText. This will display the all-time best score. - Position the elements using the RectTransform anchors — title near the top, play button in the center, high score near the bottom.
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using InfiniteRunner.Core;
using InfiniteRunner.Gameplay;
namespace InfiniteRunner.UI
{
/// <summary>
/// Controls the Main Menu panel. Displays the game title,
/// high score, and provides buttons to start the game or
/// open settings.
///
/// Attach this to the MainMenuPanel GameObject.
/// </summary>
public class MainMenuUI : MonoBehaviour
{
// ---------------------------------------------------------------
// Inspector Fields
// ---------------------------------------------------------------
[Header("Text Elements")]
[Tooltip("The game title text.")]
[SerializeField] private TMP_Text titleText;
[Tooltip("Displays the all-time high score.")]
[SerializeField] private TMP_Text highScoreText;
[Header("Buttons")]
[Tooltip("Button that starts the game.")]
[SerializeField] private Button playButton;
[Tooltip("Button that opens the settings panel.")]
[SerializeField] private Button settingsButton;
// ---------------------------------------------------------------
// Unity Lifecycle
// ---------------------------------------------------------------
private void Awake()
{
// Wire up button click listeners.
// AddListener connects a method to the button's OnClick event.
if (playButton != null)
{
playButton.onClick.AddListener(OnPlayButtonClicked);
}
if (settingsButton != null)
{
settingsButton.onClick.AddListener(OnSettingsButtonClicked);
}
}
private void OnEnable()
{
// Update the high score display every time the menu is shown.
// This ensures it reflects the latest score after a game over.
UpdateHighScoreDisplay();
}
private void OnDestroy()
{
// Remove listeners to prevent memory leaks.
if (playButton != null)
{
playButton.onClick.RemoveListener(OnPlayButtonClicked);
}
if (settingsButton != null)
{
settingsButton.onClick.RemoveListener(OnSettingsButtonClicked);
}
}
// ---------------------------------------------------------------
// Button Handlers
// ---------------------------------------------------------------
/// <summary>
/// Called when the Play button is clicked.
/// Fires the game start event which the GameManager handles.
/// </summary>
private void OnPlayButtonClicked()
{
Debug.Log("[MainMenuUI] Play button clicked.");
GameEvents.OnGameStarted?.Invoke();
}
/// <summary>
/// Called when the Settings button is clicked.
/// Opens the settings panel (to be implemented).
/// </summary>
private void OnSettingsButtonClicked()
{
Debug.Log("[MainMenuUI] Settings button clicked.");
// TODO: Open settings panel.
}
// ---------------------------------------------------------------
// Display Updates
// ---------------------------------------------------------------
/// <summary>
/// Updates the high score text element with the latest
/// persisted high score from the ScoreManager.
/// </summary>
private void UpdateHighScoreDisplay()
{
if (highScoreText == null) return;
// Try to get the high score from ScoreManager.
if (ScoreManager.Instance != null)
{
int highScore = ScoreManager.Instance.GetHighScore();
highScoreText.text = highScore > 0
? $"Best: {ScoreManager.FormatScore(highScore)}"
: "Best: ---";
}
else
{
// ScoreManager not yet initialized - try PlayerPrefs.
int highScore = PlayerPrefs.GetInt("InfiniteRunner_HighScore", 0);
highScoreText.text = highScore > 0
? $"Best: {highScore:N0}"
: "Best: ---";
}
}
}
}HUD Panel (Heads-Up Display)
The HUD is shown during gameplay and must communicate critical information at a glance without obstructing the player's view of the game world.
Building the HUD in Unity
- Select
HUDPanelin the Hierarchy. - Create a Text - TextMeshPro child named
ScoreText. Anchor to top-center. Font size 48. Align center. - Create
CoinText. Anchor to top-left. Font size 32. Show a coin icon + count. - Create
DistanceText. Anchor to top-right. Font size 32. - Create an empty child named
PowerUpIndicators. Anchor to mid-left. This will hold active power-up icons. - Create
MultiplierText. Anchor below the score. Font size 36. Only visible when a multiplier is active.
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using InfiniteRunner.Core;
using InfiniteRunner.Gameplay;
namespace InfiniteRunner.UI
{
/// <summary>
/// Controls the in-game HUD panel. Updates score, coin count,
/// distance, and active power-up indicators in real-time.
///
/// Attach this to the HUDPanel GameObject.
/// </summary>
public class HUDUI : MonoBehaviour
{
// ---------------------------------------------------------------
// Inspector Fields
// ---------------------------------------------------------------
[Header("Score Display")]
[Tooltip("Shows the current total score.")]
[SerializeField] private TMP_Text scoreText;
[Tooltip("Shows 'x2' when a score multiplier is active.")]
[SerializeField] private TMP_Text multiplierText;
[Header("Stats Display")]
[Tooltip("Shows the number of coins collected this run.")]
[SerializeField] private TMP_Text coinText;
[Tooltip("Shows the distance traveled this run.")]
[SerializeField] private TMP_Text distanceText;
[Header("Power-up Indicators")]
[Tooltip("Parent transform for power-up indicator icons.")]
[SerializeField] private Transform powerUpContainer;
[Tooltip("Icon shown when Magnet is active.")]
[SerializeField] private GameObject magnetIndicator;
[Tooltip("Icon shown when Shield is active.")]
[SerializeField] private GameObject shieldIndicator;
[Tooltip("Icon shown when Score Multiplier is active.")]
[SerializeField] private GameObject multiplierIndicator;
[Header("Power-up Timer Bars")]
[Tooltip("Fill image for the Magnet timer.")]
[SerializeField] private Image magnetTimerFill;
[Tooltip("Fill image for the Shield timer.")]
[SerializeField] private Image shieldTimerFill;
[Tooltip("Fill image for the Multiplier timer.")]
[SerializeField] private Image multiplierTimerFill;
[Header("Animation")]
[Tooltip("How much the score text scales up on coin collect.")]
[SerializeField] private float scorePunchScale = 1.2f;
[Tooltip("Duration of the score punch animation.")]
[SerializeField] private float scorePunchDuration = 0.15f;
// ---------------------------------------------------------------
// Private State
// ---------------------------------------------------------------
// Cached original scale for the score punch animation.
private Vector3 scoreOriginalScale;
// Running coroutine for score punch so we can cancel it.
private Coroutine scorePunchCoroutine;
// ---------------------------------------------------------------
// Unity Lifecycle
// ---------------------------------------------------------------
private void Awake()
{
if (scoreText != null)
{
scoreOriginalScale = scoreText.transform.localScale;
}
// Hide all power-up indicators initially.
SetPowerUpIndicator(magnetIndicator, false);
SetPowerUpIndicator(shieldIndicator, false);
SetPowerUpIndicator(multiplierIndicator, false);
// Hide multiplier text initially.
if (multiplierText != null)
{
multiplierText.gameObject.SetActive(false);
}
}
private void OnEnable()
{
// Subscribe to scoring events.
GameEvents.OnScoreUpdated += HandleScoreUpdated;
GameEvents.OnCoinCollected += HandleCoinCollected;
// Subscribe to power-up events.
GameEvents.OnPowerUpActivated += HandlePowerUpActivated;
GameEvents.OnPowerUpExpired += HandlePowerUpExpired;
GameEvents.OnPowerUpTimerUpdated += HandlePowerUpTimerUpdated;
GameEvents.OnMultiplierChanged += HandleMultiplierChanged;
}
private void OnDisable()
{
GameEvents.OnScoreUpdated -= HandleScoreUpdated;
GameEvents.OnCoinCollected -= HandleCoinCollected;
GameEvents.OnPowerUpActivated -= HandlePowerUpActivated;
GameEvents.OnPowerUpExpired -= HandlePowerUpExpired;
GameEvents.OnPowerUpTimerUpdated -= HandlePowerUpTimerUpdated;
GameEvents.OnMultiplierChanged -= HandleMultiplierChanged;
}
// ---------------------------------------------------------------
// Score Display
// ---------------------------------------------------------------
/// <summary>
/// Updates all HUD text elements with the latest score data.
/// Called periodically by the ScoreManager (every 0.1s).
/// </summary>
private void HandleScoreUpdated(ScoreData data)
{
// Update score text.
if (scoreText != null)
{
scoreText.text = ScoreManager.FormatScore(data.TotalScore);
}
// Update coin count.
if (coinText != null)
{
coinText.text = data.CoinsCollected.ToString();
}
// Update distance.
if (distanceText != null)
{
distanceText.text = ScoreManager.FormatDistance(
data.DistanceTraveled
);
}
}
/// <summary>
/// Plays a "punch" scale animation on the score text when a
/// coin is collected. This gives satisfying visual feedback.
/// </summary>
private void HandleCoinCollected(int value)
{
if (scoreText == null) return;
// Cancel any running punch animation.
if (scorePunchCoroutine != null)
{
StopCoroutine(scorePunchCoroutine);
}
scorePunchCoroutine = StartCoroutine(ScorePunchAnimation());
}
/// <summary>
/// Quickly scales the score text up and back down for a
/// satisfying "pop" effect.
/// </summary>
private IEnumerator ScorePunchAnimation()
{
Transform target = scoreText.transform;
float halfDuration = scorePunchDuration * 0.5f;
// Scale up.
float elapsed = 0f;
while (elapsed < halfDuration)
{
elapsed += Time.deltaTime;
float t = elapsed / halfDuration;
target.localScale = Vector3.Lerp(
scoreOriginalScale,
scoreOriginalScale * scorePunchScale,
t
);
yield return null;
}
// Scale back down.
elapsed = 0f;
while (elapsed < halfDuration)
{
elapsed += Time.deltaTime;
float t = elapsed / halfDuration;
target.localScale = Vector3.Lerp(
scoreOriginalScale * scorePunchScale,
scoreOriginalScale,
t
);
yield return null;
}
target.localScale = scoreOriginalScale;
scorePunchCoroutine = null;
}
// ---------------------------------------------------------------
// Power-up Display
// ---------------------------------------------------------------
/// <summary>
/// Shows the indicator for an activated power-up.
/// </summary>
private void HandlePowerUpActivated(PowerUpType type, float duration)
{
switch (type)
{
case PowerUpType.Magnet:
SetPowerUpIndicator(magnetIndicator, true);
break;
case PowerUpType.Shield:
SetPowerUpIndicator(shieldIndicator, true);
break;
case PowerUpType.ScoreMultiplier:
SetPowerUpIndicator(multiplierIndicator, true);
break;
}
}
/// <summary>
/// Hides the indicator for an expired power-up.
/// </summary>
private void HandlePowerUpExpired(PowerUpType type)
{
switch (type)
{
case PowerUpType.Magnet:
SetPowerUpIndicator(magnetIndicator, false);
break;
case PowerUpType.Shield:
SetPowerUpIndicator(shieldIndicator, false);
break;
case PowerUpType.ScoreMultiplier:
SetPowerUpIndicator(multiplierIndicator, false);
break;
}
}
/// <summary>
/// Updates the timer fill bar for an active power-up.
/// </summary>
private void HandlePowerUpTimerUpdated(
PowerUpType type,
float normalizedTime)
{
Image fillImage = type switch
{
PowerUpType.Magnet => magnetTimerFill,
PowerUpType.Shield => shieldTimerFill,
PowerUpType.ScoreMultiplier => multiplierTimerFill,
_ => null
};
if (fillImage != null)
{
fillImage.fillAmount = normalizedTime;
}
}
/// <summary>
/// Shows or hides the multiplier text (e.g., "x2").
/// </summary>
private void HandleMultiplierChanged(int multiplier)
{
if (multiplierText == null) return;
if (multiplier > 1)
{
multiplierText.gameObject.SetActive(true);
multiplierText.text = $"x{multiplier}";
}
else
{
multiplierText.gameObject.SetActive(false);
}
}
/// <summary>
/// Shows or hides a power-up indicator GameObject.
/// </summary>
private void SetPowerUpIndicator(GameObject indicator, bool show)
{
if (indicator != null)
{
indicator.SetActive(show);
}
}
}
}Game Over Panel
The game over screen is the most important UI panel because it is what the player sees after every run. It must clearly show how well they did, celebrate new high scores, and make it easy to play again.
Building the Game Over Panel in Unity
- Select
GameOverPanelin the Hierarchy. - Add a semi-transparent black Image as a background (color: 0,0,0 with alpha 0.7). Set its RectTransform to stretch-stretch.
- Create a child empty named
StatsContainer. This holds all the stats text elements. - Inside StatsContainer, create Text - TextMeshPro elements:
GameOverTitle— "GAME OVER", font size 60.FinalScoreLabel— "Score", font size 28.FinalScoreValue— dynamic, font size 52.HighScoreText— "Best: 12,345" or "NEW!", font size 32.CoinsCollectedText— "Coins: 47", font size 28.DistanceText— "Distance: 1,234.5m", font size 28.
- Add a Button named
RestartButtonwith text "PLAY AGAIN". - Add a Button named
MainMenuButtonwith text "MAIN MENU".
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using InfiniteRunner.Core;
using InfiniteRunner.Gameplay;
namespace InfiniteRunner.UI
{
/// <summary>
/// Controls the Game Over panel. Displays final score, high score,
/// coins collected, distance traveled, and provides restart/menu buttons.
/// Features animated stat reveals and a "NEW!" high score indicator.
///
/// Attach this to the GameOverPanel GameObject.
/// </summary>
public class GameOverUI : MonoBehaviour
{
// ---------------------------------------------------------------
// Inspector Fields
// ---------------------------------------------------------------
[Header("Score Display")]
[Tooltip("Shows the final total score.")]
[SerializeField] private TMP_Text finalScoreText;
[Tooltip("Shows the high score. Includes 'NEW!' if beaten.")]
[SerializeField] private TMP_Text highScoreText;
[Tooltip("The 'NEW!' indicator that appears for new high scores.")]
[SerializeField] private GameObject newHighScoreIndicator;
[Header("Stats Display")]
[Tooltip("Shows total coins collected this run.")]
[SerializeField] private TMP_Text coinsCollectedText;
[Tooltip("Shows total distance traveled this run.")]
[SerializeField] private TMP_Text distanceText;
[Header("Buttons")]
[Tooltip("Button to restart the game immediately.")]
[SerializeField] private Button restartButton;
[Tooltip("Button to return to the main menu.")]
[SerializeField] private Button mainMenuButton;
[Header("Animation")]
[Tooltip("The container holding all stats for animated reveal.")]
[SerializeField] private RectTransform statsContainer;
[Tooltip("Delay between revealing each stat element.")]
[SerializeField] private float statRevealDelay = 0.2f;
[Tooltip("All stat text elements in reveal order.")]
[SerializeField] private CanvasGroup[] statElements;
// ---------------------------------------------------------------
// Unity Lifecycle
// ---------------------------------------------------------------
private void Awake()
{
if (restartButton != null)
{
restartButton.onClick.AddListener(OnRestartClicked);
}
if (mainMenuButton != null)
{
mainMenuButton.onClick.AddListener(OnMainMenuClicked);
}
// Hide the new high score indicator by default.
if (newHighScoreIndicator != null)
{
newHighScoreIndicator.SetActive(false);
}
}
private void OnEnable()
{
// Listen for the final score event.
GameEvents.OnFinalScoreCalculated += HandleFinalScore;
}
private void OnDisable()
{
GameEvents.OnFinalScoreCalculated -= HandleFinalScore;
}
private void OnDestroy()
{
if (restartButton != null)
{
restartButton.onClick.RemoveListener(OnRestartClicked);
}
if (mainMenuButton != null)
{
mainMenuButton.onClick.RemoveListener(OnMainMenuClicked);
}
}
// ---------------------------------------------------------------
// Score Display
// ---------------------------------------------------------------
/// <summary>
/// Populates the game over screen with the final score data
/// and starts the animated reveal sequence.
/// </summary>
private void HandleFinalScore(ScoreData data)
{
// Populate text fields.
if (finalScoreText != null)
{
finalScoreText.text = ScoreManager.FormatScore(data.TotalScore);
}
if (highScoreText != null)
{
highScoreText.text =
$"Best: {ScoreManager.FormatScore(data.HighScore)}";
}
if (coinsCollectedText != null)
{
coinsCollectedText.text =
$"Coins: {data.CoinsCollected}";
}
if (distanceText != null)
{
distanceText.text =
$"Distance: {ScoreManager.FormatDistance(data.DistanceTraveled)}";
}
// Show "NEW!" if this is a new high score.
if (newHighScoreIndicator != null)
{
newHighScoreIndicator.SetActive(data.IsNewHighScore);
}
// Start the animated reveal.
StartCoroutine(RevealStatsSequence());
}
/// <summary>
/// Reveals each stat element one at a time with a fade-in,
/// creating a dramatic reveal effect.
/// </summary>
private IEnumerator RevealStatsSequence()
{
// Hide all stat elements initially.
if (statElements != null)
{
for (int i = 0; i < statElements.Length; i++)
{
if (statElements[i] != null)
{
statElements[i].alpha = 0f;
}
}
}
// Pop in the stats container.
if (statsContainer != null)
{
UIManager.Instance?.PopIn(statsContainer, 0.4f);
}
// Wait for the container to appear.
yield return new WaitForSecondsRealtime(0.3f);
// Reveal each stat element with a delay.
if (statElements != null)
{
for (int i = 0; i < statElements.Length; i++)
{
if (statElements[i] == null) continue;
// Fade in this stat element.
StartCoroutine(FadeInElement(statElements[i], 0.25f));
// Wait before revealing the next one.
yield return new WaitForSecondsRealtime(statRevealDelay);
}
}
}
/// <summary>
/// Fades a CanvasGroup from 0 to 1 alpha over the given duration.
/// </summary>
private IEnumerator FadeInElement(CanvasGroup element, float duration)
{
float elapsed = 0f;
element.alpha = 0f;
while (elapsed < duration)
{
elapsed += Time.unscaledDeltaTime;
element.alpha = Mathf.Clamp01(elapsed / duration);
yield return null;
}
element.alpha = 1f;
}
// ---------------------------------------------------------------
// Button Handlers
// ---------------------------------------------------------------
/// <summary>
/// Restarts the game. Fires the game start event.
/// </summary>
private void OnRestartClicked()
{
Debug.Log("[GameOverUI] Restart clicked.");
GameEvents.OnGameStarted?.Invoke();
}
/// <summary>
/// Returns to the main menu.
/// </summary>
private void OnMainMenuClicked()
{
Debug.Log("[GameOverUI] Main Menu clicked.");
UIManager.Instance?.ShowMainMenu();
}
}
}Instead of showing all stats at once, we reveal them one at a time with a short delay between each. This creates a dramatic, slot-machine-like effect where the player watches their stats appear: score... coins... distance... and finally, the high score with a potential "NEW!" celebration. This small touch makes the game over screen feel polished and professional.
Pause Panel
The pause panel overlays on top of the HUD when the player pauses the game. It provides resume, restart, and main menu options.
Building the Pause Panel in Unity
- Select
PausePanelin the Hierarchy. - Add a semi-transparent black Image background (same as game over).
- Add Text - TextMeshPro: "PAUSED", font size 60, centered.
- Add three Buttons: Resume, Restart, Main Menu.
- Position buttons vertically in the center of the panel.
using UnityEngine;
using UnityEngine.UI;
using InfiniteRunner.Core;
namespace InfiniteRunner.UI
{
/// <summary>
/// Controls the Pause panel. Provides resume, restart, and
/// main menu buttons. Pausing sets Time.timeScale to 0.
///
/// Attach this to the PausePanel GameObject.
/// </summary>
public class PauseUI : MonoBehaviour
{
// ---------------------------------------------------------------
// Inspector Fields
// ---------------------------------------------------------------
[Header("Buttons")]
[SerializeField] private Button resumeButton;
[SerializeField] private Button restartButton;
[SerializeField] private Button mainMenuButton;
// ---------------------------------------------------------------
// Unity Lifecycle
// ---------------------------------------------------------------
private void Awake()
{
if (resumeButton != null)
resumeButton.onClick.AddListener(OnResumeClicked);
if (restartButton != null)
restartButton.onClick.AddListener(OnRestartClicked);
if (mainMenuButton != null)
mainMenuButton.onClick.AddListener(OnMainMenuClicked);
}
private void OnDestroy()
{
if (resumeButton != null)
resumeButton.onClick.RemoveListener(OnResumeClicked);
if (restartButton != null)
restartButton.onClick.RemoveListener(OnRestartClicked);
if (mainMenuButton != null)
mainMenuButton.onClick.RemoveListener(OnMainMenuClicked);
}
// ---------------------------------------------------------------
// Button Handlers
// ---------------------------------------------------------------
private void OnResumeClicked()
{
// Restore normal time scale.
Time.timeScale = 1f;
GameEvents.OnGameResumed?.Invoke();
}
private void OnRestartClicked()
{
// Restore time scale before restarting.
Time.timeScale = 1f;
GameEvents.OnGameStarted?.Invoke();
}
private void OnMainMenuClicked()
{
Time.timeScale = 1f;
UIManager.Instance?.ShowMainMenu();
}
}
}When pausing, we set Time.timeScale = 0 to freeze the game. It is critical to reset it to 1 before resuming, restarting, or going to the main menu. If you forget, the game will remain frozen even after the pause menu is dismissed. Every button handler in PauseUI sets Time.timeScale = 1f as its first action.
Required Event Declarations
Add these to your GameEvents class for the UI system:
using System;
namespace InfiniteRunner.Core
{
public static partial class GameEvents
{
// --- Pause Events ---
/// <summary>Fired when the game is paused.</summary>
public static Action OnGamePaused;
/// <summary>Fired when the game is resumed from pause.</summary>
public static Action OnGameResumed;
}
}Chapter Summary
In this chapter, you built a complete UI system for the infinite runner:
- Canvas Setup — Screen Space Overlay with Canvas Scaler set to Scale With Screen Size for responsive layout across all devices.
- UIManager.cs — Central controller that manages panel transitions with smooth fade animations using coroutines and CanvasGroup.
- MainMenuUI.cs — Title screen with play button, settings button, and persistent high score display.
- HUDUI.cs — In-game display showing score (with punch animation on coin collect), coin count, distance, multiplier text, and power-up indicators with timer bars.
- GameOverUI.cs — Results screen with animated stat reveals, "NEW!" high score indicator, and restart/menu buttons.
- PauseUI.cs — Overlay panel with resume, restart, and main menu buttons, properly managing Time.timeScale.
- Scene Management — Transitioning between MenuScene and GameScene using SceneManager, keeping managers alive with DontDestroyOnLoad, and async loading with a progress bar via SceneLoader.cs.
The UI system is entirely event-driven. It does not directly reference game systems — it listens for events and updates its display accordingly. This means you can change the scoring system, power-up system, or game state machine without touching the UI code. In the next chapter, we will add audio to bring the game to life with sound effects and music.
Scene Management
Right now, our entire game runs in a single scene. The main menu, HUD, game over screen, and all gameplay objects all live together. We show and hide panels to create the illusion of different screens. This works — and for a small game like ours, it works well. But production games take a different approach: multiple scenes.
Understanding scene management now will serve you well as your projects grow, and it also solves some real problems even in small games. Let's explore how it works.
Why Scene Management Matters
The single-scene approach has limitations that become painful as a game grows:
- Startup time — Every object in the scene must be loaded before anything appears. A menu scene with just a Canvas and a camera loads almost instantly. A gameplay scene with terrain, obstacles, particle systems, and audio takes longer. With separate scenes, the player sees the menu immediately while the heavy gameplay scene loads only when they click "Play".
- Memory footprint — When everything is in one scene, all gameplay objects sit in memory even while the player is on the menu. Separate scenes mean the menu only loads what it needs.
- Clean separation — Menu logic and gameplay logic are fundamentally different concerns. Keeping them in separate scenes prevents accidental coupling and makes each scene easier to understand.
- Team collaboration — If two people work on the same scene, Unity scene files create merge conflicts that are nearly impossible to resolve. Separate scenes mean different team members can work on different parts of the game without conflicts.
A typical production setup uses at least two scenes:
- MenuScene — Lightweight. Contains a camera, the menu Canvas, and a background. Loads fast.
- GameScene — Heavy. Contains all gameplay objects: the player, spawners, terrain, the game camera, and the gameplay Canvas (HUD, pause, game over).
Some games add a third LoadingScene that shows a progress bar while the game scene loads in the background. We will build that capability too.
Adding Scenes to Build Settings
Unity needs to know which scenes are part of your game. Scenes that are not registered in the Build Settings cannot be loaded at runtime.
- Open File → Build Settings (or press Ctrl+Shift+B).
- With your current scene open, click Add Open Scenes. The scene appears in the list with a checkbox and an index number.
- The first scene (index 0) is the one that loads automatically when the game starts. This should be your MenuScene.
- You can drag scenes to reorder them. Each scene gets an index number (0, 1, 2, and so on).
- Create a second scene for gameplay: File → New Scene, set it up with your gameplay objects, save it as
GameScene, then add it to Build Settings as well.
Your Build Settings should look like this:
MenuScene— index 0 (loads on game start)GameScene— index 1 (loaded when the player clicks "Play")
Loading Scenes
Unity provides the SceneManager class for loading scenes at runtime. You need to include the UnityEngine.SceneManagement namespace:
using UnityEngine.SceneManagement;
// Load by name (recommended — more readable)
SceneManager.LoadScene("GameScene");
// Load by index (faster but harder to maintain)
SceneManager.LoadScene(1);Loading by name is recommended because it makes your code self-documenting. If you reorder scenes in the Build Settings, name-based loading still works. Index-based loading would break.
SceneManager.LoadScene unloads the current scene and destroys ALL GameObjects in it. Every object that is not marked with DontDestroyOnLoad is gone permanently. This is usually what you want — when you leave the menu, you do not need the menu objects anymore. But it means you need to be deliberate about which objects survive the transition. More on this in the next section.
DontDestroyOnLoad — Persistent Managers
Some objects need to survive scene changes. The two most common examples:
- AudioManager — Background music should not stop when you transition from the menu to gameplay. If the AudioManager is destroyed and recreated, the music restarts from the beginning.
- GameManager — Tracks global game state like settings, player data, or analytics. Destroying and recreating it would lose that data.
Unity provides DontDestroyOnLoad(gameObject) to mark an object as persistent. When a scene is unloaded, objects marked this way are moved to a special internal scene and survive the transition.
The best place to add this is in our Singleton base class, so every manager that uses the Singleton pattern automatically persists across scenes:
// In Singleton<T>.Awake(), add DontDestroyOnLoad:
protected virtual void Awake()
{
if (instance != null && instance != this)
{
Destroy(gameObject);
return;
}
instance = this as T;
DontDestroyOnLoad(gameObject); // Survive scene loads
}This simple addition gives us two critical behaviors:
- Persistence — The first instance of each manager survives scene loads. The AudioManager created in the MenuScene is still alive when the GameScene loads.
- Duplicate prevention — If the GameScene also has an AudioManager in its Hierarchy, the Singleton check catches it. The
if (instance != null && instance != this)block destroys the duplicate immediately. This is exactly why we wrote the Singleton pattern this way in an earlier chapter.
During Play Mode, objects marked with DontDestroyOnLoad appear in a special "DontDestroyOnLoad" section at the bottom of the Hierarchy window. This is not a real scene — it is Unity's way of showing you which objects have been marked as persistent. If you see unexpected objects piling up there, you likely have a duplicate-creation bug. Check that your Singleton base class properly destroys duplicates.
Async Scene Loading with Progress
SceneManager.LoadScene is synchronous — it freezes the entire game until the new scene is fully loaded. For small scenes this freeze is barely noticeable. For larger scenes, the player stares at a frozen screen with no feedback, which feels like a crash.
The solution is async scene loading. Unity loads the scene in the background across multiple frames while we show a loading screen with a progress bar. The player sees smooth feedback instead of a freeze.
using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
namespace InfiniteRunner.UI
{
/// <summary>
/// Handles scene transitions with a loading screen.
/// Attach to a persistent object (DontDestroyOnLoad).
/// </summary>
public class SceneLoader : MonoBehaviour
{
[Header("Loading Screen")]
[SerializeField] private GameObject loadingScreen;
[SerializeField] private Slider progressBar;
/// <summary>
/// Load a scene with a loading screen and progress bar.
/// </summary>
public void LoadScene(string sceneName)
{
StartCoroutine(LoadSceneAsync(sceneName));
}
private IEnumerator LoadSceneAsync(string sceneName)
{
// Show loading screen
if (loadingScreen != null)
{
loadingScreen.SetActive(true);
}
// Start loading the scene in the background
AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName);
// Don't let the scene activate until we're ready
operation.allowSceneActivation = false;
while (!operation.isDone)
{
// Unity reports progress from 0 to 0.9
// (0.9 means "ready to activate")
float progress = Mathf.Clamp01(operation.progress / 0.9f);
if (progressBar != null)
{
progressBar.value = progress;
}
// When loading reaches 90%, activate the scene
if (operation.progress >= 0.9f)
{
// Optional: wait a moment so the player sees 100%
yield return new WaitForSeconds(0.5f);
operation.allowSceneActivation = true;
}
yield return null;
}
// Hide loading screen
if (loadingScreen != null)
{
loadingScreen.SetActive(false);
}
}
}
}Let's break down each part of this script:
SceneManager.LoadSceneAsync(sceneName)— Starts loading the scene in the background without freezing the game. Returns anAsyncOperationthat we can monitor.operation.progress— Goes from 0 to 0.9. This is a Unity quirk: the value 0.9 means "loading is complete, ready to activate." It never reaches 1.0 until the scene is actually activated. We divide by 0.9 to normalize it to a 0–1 range for the progress bar.operation.allowSceneActivation = false— Tells Unity "don't switch to the new scene yet, even when it's ready." This gives us control over exactly when the transition happens. Without this, the scene would switch the instant loading finishes, potentially cutting off the loading animation.- The progress bar — Fills smoothly so the player knows something is happening. Even if loading is fast, the brief 0.5-second wait at 100% gives the player a moment to register that loading is complete before the scene switches.
The SceneLoader uses a coroutine to manage the loading process. If the SceneLoader's GameObject is destroyed when the old scene unloads, the coroutine stops and the new scene never activates. That is why the SceneLoader must live on a DontDestroyOnLoad object. Attach it to one of your persistent managers or create a dedicated persistent object for it.
The Complete Game Flow
With scene management in place, here is the full flow of the game from start to finish:
Game Start
|
v
MenuScene (loads first -- index 0)
|
+-- Player clicks "Play"
| |
| v
| Loading Screen (brief)
| |
| v
| GameScene (gameplay)
| |
| +-- Player dies --> Game Over panel
| | |
| | +-- "Restart" --> Reload GameScene
| | |
| | +-- "Main Menu" --> Load MenuScene
| |
| +-- Player pauses --> Pause panel
| |
| +-- "Main Menu" --> Load MenuScene
|
+-- Player clicks "Quit"
|
v
Application.Quit()Every arrow that crosses a scene boundary (MenuScene → GameScene or GameScene → MenuScene) is a SceneManager.LoadScene call. Every arrow that stays within a scene (showing the game over panel, showing the pause panel) is a panel toggle handled by the UIManager.
Updating Our UI Scripts
Now that we understand scene management, let's update our existing UI scripts to use scene loading for transitions between the menu and gameplay.
MainMenuUI — Starting the Game
The Play button should load the GameScene instead of just toggling a panel:
using UnityEngine;
using UnityEngine.SceneManagement;
namespace InfiniteRunner.UI
{
public class MainMenuUI : MonoBehaviour
{
public void OnPlayClicked()
{
SceneManager.LoadScene("GameScene");
// Or for a loading screen with progress bar:
// FindFirstObjectByType<SceneLoader>().LoadScene("GameScene");
}
public void OnQuitClicked()
{
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif
}
}
}Application.Quit() only works in a built game. In the Unity Editor, it does nothing — the game keeps running. The #if UNITY_EDITOR preprocessor directive lets us use EditorApplication.isPlaying = false instead, which stops Play Mode in the editor. This code is stripped from builds automatically, so there is no performance or security concern.
GameOverUI — Restart and Main Menu
The game over panel needs two scene-related buttons: restart (reload the current scene) and return to menu (load the MenuScene):
public void OnRestartClicked()
{
// Reload the current scene — gets the name dynamically
// so this works regardless of what the scene is called.
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}
public void OnMainMenuClicked()
{
// CRITICAL: Reset time scale before changing scenes.
// If the player paused, then died, timeScale might still be 0.
Time.timeScale = 1f;
SceneManager.LoadScene("MenuScene");
}Notice the Time.timeScale = 1f call in OnMainMenuClicked. This is a defensive measure. If the player paused the game (setting timeScale to 0), then somehow reached the game over screen, the time scale might still be zero. Loading a new scene does not automatically reset Time.timeScale — it is a global setting that persists across scene loads. If you forget to reset it, the MenuScene loads with time frozen and nothing works.
Additive Scene Loading (Advanced)
Everything we have covered so far uses single mode loading, where loading a new scene destroys the old one. Unity also supports additive mode, where a new scene is loaded on top of the current scene without unloading anything:
// Load a scene additively (does NOT unload the current scene)
SceneManager.LoadScene("UIScene", LoadSceneMode.Additive);
// Later, unload the additive scene when you no longer need it
SceneManager.UnloadSceneAsync("UIScene");Additive loading is useful for:
- Persistent UI overlays — A shared UI scene that stays loaded across multiple gameplay scenes.
- Streaming large worlds — Loading and unloading chunks of the world as the player moves through it.
- Shared elements — A lighting scene or audio scene that multiple gameplay scenes share.
For our infinite runner, the single-scene load approach is the right choice. We have two scenes (menu and gameplay) with clean transitions between them. Additive loading is more common in larger games with open worlds or shared UI layers. It is good to know it exists, but do not add complexity you do not need. You can always refactor to additive loading later if your game outgrows the simple approach.
Testing Scene Transitions
Scene transitions introduce a new category of bugs — things that work perfectly when you test within a single scene but break when you actually load between scenes. Here is a systematic checklist for verifying everything works:
- Add both scenes to Build Settings — Open File → Build Settings and click Add Open Scenes for each scene. Verify that MenuScene is index 0.
- Play from MenuScene — Always start testing from the scene that loads first. If you play from GameScene, you skip the menu flow and may miss bugs.
- Click Play — Verify that GameScene loads. The menu should disappear and gameplay should start.
- Die and verify Game Over — Play until you die. The Game Over panel should appear with correct stats.
- Click Restart — Verify that the game reloads cleanly. Score resets to zero, obstacles are fresh, the player is at the starting position.
- Click Main Menu — Verify that MenuScene loads and the main menu appears correctly.
- Verify audio continuity — If your AudioManager uses
DontDestroyOnLoad, background music should continue playing across scene transitions without restarting. - Check for duplicate managers — After returning to MenuScene, open the Hierarchy and look at the DontDestroyOnLoad section. There should be exactly one instance of each manager, not duplicates. If you see duplicates, your Singleton base class is not destroying them properly.
- Repeat the full loop — Go Menu → Game → Die → Menu → Game → Die → Restart → Die → Menu. Each cycle should work identically. Memory leaks and duplicate objects tend to accumulate over multiple cycles.
The UI system is substantial — menus, HUD, pause, game over. This is worth introducing one more Git concept: GitHub.
git add .
git commit -m "Implement complete UI system with menus, HUD, and game over"
Your commits are only on your local machine. If your hard drive dies, everything is lost. Let's back it up to GitHub — a free website that hosts Git repositories. Here's how:
- Go to
github.comand create a free account (if you don't have one). - Click New Repository. Name it
infinite-runner. Keep it private or public — your choice. Do NOT add a README (we already have files). - GitHub will show you commands. Run these in your terminal:
git remote add origin https://github.com/YOUR-USERNAME/infinite-runner.git
git push -u origin main
Your entire project history is now backed up on GitHub. From now on, after committing, you can run git push to upload your latest commits. We'll remind you.