Tutorial 8 of 17  ·  Act 2 — Systems

Items & the Identification System

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 for this part lives there. View repo →

Loot is what turns exploration into greed. Part 8 puts potions on the dungeon floor, gives you an inventory to carry them, and adds the mechanic that has defined the genre since Rogue itself: identification. That potion you found is "a murky potion." Is it healing? Is it poison? You don't know — and the only ways to find out are to gamble and drink it, or to find another way to identify it. Knowledge becomes a resource you spend.

The Identification Model

The trick is to separate two things that feel like one: what a potion is, and what it looks like.

So in Item.h, an item barely needs anything — the cleverness lives in the game's identification tables, not the item:

#pragma once

enum class PotionType { Healing, Poison, Strength, Count };

struct Item
{
    PotionType type = PotionType::Healing;
    int x = 0;   // position while lying on the dungeon floor
    int y = 0;
};

Game holds the per-run identification state, reset every newGame:

const char* m_appearance[(int)PotionType::Count] = {};
bool        m_identified[(int)PotionType::Count] = {};

And in newGame we shuffle a pool of colour words and deal one to each type — the same shuffle that makes every run a fresh puzzle:

const char* COLOURS[] = { "red", "blue", "fizzy", "murky", "glowing", "silver" };

int order[NUM_COLOURS];
for (int i = 0; i < NUM_COLOURS; ++i) order[i] = i;
std::shuffle(order, order + NUM_COLOURS, m_rng);
for (int t = 0; t < (int)PotionType::Count; ++t)
{
    m_appearance[t] = COLOURS[order[t]];
    m_identified[t] = false;
}

One method turns a type into the right label depending on what the player knows:

std::string Game::potionName(PotionType t) const
{
    static const char* TRUE_NAME[] =
        { "potion of healing", "potion of poison", "potion of strength" };

    if (m_identified[(int)t]) return TRUE_NAME[(int)t];
    return std::string(m_appearance[(int)t]) + " potion";
}

Items on the Floor

Potions are scattered on random floor tiles in newGame (into m_items), drawn as ! — and crucially, every potion looks identical on the ground, because you can't tell a type by sight. They obey the fog of war exactly like the amulets and monsters before them:

for (const Item& it : m_items)
{
    const Tile& t = m_map.tiles[it.y][it.x];
    if (t.visible)       m_glyphs.drawGlyph(it.x, it.y, '!', POTION_LIT);
    else if (t.explored) m_glyphs.drawGlyph(it.x, it.y, '!', POTION_MEM);
}

Picking up is the now-familiar "is something on my tile?" check, run right after a successful move inside playerAct — the potion moves from the floor into the inventory:

void Game::pickUpHere()
{
    for (size_t i = 0; i < m_items.size(); ++i)
        if (m_items[i].x == m_player.x && m_items[i].y == m_player.y)
        {
            PotionType t = m_items[i].type;
            m_inventory.push_back(t);
            SDL_Log("You pick up a %s.", potionName(t).c_str());
            m_items.erase(m_items.begin() + i);
            break;
        }
}

The Inventory — a New Game State

Here's where Part 6 pays off again. The inventory is simply a third GameState:

enum class GameState { Playing, Inventory, Dead };

Pressing i while playing flips to Inventory (which costs no turn — opening your bag is free). In that state, the renderer draws a panel instead of the dungeon, and the input loop reads letters: az quaff the matching item, i or Esc close. Because SDL's letter scancodes are contiguous, a key maps to a slot with one subtraction:

if (m_state == GameState::Inventory)
{
    if (sc == SDL_SCANCODE_I || sc == SDL_SCANCODE_ESCAPE)
    {
        m_state = GameState::Playing;
        dirty = true;
    }
    else if (sc >= SDL_SCANCODE_A && sc <= SDL_SCANCODE_Z)
    {
        quaff(sc - SDL_SCANCODE_A);   // may close the bag and cost a turn
        dirty = true;
    }
    break;
}

The panel lists each item by its current label — appearance or true name — so the same bag reads "c) murky potion" before you've learned it and "c) potion of healing" after.

Drinking, and the Moment of Truth

quaff applies the effect, identifies the type, removes the potion, and — because drinking is an action — ends your turn so the monsters get theirs:

void Game::quaff(int slot)
{
    if (slot < 0 || slot >= (int)m_inventory.size())
        return;                       // no such item — not a turn

    PotionType t = m_inventory[slot];
    applyPotion(t);
    m_inventory.erase(m_inventory.begin() + slot);

    m_state = GameState::Playing;     // quaffing closes the inventory...
    monstersAct();                    // ...and costs a turn
    updateTitle();
    if (m_player.hp <= 0) m_state = GameState::Dead;
}

applyPotion is where the gamble resolves. It does the effect, and the first time a type is used it flips m_identified and announces what it was — so a reckless first sip teaches you (sometimes painfully) what that colour means for the rest of the run:

void Game::applyPotion(PotionType t)
{
    bool known = m_identified[(int)t];
    SDL_Log("You quaff the %s.", potionName(t).c_str());

    switch (t)
    {
        case PotionType::Healing:
            m_player.hp = std::min(m_player.maxHp, m_player.hp + 10);
            SDL_Log("A warm glow spreads through you. (HP %d/%d)", m_player.hp, m_player.maxHp);
            break;
        case PotionType::Poison:
            m_player.hp -= 6;
            SDL_Log("You retch — it was poison! (HP %d)", m_player.hp > 0 ? m_player.hp : 0);
            break;
        case PotionType::Strength:
            m_player.power += 1;
            SDL_Log("Your muscles swell. (power %d)", m_player.power);
            break;
        default: break;
    }

    if (!known)
    {
        m_identified[(int)t] = true;
        SDL_Log("It was %s!", potionName(t).c_str());
    }
}

That's the whole loop the genre is built on: risk knowledge to gain knowledge. A poison potion this run is a trap; identify it, and every other potion of that colour is now a known quantity you can use or sell or throw.

Try It

Build and run. Explore, step over a ! to pick it up, and press i to open your bag — you'll see entries like "a) fizzy potion." Quaff one and find out the hard way what fizzy means this run; from then on it shows its true name. Drinking takes a turn, so don't open your bag with a goblin breathing down your neck. Watch the Output window for the running narration of pickups and effects.

Part 8 — the inventory panel listing unidentified potions by colour, e.g. a) murky potion, b) red potion
The inventory: unidentified potions show only their random appearance until you risk drinking one.

Notes on the Code

This part touches only Game.h and Game.cpp (plus the new Item.h); every other file is unchanged. Game.cpp has grown enough that it's listed in full in the GitHub repo rather than reproduced here — the excerpts above are the parts that are new since Part 7. Notice how cleanly the inventory dropped in: a new GameState, a couple of vectors, and a few methods, all hanging off the Game class we built in Part 6. That is exactly the dividend the refactor promised.

Common Errors

Every "murky potion" is the same type every game. The appearance shuffle isn't running, or isn't seeded. Confirm std::shuffle(order, order + NUM_COLOURS, m_rng) runs in newGame and m_rng is seeded from std::random_device.

Potions reveal their identity before you drink them. Something is reading TRUE_NAME directly instead of going through potionName, or the floor draw is colouring potions by type. All unidentified potions must share one glyph and colour, and all labels must come from potionName.

Opening the inventory lets monsters move. Opening must not call monstersAct. Only quaff (a real action) advances the turn; switching to the Inventory state is free.

Quaffing the wrong slot, or a crash on an empty bag. The slot guard is missing. quaff must return early when slot is out of range — pressing a letter with no matching item should do nothing.

What's Next

Act 2 continues with Part 9 — Equipment & Stat Modifiers: weapons and armour you can wield and wear, each adjusting the power and defence values our combat already reads. With items, an inventory and identification in place, equipment is mostly a matter of letting certain items be worn rather than drunk — and of teaching the attack maths to ask the equipment for its bonuses.