Tutorial 13 of 17  ·  Act 3 — Depth & Polish

Ranged Combat & Magic

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 →

So far every fight has been nose-to-nose. Part 13 lets you reach out and hurt something across the room, through magic scrolls — and because we built items, an inventory and identification back in Act 2, "ranged combat" turns out to be mostly a new kind of item plus a targeting rule.

Scrolls Are a New Item Category

A scroll is read, not drunk or worn, so it gets its own category and type — and, like potions, it starts unidentified behind a random label ("scroll labelled XYZZY"):

enum class ItemCategory { Potion, Weapon, Armour, Scroll };
enum class ScrollType   { Lightning, Fireball, Count };

Scrolls drop as ? on the floor and identify-on-use exactly like potions — the game keeps a parallel m_scrollAppear[] / m_scrollIdent[] pair, and scrollName mirrors potionName. Reading a fireball once teaches you what that label means for the rest of the run.

Picking a Target

Real ranged combat wants a targeting cursor, but the cleanest first version auto-targets the nearest monster you can see — which is almost always what you want anyway:

Monster* Game::nearestVisibleMonster()
{
    Level& L = cur();
    Monster* best = nullptr;
    int bestDist = 1 << 30;
    for (Monster& m : L.monsters)
    {
        if (!m.isAlive() || !L.map.tiles[m.y][m.x].visible) continue;
        int d = std::abs(m.x - m_player.x) + std::abs(m.y - m_player.y);
        if (d < bestDist) { bestDist = d; best = &m; }
    }
    return best;
}

It reuses the fog-of-war flags from Part 3 as the line-of-sight test: if a monster's tile is currently visible, it's a legal target. (A manual cursor — step a marker around with the arrow keys and confirm — is a natural extension, and a good exercise once this works.)

Casting

readScroll finds the target and applies the spell. Magic ignores armour, so it deals its damage directly rather than going through the defense-aware attack. Lightning hits one monster; fireball catches everything in the 3×3 around the target:

void Game::readScroll(ScrollType t)
{
    bool known = m_scrollIdent[(int)t];
    SDL_Log("You read the %s.", scrollName(t).c_str());

    Monster* target = nearestVisibleMonster();
    auto zap = [&](Monster& m, int dmg)
    {
        m.hp -= dmg;                         // magic ignores armour
        SDL_Log("%s is blasted for %d.", m.name, dmg);
        if (m.hp <= 0) { m.hp = 0; m.alive = false; SDL_Log("%s is destroyed.", m.name); }
    };

    if (!target)
        SDL_Log("The magic crackles and fades — nothing in sight.");
    else if (t == ScrollType::Lightning)
        zap(*target, LIGHTNING_DMG + m_magicBonus);
    else // Fireball: the target and everything around it
    {
        int tx = target->x, ty = target->y;
        for (Monster& m : cur().monsters)
            if (m.isAlive() && std::abs(m.x - tx) <= 1 && std::abs(m.y - ty) <= 1)
                zap(m, FIREBALL_DMG + m_magicBonus);
    }

    if (!known) { m_scrollIdent[(int)t] = true; SDL_Log("It was a %s!", SCROLL_TRUE[(int)t]); }
}

Notice the + m_magicBonus. It's zero now, but it's the hook the Mage class in Part 14 will turn up — proof that thinking one part ahead costs a single field. Reading a scroll dispatches through the same useItem path as everything else, so it costs a turn and the monsters get theirs in reply.

Try It

Build and run, find a ? scroll, and read it from the bag with a goblin in view. Lightning vaporises the nearest threat; fireball, read into a clustered pack (they bunch up in corridors thanks to the Dijkstra chase), can clear three at once. Read an unknown scroll with nothing in sight and it fizzles — but still identifies, so it's a safe way to learn a label. Magic punching through armour makes scrolls your answer to the armoured horrors waiting on the deep floors.

Part 13 — the log showing a fireball scroll blasting several monsters at once at range
A fireball read into a packed corridor — magic strikes at range and ignores armour.

Notes on the Code

Changed: Item.h (the Scroll category and ScrollType), Level.cpp (scrolls in the loot table), and Game.h / Game.cpp (scroll identification, nearestVisibleMonster, readScroll, and the useItem dispatch). Full Game.cpp in the repo.

Common Errors

Scrolls hit through walls or off-screen. The visibility test is missing from nearestVisibleMonster — only consider monsters whose tile is currently visible.

Armour soaks up the spell. Magic should bypass defense. Don't route zap through attack (which subtracts defense); subtract from hp directly.

Reading does nothing and never identifies. The identify step is inside the "has target" branch. Identification should happen whenever the scroll is read, target or not.

Pressing a letter equips a scroll instead of reading it. useItem must switch on category with a Scroll case calling readScroll.

What's Next

Part 14 — Character Classes lets the player choose who they are before the descent: a Fighter who starts armed and armoured, a Rogue with speed and a blade, or a Mage who begins with scrolls and turns that m_magicBonus we just planted into real spell power. We add a class-select screen — another GameState — and class-specific starting stats and gear.