Tutorial 18: Game States — Menus and Scenes
What you’ll learn
- Modelling menus and gameplay as separate states rather than flags on one class.
- A
GameStatebase 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
Pushfor overlays (pause menu over gameplay). UseReplacefor linear navigation (menu → gameplay). - Enter/Exit vs Pause/Resume —
Enter()andExit()run exactly once, when a state is pushed and popped. Covering a state with an overlay only callsPause(), and uncovering it callsResume(), so pausing a game does not reset its score or restart its level. - Do not touch
thisafterPush/Pop/Replace—PopandReplacedestroy the calling state. Make the call the last thing the state does inUpdate()(note thereturnafterPushinMainMenuState, and thePopat the very end ofFadeState). - Game loop hooks — the state manager lives inside your
Gamesubclass;MyGame::Update()andMyGame::Draw()forward to it, andGamepresents the frame afterDraw()returns, so no state ever callsPresent(). - Asset ownership — states should load their assets in
Enter()and release them inExit(), 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 callUpdateBelow()on states that opt in.