Tutorial 18: Game States — Menus and Scenes

Architecture  ·  State Machine  ·  Transitions

ℹ

What you’ll learn

  • Modelling menus and gameplay as separate states rather than flags on one class.
  • A GameState base class and a stack-based manager.
  • Fading between states.

Before you start — Tutorial 04: The Game Class Lifecycle (states hook the same Update/Draw pair) and Tutorial 09: Drawing Text with SpriteFont (menus need text).

Every non-trivial game has multiple screens: a title menu, settings, gameplay, a pause menu, a game-over screen. A state machine keeps these isolated and composable. This tutorial builds a stack-based state manager with fade-to-black transitions, demonstrating the pattern that scales from small jam games to full productions.

Why game states?

Without explicit state management you tend to end up with a jungle of booleans (isInMenu, isPaused, isGameOver) and branching if/else chains inside Update() and Draw(). This becomes hard to reason about and error-prone. A state machine separates concerns: each state owns its own update logic, draw logic, and asset lifetime.

State enum pattern

For small games a single enum plus a switch statement is often enough:

enum class AppState { MainMenu, Gameplay, Paused, GameOver };

AppState currentState_ = AppState::MainMenu;

void Update(GameTime& gt) override {
    switch (currentState_) {
        case AppState::MainMenu:  UpdateMenu(gt);     break;
        case AppState::Gameplay:  UpdateGameplay(gt); break;
        case AppState::Paused:    UpdatePaused(gt);   break;
        case AppState::GameOver:  UpdateGameOver(gt); break;
    }
}

This is sufficient for 2–4 states. For more complex games use the object-oriented pattern below.

GameState base class

// GameState.hpp
#pragma once
#include "Microsoft/Xna/Framework/GameTime.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"

class StateManager;   // forward declaration

class GameState {
public:
    virtual ~GameState() = default;

    // Called once, when the state is pushed onto the stack
    virtual void Enter() {}

    // Called once, when the state is popped off the stack
    virtual void Exit() {}

    // Called when another state is pushed on top of this one (e.g. a pause menu)
    virtual void Pause() {}

    // Called when the state above this one is popped and this one is on top again
    virtual void Resume() {}

    // Called every frame while this state is on top of the stack
    virtual void Update(Microsoft::Xna::Framework::GameTime& gt) = 0;

    // Called every frame — states are drawn bottom-up, so overlays draw over the states below
    virtual void Draw(Microsoft::Xna::Framework::Graphics::SpriteBatch& sb) = 0;

    // Reference back to manager so states can push/pop siblings
    void setManager(StateManager* m) { manager_ = m; }

protected:
    StateManager* manager_ = nullptr;
};

Stack-based StateManager

// StateManager.hpp
#pragma once
#include <memory>
#include <utility>
#include <vector>
#include "GameState.hpp"

class StateManager {
public:
    void Push(std::unique_ptr<GameState> state) {
        if (!stack_.empty()) stack_.back()->Pause();
        state->setManager(this);
        stack_.push_back(std::move(state));
        stack_.back()->Enter();
    }

    void Pop() {
        if (stack_.empty()) return;
        stack_.back()->Exit();
        stack_.pop_back();
        if (!stack_.empty()) stack_.back()->Resume();
    }

    // Replace the top state (linear navigation: menu -> gameplay)
    void Replace(std::unique_ptr<GameState> state) {
        if (!stack_.empty()) {
            stack_.back()->Exit();
            stack_.pop_back();
        }
        state->setManager(this);
        stack_.push_back(std::move(state));
        stack_.back()->Enter();
    }

    // Replace the state directly UNDER the top one (used by FadeState below)
    void ReplaceBelowTop(std::unique_ptr<GameState> state) {
        if (stack_.size() < 2) return;
        std::unique_ptr<GameState>& below = stack_[stack_.size() - 2];
        below->Exit();
        state->setManager(this);
        below = std::move(state);
        below->Enter();
    }

    void Update(Microsoft::Xna::Framework::GameTime& gt) {
        if (!stack_.empty()) stack_.back()->Update(gt);
    }

    void Draw(Microsoft::Xna::Framework::Graphics::SpriteBatch& sb) {
        // Draw all states from bottom up (allows transparent overlays)
        for (auto& s : stack_) s->Draw(sb);
    }

    bool Empty() const { return stack_.empty(); }

private:
    std::vector<std::unique_ptr<GameState>> stack_;
};

Transition effects — fade to black

Wrap state transitions in a FadeState. You push it on top of the state you are leaving; it draws a black overlay whose alpha ramps from 0 → 1 → 0, swaps the state underneath at the midpoint (when the screen is fully black), and pops itself when the fade-in is done. Because the overlay is just another state, the states below keep drawing under it. It needs a 1×1 white texture and the screen rectangle, which the states share through a small context struct:

// GameContext.hpp — resources shared by every state
#pragma once
#include "Microsoft/Xna/Framework/Rectangle.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"

struct GameContext {
    Microsoft::Xna::Framework::Graphics::Texture2D pixel;    // 1x1 white texture
    Microsoft::Xna::Framework::Rectangle           screen;   // the whole back buffer
};
// FadeState.hpp
#pragma once
#include <algorithm>
#include "GameContext.hpp"
#include "GameState.hpp"
#include "StateManager.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"

// Pushed ON TOP of the state you are leaving. It fades the screen to black, swaps
// the state underneath at the midpoint, fades back in, then pops itself.
class FadeState : public GameState {
public:
    FadeState(std::unique_ptr<GameState> next, const GameContext& ctx,
              float duration = 0.5f)
        : next_(std::move(next)), ctx_(&ctx), halfDuration_(duration / 2.0f) {}

    void Update(Microsoft::Xna::Framework::GameTime& gt) override {
        timer_ += static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());

        if (!switched_ && timer_ >= halfDuration_) {
            // Fully black: swap the state under the overlay
            switched_ = true;
            manager_->ReplaceBelowTop(std::move(next_));
        } else if (switched_ && timer_ >= 2.0f * halfDuration_) {
            // Fade finished. This destroys *this*, so touch nothing afterwards.
            manager_->Pop();
        }
    }

    void Draw(Microsoft::Xna::Framework::Graphics::SpriteBatch& sb) override {
        using namespace Microsoft::Xna::Framework;
        float alpha = !switched_
            ? timer_ / halfDuration_                           // fading out (0 -> 1)
            : 1.0f - (timer_ - halfDuration_) / halfDuration_; // fading in  (1 -> 0)
        alpha = std::clamp(alpha, 0.0f, 1.0f);

        // Black scaled by alpha (premultiplied): a full-screen quad made
        // from the shared 1x1 white texture
        sb.Draw(ctx_->pixel, ctx_->screen, Color::Black * alpha);
    }

private:
    std::unique_ptr<GameState> next_;
    const GameContext*         ctx_;
    float halfDuration_;
    float timer_    = 0.0f;
    bool  switched_ = false;
};

Complete example: MainMenuState and GameplayState

// GameplayState.hpp
#pragma once
#include "GameState.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"

class GameplayState : public GameState {
public:
    void Enter() override {
        // Runs once, when pushed: reset score, load level, play game music
        score_ = 0;
    }

    void Exit() override {
        // Runs once, when popped: stop game music, save high score, etc.
    }

    void Pause() override  { /* a pause menu was pushed on top: mute music... */ }
    void Resume() override { /* ...and un-mute it when the pause menu is popped */ }

    void Update(Microsoft::Xna::Framework::GameTime& gt) override {
        using namespace Microsoft::Xna::Framework::Input;
        auto kb = Keyboard::GetState();

        // Escape → push pause menu on top (not replace)
        if (kb.IsKeyDown(Keys::Escape) && !prevKb_.IsKeyDown(Keys::Escape)) {
            // manager_->Push(std::make_unique<PauseState>());
        }

        // ... update player, enemies, score ...
        prevKb_ = kb;
    }

    void Draw(Microsoft::Xna::Framework::Graphics::SpriteBatch& sb) override {
        // Draw world, HUD, score
    }

private:
    int score_ = 0;
    Microsoft::Xna::Framework::Input::KeyboardState prevKb_;
};

// MainMenuState.hpp
#pragma once
#include "FadeState.hpp"
#include "GameContext.hpp"
#include "GameplayState.hpp"
#include "GameState.hpp"
#include "StateManager.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"

class MainMenuState : public GameState {
public:
    explicit MainMenuState(const GameContext& ctx) : ctx_(&ctx) {}

    void Enter() override {
        // Start menu music, etc.
    }

    void Update(Microsoft::Xna::Framework::GameTime& gt) override {
        using namespace Microsoft::Xna::Framework::Input;
        auto kb = Keyboard::GetState();
        if (kb.IsKeyDown(Keys::Enter) && !prevKb_.IsKeyDown(Keys::Enter)) {
            // Fade out, swap the menu for gameplay, fade back in.
            // (Plain alternative: manager_->Replace(std::make_unique<GameplayState>());)
            manager_->Push(std::make_unique<FadeState>(
                std::make_unique<GameplayState>(), *ctx_));
            return;   // we were just paused by the Push: do no more work this frame
        }
        prevKb_ = kb;
    }

    void Draw(Microsoft::Xna::Framework::Graphics::SpriteBatch& sb) override {
        // Draw menu background, title, "Press Enter" text
    }

private:
    const GameContext* ctx_;
    Microsoft::Xna::Framework::Input::KeyboardState prevKb_;
};

// MyGame.cpp — wiring it all together
#include <memory>
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
#include "GameContext.hpp"
#include "MainMenuState.hpp"
#include "StateManager.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;

class MyGame final : public Game {
public:
    MyGame() : graphics_(this) {
        graphics_.setPreferredBackBufferWidthProperty(800);
        graphics_.setPreferredBackBufferHeightProperty(600);
    }

protected:
    void LoadContent() override {
        auto& gd = getGraphicsDeviceProperty();
        spriteBatch_ = std::make_unique<SpriteBatch>(gd);

        // Shared resources: a 1x1 white texture and the screen rectangle
        ctx_.pixel  = Texture2D(gd, 1, 1);
        Color white = Color::White;
        ctx_.pixel.SetData(&white, 1);
        ctx_.screen = Rectangle(0, 0, gd.getViewportProperty().getWidthProperty(),
                                      gd.getViewportProperty().getHeightProperty());

        states_.Push(std::make_unique<MainMenuState>(ctx_));
    }

    void Update(GameTime& gt) override {
        states_.Update(gt);
        if (states_.Empty()) Exit();  // all states popped = quit
    }

    void Draw(const GameTime&) override {
        getGraphicsDeviceProperty().Clear(Color::Black);
        spriteBatch_->Begin();
        states_.Draw(*spriteBatch_);
        spriteBatch_->End();
        // Game::EndDraw() presents the frame after Draw() returns.
    }

private:
    GraphicsDeviceManager        graphics_;
    std::unique_ptr<SpriteBatch> spriteBatch_;
    GameContext                  ctx_;
    StateManager                 states_;
};

int main() { MyGame game; game.Run(); }

Design notes

  • Push vs Replace — use Push for overlays (pause menu over gameplay). Use Replace for linear navigation (menu → gameplay).
  • Enter/Exit vs Pause/Resume — Enter() and Exit() run exactly once, when a state is pushed and popped. Covering a state with an overlay only calls Pause(), and uncovering it calls Resume(), so pausing a game does not reset its score or restart its level.
  • Do not touch this after Push/Pop/Replace — Pop and Replace destroy the calling state. Make the call the last thing the state does in Update() (note the return after Push in MainMenuState, and the Pop at the very end of FadeState).
  • Game loop hooks — the state manager lives inside your Game subclass; MyGame::Update() and MyGame::Draw() forward to it, and Game presents the frame after Draw() returns, so no state ever calls Present().
  • Asset ownership — states should load their assets in Enter() and release them in Exit(), or use a shared resource cache to avoid redundant loads.
  • State communication — states can communicate via a shared context object rather than global variables. Pass it to state constructors.
  • Deep stacks — if you need the gameplay state to still update while the pause menu is displayed, have StateManager::Update() walk the stack bottom-up and call UpdateBelow() on states that opt in.