Tutorial 9 of 17  ·  Act 2 — Systems

Equipment & Stat Modifiers

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 →

Potions are consumables — used once and gone. Equipment is different: a sword or a coat of mail changes who you are for as long as you carry it. Part 9 adds weapons and armour you can wield and wear, and in doing so promotes items to first-class citizens and finally gives the combat maths a defensive half.

Items Become First-Class

In Part 8 the inventory held bare PotionType values — fine when everything was a potion. Now an item might be a potion, a weapon, or armour, so we widen Item into a small tagged record and let the inventory hold those:

enum class ItemCategory { Potion, Weapon, Armour };
enum class PotionType    { Healing, Poison, Strength, Count };

struct Item
{
    ItemCategory category = ItemCategory::Potion;

    PotionType   potion   = PotionType::Healing;   // when category == Potion
    int          bonus    = 0;                     // power (Weapon) or defense (Armour)
    const char*  name     = "";                    // true name for equipment

    char         glyph    = '!';
    SDL_Color    color    = { 200, 120, 220, 255 };

    int          x = 0, y = 0;   // position while on the floor
};

The category tag tells the rest of the game how to treat the item — which is the whole pattern. Weapons draw as /, armour as [, potions still as !. The identification system is untouched: only potions hide their identity; a sword is obviously a sword.

Defense — the Other Half of Combat

Entities gain one field, defense, and the attack formula learns to subtract it:

void Game::attack(Entity& atk, Entity& def)
{
    int dmg = atk.power - def.defense;
    if (dmg < 1) dmg = 1;             // a clean hit always stings at least 1
    def.hp -= dmg;
    // ...log, and mark dead if hp <= 0
}

The max(1, …) floor matters: without it, enough armour would make you literally invincible, which is never fun. Monsters keep defense = 0, so attacking them is unchanged — it's the player who now benefits from armour.

Derived Stats: the Player Is the Sum of Their Gear

Here's the cleanest way to handle modifiers, and it avoids a whole class of bugs. We never add a bonus directly onto player.power. Instead the player's power and defense are derived from a base plus every active source, recomputed whenever anything changes:

void Game::recomputeStats()
{
    m_player.power   = BASE_POWER   + m_strengthBonus + (m_hasWeapon ? m_weapon.bonus : 0);
    m_player.defense = BASE_DEFENSE +                   (m_hasArmour ? m_armour.bonus : 0);
}

Why bother? Because the naïve approach — player.power += weapon.bonus on equip — forces you to remember to subtract it again on unequip, and the moment you have potions of strength, cursed items, and temporary buffs all touching the same number, those plus/minus pairs drift out of sync and your stats slowly corrupt. Deriving from sources means there's nothing to undo: change a source, call recomputeStats, done. The strength potion now just bumps m_strengthBonus and recomputes.

Equipping — a Swap

Equipping is a swap: the new piece goes into its slot, and whatever was there returns to the pack. Then we recompute:

void Game::equip(const Item& it)
{
    if (it.category == ItemCategory::Weapon)
    {
        if (m_hasWeapon) m_inventory.push_back(m_weapon);   // old weapon back to the bag
        m_weapon = it;  m_hasWeapon = true;
        SDL_Log("You wield the %s.", it.name);
    }
    else // Armour
    {
        if (m_hasArmour) m_inventory.push_back(m_armour);
        m_armour = it;  m_hasArmour = true;
        SDL_Log("You don the %s.", it.name);
    }
    recomputeStats();
}

The inventory's "use" action now dispatches on category — a potion is quaffed, a weapon or armour is equipped — and either way it costs a turn, just like in Part 8:

void Game::useItem(int slot)
{
    if (slot < 0 || slot >= (int)m_inventory.size()) return;

    Item it = m_inventory[slot];
    m_inventory.erase(m_inventory.begin() + slot);

    if (it.category == ItemCategory::Potion) quaff(it.potion);
    else                                     equip(it);

    m_state = GameState::Playing;
    monstersAct();
    updateTitle();
    if (m_player.hp <= 0) m_state = GameState::Dead;
}

The inventory panel now shows your equipped gear at the top — Wielding: sword (+4 pow), Wearing: leather (+1 def) — above the lettered pack list, and the title bar reports your live POW and DEF so you can watch them jump the instant you equip something.

Try It

Build and run. Among the potions you'll now find / weapons and [ armour. Pick one up, open the bag with i, press its letter to equip it, and watch POW or DEF climb in the title bar. Find a better weapon and equipping it swaps the old one back into your pack. Then go test the difference armour makes: a goblin that used to take six off you now takes less, and a dagger-armed @ trades very differently from a sword-armed one.

Part 9 — the inventory panel showing equipped weapon and armour at the top, with a lettered pack list below
Equipped gear sits above the pack; the title bar tracks the power and defense those items grant.

Notes on the Code

This part changes Entity.h (the defense field), Item.h (the wider Item), and Game.h / Game.cpp. The full Game.cpp is in the repo; the excerpts above are what's new. Map, GlyphCache, FOV, Monster, Player and DijkstraMap are unchanged.

Common Errors

Stats drift — power keeps rising, or doesn't reset on a new game. A bonus is being added directly to m_player.power somewhere. Route every modifier through a source (m_strengthBonus, the equipped items) and let recomputeStats set the totals; newGame must reset those sources and recompute.

Armour makes you invincible. The max(1, …) damage floor is missing — high defense drives damage to zero or negative. Always deal at least 1.

Equipping a second weapon loses the first. The swap isn't pushing the old item back. Before overwriting the slot, push_back the currently-equipped item into the inventory.

Pressing a letter quaffs when you meant to equip (or vice-versa). useItem must branch on it.category. If everything is treated as a potion, you generalised the inventory but not the action.

What's Next

Part 10 — Status Effects adds the dimension of time to the game. That poison potion stops being an instant hit and becomes a lingering poisoned state that drains you over several turns; a venomous monster can inflict it with a bite; a potion of regeneration heals you slowly. We add a small per-entity status system that ticks every turn — the foundation for buffs, debuffs, and every "for the next N turns…" effect to come.