Tutorial 14 of 17  ·  Act 3 — Depth & Polish

Character Classes

Source code on GitHub. The finished project for this part — and all seventeen — is at EliteIntegrity/Roguelike-tutorial-series, one folder per tutorial. The full Game.cpp lives there. View repo →

Part 14 lets the player decide who they are before the descent. Three heroes — a Fighter, a Rogue, and a Mage — each begin with different health, melee strength, magic, and starting gear, which makes every run play differently from the first turn. And it all snaps in because every stat a class wants to set already exists: base power, max HP, the magic bonus we planted in Part 13, and the equipment slots from Part 9.

A New State: the Title Screen

The game now starts on a menu, not in the dungeon, so we add a fourth GameState and an enum of classes:

enum class GameState   { ClassSelect, Playing, Inventory, Dead };
enum class PlayerClass { Fighter, Rogue, Mage };

The Game constructor no longer starts a run — it just sets m_state = ClassSelect and waits. render checks that state first (before it ever touches a level, since none exists yet) and draws the menu; pressing 1, 2 or 3 calls newGame with the chosen class. This is the same state-machine pattern that has absorbed the death screen and the inventory — adding a title screen is now routine.

Classes Are Just Different Starting Values

A "class" sounds like it needs a hierarchy of subclasses. It doesn't. Because the player's stats are already derived (Part 9) and the magic bonus is already a number (Part 13), a class is nothing more than a set of opening values and a starting kit, chosen in a switch:

switch (cls)
{
    case PlayerClass::Fighter:
        m_baseMaxHp = 36;  m_basePower = 6;
        m_weapon = makeWeapon("sword", 4);      m_hasWeapon = true;
        m_armour = makeArmour("chain mail", 3); m_hasArmour = true;
        m_inventory.push_back(makePotion(PotionType::Healing));
        break;
    case PlayerClass::Rogue:
        m_baseMaxHp = 28;  m_basePower = 5;
        m_weapon = makeWeapon("dagger", 2);  m_hasWeapon = true;
        m_armour = makeArmour("leather", 1); m_hasArmour = true;
        m_inventory.push_back(makePotion(PotionType::Healing));
        m_inventory.push_back(makeScroll(ScrollType::Lightning));
        break;
    case PlayerClass::Mage:
        m_baseMaxHp = 22;  m_basePower = 3;  m_magicBonus = 6;
        m_inventory.push_back(makeScroll(ScrollType::Lightning));
        m_inventory.push_back(makeScroll(ScrollType::Fireball));
        m_inventory.push_back(makePotion(PotionType::Healing));
        break;
}

m_player.maxHp = m_baseMaxHp;
m_player.hp    = m_baseMaxHp;
recomputeStats();   // power = m_basePower + bonuses + weapon; defense = armour

The Fighter is a wall of HP and steel; the Rogue is leaner with a ranged option; the Mage is glass, but his m_magicBonus = 6 flows straight into the scroll-damage formula from Part 13, so his fireballs land for more than double a warrior's. Three genuinely different play styles, expressed entirely in numbers and a few starting items — no inheritance, no special-case combat code. (The little makeWeapon/makeScroll/makePotion helpers just stamp out a fully-formed Item so the starting kit is built the same way loot is.)

The Menu, and Death

drawClassSelect is a few centred lines describing the heroes; the input loop maps 1/2/3 to newGame. And death now loops back to the menu rather than restarting the same hero — so a fresh run means a fresh choice:

if (m_state == GameState::Dead)
{
    if (sc == SDL_SCANCODE_SPACE)  { m_state = GameState::ClassSelect; dirty = true; }
    if (sc == SDL_SCANCODE_ESCAPE) running = false;
    break;
}

Try It

Build and run — you're greeted by the hero menu now, not a dungeon. Take the Fighter and wade in; the early floors barely scratch you. Then die, pick the Mage, and play completely differently: hang back, open with a fireball into the pack, and pray nothing reaches you while you're at 22 HP. Same dungeon, same systems, three distinct experiences — the hallmark of a class system done with data instead of code.

Part 14 — the class-select screen listing Fighter, Rogue and Mage with their starting kit
The hero menu. Each class is just a set of opening stats and a starting kit — the engine underneath is identical.

Notes on the Code

Changed: Game.h / Game.cpp only (the ClassSelect state, PlayerClass, the class switch in newGame, the item-maker helpers, and drawClassSelect). Full Game.cpp in the repo. Everything else is unchanged from Part 13.

Common Errors

Crash on launch. Something accessed cur() before a class was chosen, when m_levels is still empty. render must check the ClassSelect state and return before touching a level.

Every class feels the same. recomputeStats isn't using m_basePower, or the class switch runs after stats are computed. Set the bases first, then recomputeStats.

The Mage's spells are no stronger. m_magicBonus isn't being added in readScroll (it was wired up in Part 13) — or it's reset after the class sets it.

Starting gear isn't equipped. The class set m_weapon but not m_hasWeapon = true, or recomputeStats wasn't called after assigning the kit.

What's Next

Part 15 — Save & Load lets a run survive closing the program. We serialise the whole game — the player, inventory, equipment, identification, every floor's map and inhabitants — to a plain-text file, and read it back. It's the most mechanical part of the series and a good lesson in turning a live object graph into bytes and back.