Skip to main content

📝 Lesson 4.1: Game State and Managers

Somewhere, something has to know the score, whether the game is paused, and which level loads next. That's a manager — a central coordinator. This lesson covers the manager and singleton patterns, a clean game state machine, keeping managers alive across scenes, and loading scenes from code.

🎯 Learning Objectives

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

  • Explain the role of a manager and implement the Unity singleton pattern
  • Persist a manager across scene loads with DontDestroyOnLoad
  • Model game flow as a state machine (MainMenu → Playing → Paused → GameOver)
  • Pause the game with Time.timeScale and react to state changes
  • Load and switch scenes with SceneManager (single & additive)
  • Weigh singletons against events (Lesson 3.2) and avoid their pitfalls

Estimated Time: 60 minutes

Project: A GameManager singleton that tracks state and score, persists across scenes, and drives pause and restart.

In This Lesson

What Is a Manager?

As a game grows, some responsibilities don't belong to any single GameObject. "The current score" isn't the player's job or the UI's job — it's game-wide state. A manager is a component that owns one such cross-cutting concern and offers a central place to read and change it.

📖 Definition

A manager is a coordinating MonoBehaviour responsible for a global concern — GameManager (overall state, score), AudioManager (music/SFX), SceneLoader (transitions), SaveManager (persistence). Typically there's exactly one of each in the game, which is why managers so often become singletons.

The challenge: if there's one GameManager, how does the player, the UI, an enemy — anyone — reach it? You could wire a [SerializeField] reference into every script (Lesson 1.3), but that's tedious for something everything needs. The singleton pattern gives one globally-reachable access point.

The Singleton Pattern

A singleton guarantees a class has exactly one instance and exposes it through a static Instance property, so any script can call GameManager.Instance.AddScore(10) from anywhere.

using UnityEngine;

public class GameManager : MonoBehaviour
{
    // The single, globally-accessible instance.
    public static GameManager Instance { get; private set; }

    public int Score { get; private set; }

    void Awake()
    {
        // Enforce a single instance: if one already exists, destroy this duplicate.
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;
    }

    public void AddScore(int amount)
    {
        Score += amount;
        Debug.Log($"Score: {Score}");
    }
}
graph TD GM["GameManager.Instance
(the one manager)"] P["Player"] -->|"AddScore(10)"| GM E["Enemy"] -->|"AddScore(50)"| GM U["ScoreUI"] -->|"reads Score"| GM style GM fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style P fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style E fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

💡 Why register in Awake?

From Lesson 1.2: all Awake calls run before any Start. Registering Instance = this in Awake guarantees the singleton is ready before any other script's Start tries to use it. That's the two-phase startup rule paying off — set yourself up in Awake, use others in Start.

⚠️ The duplicate guard matters

The if (Instance != null) check destroys accidental duplicates — crucial once you add DontDestroyOnLoad (next), because reloading a scene that contains the manager would otherwise create a second one. Expose Instance with a private set so outside code can read it but never reassign it.

Surviving Scene Loads

By default, loading a new scene destroys every GameObject in the old one — including your manager. But score, settings, and audio should carry from the menu into the level and between levels. DontDestroyOnLoad marks an object to survive scene changes.

void Awake()
{
    if (Instance != null && Instance != this)
    {
        Destroy(gameObject);
        return;
    }
    Instance = this;
    DontDestroyOnLoad(gameObject);   // this manager persists across all scenes
}
graph LR MENU["Main Menu scene"] -->|"Load Level 1"| L1["Level 1 scene"] L1 -->|"Load Level 2"| L2["Level 2 scene"] GM["GameManager (DontDestroyOnLoad)"] -.->|"survives"| MENU GM -.->|"survives"| L1 GM -.->|"survives"| L2 style GM fill:#eff6ff,stroke:#3b82f6,stroke-width:2px

✅ The persistent-singleton recipe

Put the manager in your first scene, register Instance in Awake, guard against duplicates, and call DontDestroyOnLoad(gameObject). Now it's a single object that lives for the whole session and is reachable everywhere via Instance. The duplicate guard is what stops a second copy appearing if you return to the first scene.

⚠️ Important: DontDestroyOnLoad only works on root GameObjects. If your manager is a child of another object, it won't persist — Unity even warns you. Keep managers at the scene root.

A Game State Machine

Games move through distinct states — a main menu, active play, a pause screen, game over — and each allows different things. Scattering bool isPaused, bool isGameOver flags everywhere quickly becomes a tangle of impossible combinations. A state machine models this cleanly with a single enum.

public enum GameState { MainMenu, Playing, Paused, GameOver }
stateDiagram-v2 [*] --> MainMenu MainMenu --> Playing: Start Playing --> Paused: Pause Paused --> Playing: Resume Playing --> GameOver: Player dies GameOver --> MainMenu: Restart Paused --> MainMenu: Quit

The manager holds the current state, exposes a method to change it, and raises an event (Lesson 3.2) so UI and systems react — without the manager knowing about them:

using System;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    public static GameManager Instance { get; private set; }

    public GameState State { get; private set; } = GameState.MainMenu;
    public event Action<GameState> StateChanged;   // announce transitions

    void Awake()
    {
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        Instance = this;
        DontDestroyOnLoad(gameObject);
    }

    public void SetState(GameState newState)
    {
        if (State == newState) return;
        State = newState;

        // Pause freezes time-based logic; WaitForSeconds & physics stop (Lesson 3.3 / 1.2).
        Time.timeScale = (newState == GameState.Paused) ? 0f : 1f;

        StateChanged?.Invoke(newState);   // let listeners react
    }
}

💡 Time.timeScale is the pause switch

Setting Time.timeScale = 0 freezes Time.deltaTime, physics, and WaitForSeconds — most gameplay halts without any per-object code. Set it back to 1 to resume. Remember (Lesson 3.3): menu animations that must run while paused should use unscaled time or WaitForSecondsRealtime.

Scene Management

Loading levels, returning to the menu, restarting after game over — all handled by the SceneManager class in the UnityEngine.SceneManagement namespace.

using UnityEngine;
using UnityEngine.SceneManagement;   // required for SceneManager

public class SceneFlow : MonoBehaviour
{
    public void LoadLevel(string sceneName) => SceneManager.LoadScene(sceneName);

    public void Restart()
    {
        // Reload the scene that's currently active.
        SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
    }

    public void QuitToMenu() => SceneManager.LoadScene("MainMenu");
}
CallEffect
LoadScene("Level1")Replaces the current scene(s) with the named one.
LoadScene(index)Same, by build index (from Build Settings).
LoadScene(name, LoadSceneMode.Additive)Loads a scene alongside the current one (streaming, overlays).
LoadSceneAsync(...)Loads in the background so you can show a progress bar (pairs with coroutines/async, Lesson 3.3).

⚠️ Scenes must be in Build Settings

LoadScene can only load scenes added to File → Build Settings → Scenes In Build. A scene that exists in your Project but isn't in that list fails at runtime. Also: resetting Time.timeScale = 1 when leaving a paused state is essential — load a scene while timeScale is still 0 and the new scene appears frozen.

💡 Additive scenes and managers: a common architecture keeps managers in a small persistent "bootstrap" scene loaded additively, with levels swapped in and out around it. Combined with DontDestroyOnLoad, this cleanly separates "systems that live forever" from "content that comes and goes."

Singleton Pitfalls

Singletons are convenient — maybe too convenient. Because Instance is reachable from anywhere, it's tempting to route everything through it, recreating the tight coupling Lesson 3.2 warned against. Used well, singletons and events complement each other.

PitfallWhy it hurtsMitigation
Global access everywhereEverything depends on the singleton — hard to reuse or reason aboutLet the manager raise events; listeners react (don't have listeners poll the singleton)
Hidden dependenciesGameManager.Instance buried in a method isn't visible in the InspectorPrefer a [SerializeField] reference when a specific object is known
Hard to testStatic state persists between testsKeep managers thin; put logic in plain, testable classes (Lesson 5.2)
Too many managersA "GodManager" that does everythingOne manager per concern; split responsibilities

✅ A healthy balance

Use a singleton for genuinely global, single-instance services (the game manager, audio, scene flow). Have it broadcast state changes via events so most systems react without reaching back in. Reserve direct Instance calls for deliberate commands ("add score", "load level"). That mix keeps a central authority and loose coupling.

💡 Rule of thumb: if you find everything calling Instance, you've drifted back to tight coupling. Push notifications out with events; pull commands in sparingly.

Exercise & Quiz

🏋️ Exercise: A Persistent GameManager

Objective: Build a singleton GameManager that tracks state and score, persists across scenes, and drives pause and restart.

Instructions:

  1. Create the GameState enum and a GameManager singleton (Sections 2–4) with the Awake guard and DontDestroyOnLoad.
  2. Add a Score property and AddScore(int); raise an event when it changes.
  3. Add SetState(GameState) that sets Time.timeScale for pause and raises StateChanged.
  4. On Escape, toggle between Playing and Paused. On a "Restart" call, reset score and reload the active scene.
  5. Put the manager in your first scene, add a second scene to Build Settings, and confirm the score survives a scene load.

Starter Code:

using System;
using UnityEngine;
using UnityEngine.SceneManagement;

public enum GameState { MainMenu, Playing, Paused, GameOver }

public class GameManager : MonoBehaviour
{
    public static GameManager Instance { get; private set; }
    public GameState State { get; private set; } = GameState.MainMenu;
    public int Score { get; private set; }

    public event Action<GameState> StateChanged;
    public event Action<int> ScoreChanged;

    void Awake()
    {
        // TODO: singleton guard + DontDestroyOnLoad.
    }

    // TODO: AddScore, SetState (with timeScale), Restart.
}
💡 Hint

Guard: if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject);. In SetState, set Time.timeScale = newState == GameState.Paused ? 0f : 1f; then StateChanged?.Invoke(newState). In Restart, reset Score, set state to Playing (which restores timeScale), then SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex).

✅ Solution
using System;
using UnityEngine;
using UnityEngine.SceneManagement;

public enum GameState { MainMenu, Playing, Paused, GameOver }

public class GameManager : MonoBehaviour
{
    public static GameManager Instance { get; private set; }
    public GameState State { get; private set; } = GameState.MainMenu;
    public int Score { get; private set; }

    public event Action<GameState> StateChanged;
    public event Action<int> ScoreChanged;

    void Awake()
    {
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        Instance = this;
        DontDestroyOnLoad(gameObject);
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Escape))
        {
            if (State == GameState.Playing) SetState(GameState.Paused);
            else if (State == GameState.Paused) SetState(GameState.Playing);
        }
    }

    public void AddScore(int amount)
    {
        Score += amount;
        ScoreChanged?.Invoke(Score);
    }

    public void SetState(GameState newState)
    {
        if (State == newState) return;
        State = newState;
        Time.timeScale = (newState == GameState.Paused) ? 0f : 1f;
        StateChanged?.Invoke(newState);
    }

    public void Restart()
    {
        Score = 0;
        SetState(GameState.Playing);   // also restores timeScale to 1
        SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
    }
}

One central authority, reachable via Instance, that announces state and score changes through events. UI and systems subscribe (Lesson 3.2) — they never poll the manager. It persists across scenes, and Restart cleanly resets both score and time.

🎯 Quick Quiz

Question 1: Why does the singleton register Instance = this in Awake rather than Start?

Question 2: What does DontDestroyOnLoad(gameObject) do?

Question 3: What's the recommended way to keep managers from recreating the tight coupling of Lesson 3.2?

Summary

🎉 Key Takeaways

  • A manager owns a global concern (state, score, audio, scenes); typically one per game, so managers are often singletons.
  • The singleton pattern: a static Instance (with private set), registered in Awake, with a duplicate guard that destroys extra copies.
  • DontDestroyOnLoad(gameObject) (on a root object) keeps a manager alive across scene loads — the persistent-singleton recipe.
  • Model game flow as a state machine with an enum; Time.timeScale = 0 pauses time-based logic. Raise an event on transitions.
  • SceneManager.LoadScene (single/additive/async) switches scenes — but scenes must be in Build Settings, and reset timeScale to 1 before loading.
  • Avoid singleton overuse: broadcast via events, command via Instance sparingly, one manager per concern.

📚 Additional Resources

🚀 What's Next?

Your game now has a brain that tracks state and score. Next, players need to see it. Lesson 4.2, UI Scripting, connects your managers and events to on-screen UI — score labels, health bars, pause menus — with uGUI and UI Toolkit.

🎉 Your game has a control center!

State, score, scenes, pause — all coordinated from one clear place. That's the backbone every shippable game needs.