Skip to main content

โธ๏ธ Lesson 4.2: Main Menu & Pause System

Every game needs two screens before it needs anything else: a main menu to start from, and a pause menu to escape to. In this lesson you'll build both. You'll freeze the whole game with a single line, toggle a pause panel on and off, wire Resume and Quit, and route the pause key through the new Input System โ€” then handle the one gotcha that catches everyone: animations that die when the game freezes.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Lay out a main menu and a pause overlay as UI panels
  • Show and hide a panel by toggling a GameObject's active state
  • Freeze and unfreeze gameplay with Time.timeScale
  • Wire Resume, Restart, and Quit buttons in code
  • Trigger pause from a new Input System action callback
  • Keep menu animations running while paused using unscaled time

Estimated Time: 45 minutes  ยท  Prerequisite: Lesson 4.1 (Canvas Render Modes & Scaling) and Module 3 (the new Input System)

In This Lesson

The Pause Menu Hierarchy

The pause menu is different from the main menu: it lives inside your gameplay scene, hidden until needed. The trick is to build it as a single parent Panel that you switch on and off. Here's the Hierarchy layout you're aiming for:

The pause menu hierarchy in Unity A recreation of Unity's Hierarchy window. Under a Canvas set to Screen Space Overlay sits a PauseMenu panel (shown greyed out because it is inactive) containing a semi-transparent Background image, a Title text reading Paused, and three buttons: ResumeButton, RestartButton, and QuitButton. The GameManager object holding the pause script sits alongside the Canvas. Hierarchy Searchโ€ฆ โ–พ ๐Ÿ—” Canvas Screen Space - Overlay โ–พ โ–ข PauseMenu inactive ๐Ÿ–ผ Background Image ยท black 60% alpha T Title "Paused" TextMeshPro - Text (UI) ๐Ÿ”˜ ResumeButton Button โ–ธ OnClick โ†’ Resume() ๐Ÿ”˜ RestartButton Button โ–ธ OnClick โ†’ Restart() ๐Ÿ”˜ QuitButton Button โ–ธ OnClick โ†’ QuitToMenu() ๐Ÿ—” EventSystem โš™ GameManager PauseController.cs The whole PauseMenu panel is toggled active/inactive as one unit; its children come and go with it.
Figure 1: The pause menu hierarchy (faithfully recreated). The PauseMenu panel is disabled by default (greyed out) and switched on when the player pauses. Its background dims the game; the three buttons call methods on PauseController.

The key idea: build the panel once, set it inactive (untick the checkbox at the top-left of its Inspector), and let a script flip it on. Everything inside it โ€” the dim background, the title, the buttons โ€” appears and disappears with the parent.

๐Ÿ“– Definition

Overlay panel: a full-screen UI child (usually an Image stretched to all four anchors, tinted black at partial alpha) that sits above gameplay to dim it and hold menu content. Toggling its GameObject active/inactive is the cheapest, most reliable way to show and hide a menu.

Freezing the Game with Time.timeScale

Pausing isn't just hiding the game behind a panel โ€” the action underneath has to stop. Unity gives you one global dial for this: Time.timeScale.

  • Time.timeScale = 1f โ†’ normal speed (the default).
  • Time.timeScale = 0f โ†’ frozen. Physics stops, Time.deltaTime becomes 0, and anything driven by it halts.
  • Time.timeScale = 0.5f โ†’ half speed (handy for slow-motion effects).

Setting it to 0 freezes almost everything automatically: Rigidbody motion, most animations, and any code that multiplies by Time.deltaTime (which, if you followed Fundamentals, is most of your movement code). That's why it's the standard pause switch.

๐Ÿ“– Definition

Time.timeScale: a global multiplier applied to the flow of scaled game time. At 0 the game clock stops, so Time.deltaTime reads 0 and time-based updates freeze. It does not stop Update() from running โ€” your scripts still tick, they just see no elapsed time.

โš ๏ธ Update still runs at timeScale 0

Freezing time does not pause your scripts' Update() methods โ€” they keep being called every frame. What changes is that Time.deltaTime becomes 0, so any motion computed as speed * Time.deltaTime produces no movement. Code that ignores deltaTime (like reading a raw input each frame) still runs. This is exactly why your pause key still works while paused.

Toggling the Pause Panel

Now the controller that ties the panel and the time freeze together. It tracks whether we're paused, shows/hides the panel with SetActive, and drives Time.timeScale. The button methods (Resume, Restart, QuitToMenu) are public so the Inspector's On Click events can call them.

using UnityEngine;
using UnityEngine.SceneManagement;

public class PauseController : MonoBehaviour
{
    [SerializeField] GameObject pauseMenu;   // the PauseMenu panel

    public bool IsPaused { get; private set; }

    public void TogglePause()
    {
        if (IsPaused) Resume();
        else Pause();
    }

    public void Pause()
    {
        IsPaused = true;
        pauseMenu.SetActive(true);           // show the panel
        Time.timeScale = 0f;                 // freeze gameplay
    }

    public void Resume()
    {
        IsPaused = false;
        pauseMenu.SetActive(false);          // hide the panel
        Time.timeScale = 1f;                 // unfreeze
    }

    public void Restart()
    {
        Time.timeScale = 1f;                 // ALWAYS restore before leaving!
        SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
    }

    public void QuitToMenu()
    {
        Time.timeScale = 1f;                 // ALWAYS restore before leaving!
        SceneManager.LoadScene("MainMenu");
    }
}

โš ๏ธ Always restore timeScale before changing scenes

Notice every method that leaves the scene sets Time.timeScale = 1f first. timeScale is global and persists across scene loads โ€” if you quit to the menu while frozen and forget to restore it, your next game starts completely frozen and looks broken. This is one of the most common "my game won't move after restart" bugs.

Pause from the Input System

In Module 3 you built Input Actions and read them through callbacks. Pausing is a perfect one-shot action. Add an action called Pause to your Action Map (bind it to Escape on keyboard and Start on gamepad), set its Action Type to Button, and subscribe to its performed callback.

Here's the pause controller wired to a PlayerInput-style action reference. Using an InputActionReference keeps the binding editable in the Inspector:

using UnityEngine;
using UnityEngine.InputSystem;

public class PauseInput : MonoBehaviour
{
    [SerializeField] PauseController pause;
    [SerializeField] InputActionReference pauseAction;   // the "Pause" action

    void OnEnable()
    {
        pauseAction.action.performed += OnPause;
        pauseAction.action.Enable();
    }

    void OnDisable()
    {
        pauseAction.action.performed -= OnPause;
    }

    void OnPause(InputAction.CallbackContext ctx)
    {
        pause.TogglePause();     // one press flips between paused and playing
    }
}

Because input polling and action callbacks are driven by real time (not scaled game time), the Pause action keeps firing even when Time.timeScale is 0 โ€” which is exactly what lets the player press Escape again to un-pause. If you had instead read a key inside a deltaTime-based loop, you'd be fine here too, since reading input doesn't depend on deltaTime.

โœ… Pro Tip

Give your pause action its own tiny Action Map, or make sure it lives in a map that stays enabled during gameplay. A frequent mistake is disabling the whole "Player" map on pause (to stop movement) and accidentally disabling the pause key with it โ€” leaving the player unable to resume.

Animating While Paused: Unscaled Time

Here's the gotcha that surprises everyone. You freeze the game with Time.timeScale = 0, then your pause menu's fade-in or button-hover animationโ€ฆ doesn't play. Because the Animator and any Time.deltaTime code are running on scaled time โ€” which you just set to zero.

The fix is unscaled time โ€” a parallel clock that ignores timeScale and always ticks in real seconds:

  • In code: use Time.unscaledDeltaTime instead of Time.deltaTime for any animation that must run while paused (a menu fade, a spinner).
  • On an Animator: set its Update Mode to Unscaled Time (Animator component โ–ธ Update Mode dropdown). It will now animate even at timeScale 0.
  • On coroutines: yield new WaitForSecondsRealtime(0.3f) instead of WaitForSeconds, which would wait forever at timeScale 0.

A simple unscaled fade for the pause panel's CanvasGroup:

using System.Collections;
using UnityEngine;

public class PanelFade : MonoBehaviour
{
    [SerializeField] CanvasGroup group;
    [SerializeField] float duration = 0.25f;

    public void FadeIn()
    {
        gameObject.SetActive(true);
        StartCoroutine(Fade(0f, 1f));
    }

    IEnumerator Fade(float from, float to)
    {
        float t = 0f;
        while (t < duration)
        {
            t += Time.unscaledDeltaTime;              // real time, ignores the pause
            group.alpha = Mathf.Lerp(from, to, t / duration);
            yield return null;
        }
        group.alpha = to;
    }
}

โš ๏ธ WaitForSeconds hangs forever when paused

A coroutine that does yield return new WaitForSeconds(1f) will never complete at timeScale = 0, because scaled time never advances. Use WaitForSecondsRealtime for anything that must progress during a pause.

The whole pause flow, end to end:

stateDiagram-v2 [*] --> Playing Playing --> Paused: Pause action performed Paused --> Playing: Resume (button or Pause action) Paused --> Reloading: Restart / Quit Reloading --> [*] state Playing { [*] --> Running note right of Running: timeScale = 1
panel hidden } state Paused { [*] --> Frozen note right of Frozen: timeScale = 0
panel shown
menu anims use unscaled time }

Figure 2: The pause state flow. The Pause action toggles between Playing and Paused; any exit path first restores timeScale to 1.

Hands-on Challenge

๐Ÿ‹๏ธ Exercise 1: A working pause overlay

Objective: Pause and resume your game with a key and a button.

  1. In your gameplay scene, add an Overlay Canvas with a PauseMenu panel: a dim full-screen Image, a "Paused" title, and Resume / Restart / Quit buttons. Set the panel inactive.
  2. Add a GameManager object with PauseController; drag the PauseMenu panel into its field.
  3. Wire each button's On Click to the matching PauseController method.
  4. Add a Pause Input Action (Escape / gamepad Start) and a PauseInput component; hook it to the controller.
  5. Press Play. Tap Escape: the game freezes and the panel appears. Tap again (or click Resume): it unfreezes.
๐Ÿ’ก Hint: pressing Escape does nothing

Confirm the Pause action is enabled (the OnEnable call), that its binding is Escape, and that you subscribed to performed (not started). Also make sure the object with PauseInput is active in the scene.

โœ… Success check

Movement and physics stop the instant you pause, the panel dims the game, Resume returns you to normal speed, and Restart/Quit both work without the next scene starting frozen.

๐Ÿ‹๏ธ Exercise 2: Fade the panel in while frozen

Add a CanvasGroup to the PauseMenu panel and the PanelFade script. Call FadeIn() from Pause() instead of a bare SetActive(true). Confirm the fade animates smoothly even though Time.timeScale is 0 โ€” proof that Time.unscaledDeltaTime is doing its job. Then break it on purpose: swap in Time.deltaTime and watch the fade freeze, so you understand exactly why unscaled time matters.

๐ŸŽฏ Quick Quiz

Question 1: What is the standard way to freeze all gameplay when the player pauses?

Question 2: Your pause-menu fade animation freezes when you pause. What's the fix?

Question 3: Why must you set Time.timeScale = 1f before loading a new scene from the pause menu?

Summary

๐ŸŽ‰ Key Takeaways

  • A main menu is usually its own scene with buttons that call script methods (PlayGame, QuitGame).
  • A pause menu is a panel inside the gameplay scene, built once, inactive by default, and toggled with SetActive.
  • Time.timeScale = 0f freezes physics and time-based motion; = 1f resumes. It's global and persists across scene loads โ€” always restore it before leaving.
  • Route the pause key through a new Input System action's performed callback; input runs on real time, so it still fires while frozen.
  • Menu animations that must run while paused need unscaled time: Time.unscaledDeltaTime, an Animator set to Unscaled Time, or WaitForSecondsRealtime.

๐Ÿš€ What's Next?

Your menus are done. Now for the UI that changes every frame while you play. In Lesson 4.3: HUD โ€” Health Bars & Live Data you'll build a health bar from a Filled Image, drive it from a health event, and smooth its motion with Lerp so it glides instead of snapping.

โธ๏ธ Start, pause, resume, quit

You've built the four screens every shippable game needs, learned the one global dial that freezes time, and dodged the unscaled-time trap. That's the skeleton of every game's front end.