This tutorial adds no new gameplay. Not a single new feature. And it might be the most valuable part of the series — because it's the one where we stop and tidy the house before the furniture arrives.
Here's the situation. Across Parts 1–5, all of the game's state and logic accreted inside main() as a growing pile of captured lambdas — newGame, draw, monstersAct, attack, plus loose variables for the map, the player, the monsters, the RNG and a dead flag. It worked. But Act 2 is about to add pathfinding, items, equipment, status effects and multiple floors, and if we keep bolting each onto main(), it becomes an unmaintainable thousand-line monster. So before we build up, we build a foundation: a Game class that owns the state and the loop, with the lambdas promoted to real methods.
The golden rule of this part: a refactor changes a program's shape, not its behaviour. When you finish, the game must play exactly as it did at the end of Part 5 — same dungeon, same monsters, same combat, same death screen. If anything plays differently, that's a bug, not a refactor. This is a skill worth practising deliberately, because it's how real codebases stay alive as they grow.
What's New in Part 6
| File | Status | What it does |
|---|---|---|
Game.h / .cpp | New | Owns all state, the turn logic, rendering, and the event loop |
main.cpp | Gutted | Just brings SDL up, runs a Game, tears SDL down — ~40 lines |
Entity / Player / Monster | Unchanged | From Parts 4–5 |
Map / GlyphCache / FOV | Unchanged | From Parts 1–3 |
State Becomes Members; Lambdas Become Methods
The translation is almost mechanical, and that's the point — there's nothing clever here, just a tidier home for what we already had. Every loose variable in main becomes a member; every lambda becomes a method:
Was, in main() | Now, in Game |
|---|---|
local map, player, monsters, rng | m_map, m_player, m_monsters, m_rng members |
bool dead | GameState m_state |
newGame, draw, monstersAct, attack lambdas | newGame(), render(), monstersAct(), attack() methods |
the while loop in main | Game::run() |
A real state, not a bare bool
The one genuine improvement we allow ourselves is replacing the dead boolean with an enum:
enum class GameState { Playing, Dead };
Today it has two values and does exactly what the bool did. But a bool can only ever say yes/no — and this game will soon want a title screen, a victory state, an inventory overlay, a pause. An enum class grows to hold all of those; a bool would have to multiply into a tangle of flags. We're not adding those states now — we're choosing the shape that can.
The Header — Game.h
The header is a table of contents for the whole game: the public way in (run), the private turn logic, the private rendering helpers, and the owned state. Read it top to bottom and you know what the game is:
#pragma once
#include <SDL3/SDL.h>
#include <vector>
#include <random>
#include "Map.h"
#include "GlyphCache.h"
#include "Player.h"
#include "Monster.h"
enum class GameState { Playing, Dead };
class Game
{
public:
Game(SDL_Window* window, SDL_Renderer* sdl, GlyphCache& glyphs);
void run();
private:
void newGame();
void playerAct(int dx, int dy);
void monstersAct();
void attack(Entity& attacker, Entity& defender);
Monster* monsterAt(int x, int y);
void render();
void drawTile(int col, int row, const Tile& t);
void drawCentred(int row, const char* text, SDL_Color color);
void drawDeathScreen();
void updateTitle();
SDL_Window* m_window;
SDL_Renderer* m_sdl;
GlyphCache& m_glyphs;
Map m_map;
Player m_player;
std::vector<Monster> m_monsters;
std::mt19937 m_rng{ std::random_device{}() };
GameState m_state = GameState::Playing;
};
Notice the dependency direction: Game depends on Map, Player, Monster, GlyphCache — never the other way around. Those types still know nothing about the game that uses them, which is exactly why they were so easy to reuse. Game sits on top and wires them together. The renderer and window are borrowed (a reference and pointers) — main still owns and destroys them, because their lifetime brackets SDL itself.
The Thin main.cpp
Here's the headline result. After five parts of accretion, main() goes back to doing one job — start SDL, run a Game, stop SDL:
int main(int argc, char* argv[])
{
if (!SDL_Init(SDL_INIT_VIDEO)) { SDL_Log("SDL_Init: %s", SDL_GetError()); return 1; }
if (!TTF_Init()) { SDL_Log("TTF_Init: %s", SDL_GetError()); SDL_Quit(); return 1; }
SDL_Window* window = nullptr;
SDL_Renderer* sdl = nullptr;
if (!SDL_CreateWindowAndRenderer(
"Roguelike - Part 6: The Game Class",
MAP_WIDTH * TILE_SIZE, MAP_HEIGHT * TILE_SIZE, 0,
&window, &sdl))
{
SDL_Log("CreateWindowAndRenderer: %s", SDL_GetError());
TTF_Quit(); SDL_Quit();
return 1;
}
GlyphCache glyphs(sdl, FONT_PATH, FONT_PT);
if (!glyphs.ok())
{
SDL_DestroyRenderer(sdl); SDL_DestroyWindow(window);
TTF_Quit(); SDL_Quit();
return 1;
}
{
Game game(window, sdl, glyphs);
game.run();
} // game is destroyed here, before we tear down SDL
SDL_DestroyRenderer(sdl);
SDL_DestroyWindow(window);
TTF_Quit();
SDL_Quit();
return 0;
}
The extra { ... } braces around the Game are deliberate: they force game to be destroyed before SDL_DestroyRenderer runs. If the Game ever holds SDL resources of its own (it will, once it owns textures), they'll be released while SDL is still alive. Getting teardown order right is the kind of thing a clean owner makes obvious.
The Loop Moves Into the Class
Game::run is the Part 5 event loop, moved wholesale. The only change is that dead checks become m_state checks, and the movement branch calls one tidy method, playerAct, instead of an inline block:
if (dx != 0 || dy != 0)
{
playerAct(dx, dy);
dirty = true;
}
And playerAct is just the Part 5 logic with members instead of captures — bump-to-attack, else move, then the monsters act and we check for death:
void Game::playerAct(int dx, int dy)
{
bool tookTurn = false;
Monster* target = monsterAt(m_player.x + dx, m_player.y + dy);
if (target) { attack(m_player, *target); tookTurn = true; }
else if (m_player.tryMove(dx, dy, m_map))
{
FOV::compute(m_map, m_player.x, m_player.y);
tookTurn = true;
}
if (tookTurn)
{
monstersAct();
updateTitle();
if (m_player.hp <= 0) m_state = GameState::Dead;
}
}
Same behaviour, a name you can call, and a method short enough to read at a glance. That's the whole refactor in miniature.
Try It
Build and run — and then the most important step of any refactor: confirm nothing changed. The dungeon should generate, the gold @ should explore, monsters should chase and fight, the title bar should track HP, and three goblins should still be able to kill you and raise the death screen — all exactly as in Part 5. If it all feels identical, you did it right. The win is invisible to the player and enormous for you: the next ten tutorials now have a clean class to grow inside instead of a swelling main().
Complete Code
Only the new and changed files are listed. Entity.h, Player.h, Monster.h, Map.*, GlyphCache.* and FOV.* are unchanged from earlier parts.
Game.h
#pragma once
#include <SDL3/SDL.h>
#include <vector>
#include <random>
#include "Map.h"
#include "GlyphCache.h"
#include "Player.h"
#include "Monster.h"
enum class GameState { Playing, Dead };
class Game
{
public:
Game(SDL_Window* window, SDL_Renderer* sdl, GlyphCache& glyphs);
void run();
private:
void newGame();
void playerAct(int dx, int dy);
void monstersAct();
void attack(Entity& attacker, Entity& defender);
Monster* monsterAt(int x, int y);
void render();
void drawTile(int col, int row, const Tile& t);
void drawCentred(int row, const char* text, SDL_Color color);
void drawDeathScreen();
void updateTitle();
SDL_Window* m_window;
SDL_Renderer* m_sdl;
GlyphCache& m_glyphs;
Map m_map;
Player m_player;
std::vector<Monster> m_monsters;
std::mt19937 m_rng{ std::random_device{}() };
GameState m_state = GameState::Playing;
};
Game.cpp
#include "Game.h"
#include "FOV.h"
#include <cstdlib>
namespace {
const SDL_Color BG = { 12, 12, 16, 255 };
const SDL_Color WALL_LIT = { 150, 140, 175, 255 };
const SDL_Color FLOOR_LIT = { 100, 92, 80, 255 };
const SDL_Color WALL_MEM = { 58, 54, 72, 255 };
const SDL_Color FLOOR_MEM = { 40, 37, 33, 255 };
const SDL_Color DEAD_FRAME = { 150, 60, 60, 255 };
const SDL_Color DEAD_TITLE = { 220, 90, 90, 255 };
const SDL_Color DEAD_TEXT = { 210, 200, 205, 255 };
const SDL_Color DEAD_HINT = { 120, 112, 112, 255 };
struct MonsterKind { char glyph; SDL_Color color; int hp; int power; const char* name; };
const MonsterKind KINDS[] = {
{ 'r', { 150, 150, 160, 255 }, 3, 1, "the rat" },
{ 'k', { 110, 200, 120, 255 }, 5, 2, "the kobold" },
{ 'g', { 120, 170, 230, 255 }, 8, 3, "the goblin" },
};
const int NUM_KINDS = (int)(sizeof(KINDS) / sizeof(KINDS[0]));
const int MONSTER_COUNT = 8;
int sgn(int v) { return (v > 0) - (v < 0); }
}
Game::Game(SDL_Window* window, SDL_Renderer* sdl, GlyphCache& glyphs)
: m_window(window), m_sdl(sdl), m_glyphs(glyphs)
{
newGame();
}
void Game::newGame()
{
m_map.generate();
m_player.x = m_map.rooms[0].centreX();
m_player.y = m_map.rooms[0].centreY();
m_player.hp = m_player.maxHp;
m_player.alive = true;
m_state = GameState::Playing;
FOV::compute(m_map, m_player.x, m_player.y);
m_monsters.clear();
int n = (int)m_map.rooms.size();
for (int i = 1; i < n && (int)m_monsters.size() < MONSTER_COUNT; ++i)
{
const MonsterKind& k = KINDS[std::uniform_int_distribution<int>(0, NUM_KINDS - 1)(m_rng)];
Monster m;
m.x = m_map.rooms[i].centreX();
m.y = m_map.rooms[i].centreY();
m.glyph = k.glyph; m.color = k.color;
m.hp = m.maxHp = k.hp;
m.power = k.power; m.name = k.name;
m_monsters.push_back(m);
}
updateTitle();
}
Monster* Game::monsterAt(int x, int y)
{
for (Monster& m : m_monsters)
if (m.isAlive() && m.x == x && m.y == y) return &m;
return nullptr;
}
void Game::attack(Entity& atk, Entity& def)
{
def.hp -= atk.power;
SDL_Log("%s hits %s for %d. (%s: %d hp)", atk.name, def.name, atk.power,
def.name, def.hp > 0 ? def.hp : 0);
if (def.hp <= 0)
{
def.hp = 0;
def.alive = false;
SDL_Log("%s dies.", def.name);
}
}
void Game::playerAct(int dx, int dy)
{
bool tookTurn = false;
Monster* target = monsterAt(m_player.x + dx, m_player.y + dy);
if (target)
{
attack(m_player, *target);
tookTurn = true;
}
else if (m_player.tryMove(dx, dy, m_map))
{
FOV::compute(m_map, m_player.x, m_player.y);
tookTurn = true;
}
if (tookTurn)
{
monstersAct();
updateTitle();
if (m_player.hp <= 0) m_state = GameState::Dead;
}
}
void Game::monstersAct()
{
for (Monster& m : m_monsters)
{
if (!m.isAlive()) continue;
if (!m_map.tiles[m.y][m.x].visible)
{
m.wander(m_map, m_rng);
continue;
}
int ddx = m_player.x - m.x;
int ddy = m_player.y - m.y;
if (std::abs(ddx) + std::abs(ddy) == 1)
{
attack(m, m_player);
continue;
}
int sx = (std::abs(ddx) >= std::abs(ddy)) ? sgn(ddx) : 0;
int sy = (sx == 0) ? sgn(ddy) : 0;
auto stepIfFree = [&](int dxx, int dyy) -> bool
{
if (dxx == 0 && dyy == 0) return false;
int nx = m.x + dxx, ny = m.y + dyy;
if (nx < 0 || nx >= MAP_WIDTH || ny < 0 || ny >= MAP_HEIGHT) return false;
if (m_map.tiles[ny][nx].type != TileType::Floor) return false;
if (nx == m_player.x && ny == m_player.y) return false;
if (monsterAt(nx, ny)) return false;
m.x = nx; m.y = ny; return true;
};
if (!stepIfFree(sx, sy))
stepIfFree(sgn(ddx) - sx, sgn(ddy) - sy);
}
}
void Game::updateTitle()
{
char buf[96];
SDL_snprintf(buf, sizeof(buf), "Roguelike - Part 6 | HP: %d / %d",
m_player.hp, m_player.maxHp);
SDL_SetWindowTitle(m_window, buf);
}
void Game::drawTile(int col, int row, const Tile& t)
{
if (!t.explored) return;
if (t.visible)
{
if (t.type == TileType::Floor) m_glyphs.drawGlyph(col, row, '.', FLOOR_LIT);
else m_glyphs.drawGlyph(col, row, '#', WALL_LIT);
}
else
{
if (t.type == TileType::Floor) m_glyphs.drawGlyph(col, row, '.', FLOOR_MEM);
else m_glyphs.drawGlyph(col, row, '#', WALL_MEM);
}
}
void Game::drawCentred(int row, const char* text, SDL_Color color)
{
int len = (int)SDL_strlen(text);
int startCol = (MAP_WIDTH - len) / 2;
for (int i = 0; i < len; ++i)
m_glyphs.drawGlyph(startCol + i, row, text[i], color);
}
void Game::drawDeathScreen()
{
SDL_SetRenderDrawColor(m_sdl, BG.r, BG.g, BG.b, BG.a);
SDL_RenderClear(m_sdl);
const int boxW = 40, boxH = 9;
const int x0 = (MAP_WIDTH - boxW) / 2;
const int y0 = (MAP_HEIGHT - boxH) / 2;
for (int i = 0; i < boxW; ++i)
{
m_glyphs.drawGlyph(x0 + i, y0, '=', DEAD_FRAME);
m_glyphs.drawGlyph(x0 + i, y0 + boxH - 1, '=', DEAD_FRAME);
}
for (int j = 0; j < boxH; ++j)
{
m_glyphs.drawGlyph(x0, y0 + j, '|', DEAD_FRAME);
m_glyphs.drawGlyph(x0 + boxW - 1, y0 + j, '|', DEAD_FRAME);
}
drawCentred(y0 + 2, "Y O U D I E D", DEAD_TITLE);
drawCentred(y0 + 4, "The dungeon claims another.", DEAD_TEXT);
drawCentred(y0 + 6, "SPACE try again ESC quit", DEAD_HINT);
SDL_RenderPresent(m_sdl);
}
void Game::render()
{
if (m_state == GameState::Dead) { drawDeathScreen(); return; }
SDL_SetRenderDrawColor(m_sdl, BG.r, BG.g, BG.b, BG.a);
SDL_RenderClear(m_sdl);
for (int row = 0; row < MAP_HEIGHT; ++row)
for (int col = 0; col < MAP_WIDTH; ++col)
drawTile(col, row, m_map.tiles[row][col]);
for (const Monster& m : m_monsters)
if (m.isAlive() && m_map.tiles[m.y][m.x].visible)
m_glyphs.drawGlyph(m.x, m.y, m.glyph, m.color);
m_glyphs.drawGlyph(m_player.x, m_player.y, m_player.glyph, m_player.color);
SDL_RenderPresent(m_sdl);
}
void Game::run()
{
render();
bool running = true;
SDL_Event event;
while (running)
{
SDL_WaitEvent(&event);
bool dirty = false;
switch (event.type)
{
case SDL_EVENT_QUIT:
running = false;
break;
case SDL_EVENT_WINDOW_EXPOSED:
dirty = true;
break;
case SDL_EVENT_KEY_DOWN:
{
SDL_Scancode sc = event.key.scancode;
if (m_state == GameState::Dead)
{
if (sc == SDL_SCANCODE_SPACE) { newGame(); dirty = true; }
if (sc == SDL_SCANCODE_ESCAPE) running = false;
break;
}
int dx = 0, dy = 0;
switch (sc)
{
case SDL_SCANCODE_UP: case SDL_SCANCODE_W: dy = -1; break;
case SDL_SCANCODE_DOWN: case SDL_SCANCODE_S: dy = +1; break;
case SDL_SCANCODE_LEFT: case SDL_SCANCODE_A: dx = -1; break;
case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_D: dx = +1; break;
case SDL_SCANCODE_SPACE: newGame(); dirty = true; break;
case SDL_SCANCODE_ESCAPE: running = false; break;
default: break;
}
if (dx != 0 || dy != 0)
{
playerAct(dx, dy);
dirty = true;
}
break;
}
}
if (dirty) render();
}
}
main.cpp
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include "Map.h"
#include "GlyphCache.h"
#include "Game.h"
static const char* FONT_PATH = "RobotoMono-Light.ttf";
static const float FONT_PT = 20.0f;
int main(int argc, char* argv[])
{
if (!SDL_Init(SDL_INIT_VIDEO)) { SDL_Log("SDL_Init: %s", SDL_GetError()); return 1; }
if (!TTF_Init()) { SDL_Log("TTF_Init: %s", SDL_GetError()); SDL_Quit(); return 1; }
SDL_Window* window = nullptr;
SDL_Renderer* sdl = nullptr;
if (!SDL_CreateWindowAndRenderer(
"Roguelike - Part 6: The Game Class",
MAP_WIDTH * TILE_SIZE, MAP_HEIGHT * TILE_SIZE, 0,
&window, &sdl))
{
SDL_Log("CreateWindowAndRenderer: %s", SDL_GetError());
TTF_Quit(); SDL_Quit();
return 1;
}
GlyphCache glyphs(sdl, FONT_PATH, FONT_PT);
if (!glyphs.ok())
{
SDL_DestroyRenderer(sdl); SDL_DestroyWindow(window);
TTF_Quit(); SDL_Quit();
return 1;
}
{
Game game(window, sdl, glyphs);
game.run();
}
SDL_DestroyRenderer(sdl);
SDL_DestroyWindow(window);
TTF_Quit();
SDL_Quit();
return 0;
}
Common Errors
Linker error: unresolved Game::… methods. Game.cpp isn't in the build. Add it to the project alongside the headers — declaring methods in Game.h isn't enough; they need their definitions compiled.
Everything is black / the game does nothing. run() calls render() once before the loop and again only when dirty. If you dropped the initial render(), nothing draws until the first key. Confirm it's there.
The game behaves differently from Part 5. Then it isn't a faithful refactor. The usual culprit is a captured-by-value lambda turned into a method that reads a stale copy — every method must work on the m_ members, not local duplicates. Diff your logic against Part 5 line by line.
Crash on exit. Teardown order. The Game must be destroyed before SDL_DestroyRenderer — keep it inside its own { } scope (or make it a pointer you delete first).
What's Next
With a real Game to build inside, Act 2 begins. Part 7 replaces the greedy chase with Dijkstra maps — flood the dungeon with distance-to-player values once per turn, and every monster can follow the true shortest path by simply rolling downhill. Run the same map in reverse and a frightened monster flees with the identical code. It slots cleanly into monstersAct — which is exactly the kind of change the refactor was for.