Tutorial 18: Game States — Menus and Scenes

Architecture  ·  State Machine  ·  Transitions

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 becomes active
    virtual void Enter() {}

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

    // Called every frame while active
    virtual void Update(Microsoft::Xna::Framework::GameTime& gt) = 0;

    // Called every frame — draw below any overlay states
    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 <vector>
#include "GameState.hpp"

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

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

    void Replace(std::unique_ptr<GameState> state) {
        Pop();
        Push(std::move(state));
    }

    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 FadeTransitionState that draws a black overlay whose alpha ramps from 0 → 1 → 0, then activates the target state at the midpoint:

// FadeState.hpp
#pragma once
#include "GameState.hpp"
#include "StateManager.hpp"

class FadeState : public GameState {
public:
    FadeState(std::unique_ptr<GameState> next,
              Single duration = 0.5f)
        : next_(std::move(next)), halfDuration_(duration / 2.0f) {}

    void Enter() override { timer_ = 0.0f; switched_ = false; }

    void Update(Microsoft::Xna::Framework::GameTime& gt) override {
        using namespace Microsoft::Xna::Framework;
        timer_ += (Single)gt.getElapsedGameTime().TotalSeconds();
        if (!switched_ && timer_ >= halfDuration_) {
            // Switch to target state at peak opacity
            switched_ = true;
            manager_->Replace(std::move(next_));  // replaces *this*
        }
    }

    void Draw(Microsoft::Xna::Framework::Graphics::SpriteBatch& sb) override {
        using namespace Microsoft::Xna::Framework;
        Single alpha;
        if (!switched_)
            alpha = timer_ / halfDuration_;          // fade in (0→1)
        else
            alpha = 1.0f - ((timer_ - halfDuration_) / halfDuration_); // fade out

        alpha = std::clamp(alpha, 0.0f, 1.0f);
        Color overlay(0, 0, 0, (bytecs)(alpha * 255.0f));
        // Draw a full-screen black quad using a 1x1 white pixel texture
        sb.Draw(*pixel_, Rectangle(0, 0, 1920, 1080), overlay);
    }

private:
    std::unique_ptr<GameState> next_;
    Single halfDuration_;
    Single timer_    = 0.0f;
    bool   switched_ = false;
    // pixel_ must be injected or accessed via a shared resource system
    Microsoft::Xna::Framework::Graphics::Texture2D* pixel_ = nullptr;
};

Complete example: MainMenuState and GameplayState

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

class GameplayState;   // forward

class MainMenuState : public GameState {
public:
    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)) {
            // Transition to gameplay
            manager_->Push(std::make_unique<GameplayState>());
        }
        prevKb_ = kb;
    }

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

private:
    Microsoft::Xna::Framework::Input::KeyboardState prevKb_;
};

// 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 {
        // Reset score, load level, play game music
        score_ = 0;
    }

    void Exit() override {
        // Stop game music, save high score, etc.
    }

    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:
    intcs score_ = 0;
    Microsoft::Xna::Framework::Input::KeyboardState prevKb_;
};

// MyGame.cpp — wiring it all together
class MyGame final : public Microsoft::Xna::Framework::Game {
public:
    MyGame() : graphics_(this) {}

protected:
    void LoadContent() override {
        spriteBatch_ = std::make_unique<SpriteBatch>(getGraphicsDeviceProperty());
        states_.Push(std::make_unique<MainMenuState>());
    }

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

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color::Black);
        spriteBatch_->Begin();
        states_.Draw(*spriteBatch_);
        spriteBatch_->End();
        gd.Present();
    }

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

Design notes

  • Push vs Replace — use Push for overlays (pause menu over gameplay). Use Replace for linear navigation (menu → gameplay).
  • 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.