Tutorial 15 of 17  ·  Act 3 — Depth & Polish

Save & Load 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 lives there. View repo →

Part 15 lets a run survive being closed. We serialise the entire game — the chosen class, the player and their statuses, inventory and equipment, what's been identified, and every floor's map and inhabitants — to a plain-text file, and read it all back. It's the most mechanical tutorial in the series, and a clean lesson in turning a living object graph into bytes and back without losing anything.

The Guiding Idea: Store Numbers, Rebuild the Rest

The trick that keeps the serialiser small is to save only what you can't recompute, and reconstruct everything else on load:

That last point needed one small change: the appearance tables became int indices instead of const char*, which is both easier to serialise and avoids any question of who owns the string:

int  m_potionAppearIdx[(int)PotionType::Count] = {};   // index into COLOURS[]
bool m_potionIdent     [(int)PotionType::Count] = {};

Writing the Save

save opens an std::ofstream and writes a version header, the run-wide state, then loops over the levels. The map is the bulkiest piece, so we encode each tile as a single digit — 0..3 packing type and explored together (visibility is recomputed on load, so it isn't stored):

f << "RLSAVE 2\n";
f << (int)m_class << ' ' << m_depth << '\n';
// ...player line, equipment, identification, inventory...

f << m_levels.size() << '\n';
for (const Level& L : m_levels)
{
    f << L.stairsX << ' ' << L.stairsY << '\n';
    for (int y = 0; y < MAP_HEIGHT; ++y)
    {
        for (int x = 0; x < MAP_WIDTH; ++x)
        {
            const Tile& t = L.map.tiles[y][x];
            int code = (t.type == TileType::Floor ? 2 : 0) + (t.explored ? 1 : 0);
            f << (char)('0' + code);
        }
        f << '\n';
    }
    // ...then monster and item counts followed by one line each...
}

Each monster line is glyph x y hp maxHp power poison regen venom alive; each item is category potion scroll bonus (x y). No strings, no colours — all of that comes back from the glyph or the category.

Reading It Back

load mirrors save with an std::ifstream, checking the version header first so a malformed or future file fails cleanly instead of reading garbage. A tiny buildItem lambda turns the saved numbers back into a fully-formed Item — reusing the same makeWeapon/makePotion helpers the live game uses — and monsters are rebuilt by looking their glyph up in a small kindByGlyph table:

auto buildItem = [&](int cat, int pot, int scr, int bonus) -> Item
{
    switch ((ItemCategory)cat)
    {
        case ItemCategory::Potion: return makePotion((PotionType)pot);
        case ItemCategory::Scroll: return makeScroll((ScrollType)scr);
        case ItemCategory::Weapon: return makeWeapon(weaponNameFor(bonus), bonus);
        default:                   return makeArmour(armourNameFor(bonus), bonus);
    }
};

After the whole graph is rebuilt, we drop straight into Playing, recompute field of view for the current floor (we deliberately didn't save the transient visible flags), and the run continues exactly where it left off — on the right floor, with the monsters where you left them and the same fizzy potion still unidentified.

Hooking It Up

F5 saves, F9 loads — and loading works from the class-select screen too, so "continue" is just F9 at the title. Neither costs a turn. A short confirmation flashes in the title bar via a one-line flash helper.

Try It

Build and run, descend a couple of floors, grab some loot, then press F5. Quit the program entirely, relaunch, and at the hero menu press F9 — you're back exactly where you were: same depth, same HP and gear, the floors above still explored and still holding the monsters you didn't kill. Open the save file in a text editor too; it's readable, and you can see the map rows as grids of digits.

Part 15 — a restored game showing the same depth and gear after loading, with the title bar confirming Game loaded
Loaded back to the exact moment it was saved — floor, inventory, identification and all.

Notes on the Code

Changed: Game.h / Game.cpp only — the index-based appearance tables, save/load, and the F5/F9 handling. Full Game.cpp in the repo. The save lands in savegame.txt beside the executable.

Common Errors

Load reads garbage or crashes. The read order must exactly mirror the write order. Keep save and load physically next to each other and change them together; a version string at the top lets you reject mismatches early.

The map rows come back scrambled. A tile row is one whitespace-free token of digits, read with in >> rowString. If you wrote spaces between tiles, >> will stop at the first one.

Everything's black after loading. You restored the explored flags but not visible — which is correct, but you must then call FOV::compute for the current floor so the area around the player lights up again.

Loaded monsters are all grey "things". kindByGlyph doesn't recognise the saved glyph, or the glyph was read with >> into the wrong type. Read it as a short token and use its first character.

What's Next

The finale of the build: Part 16 — UI Polish & Sound. We give the cramped title-bar HUD a real home — an on-screen status panel and a scrolling message log (the small MessageLog extraction we earmarked at the Interlude) so the combat narration finally appears in the window — and we add sound with SDL3's audio: short synthesised blips for hits, pickups and descents, no asset files required. Then Part 17 crowns it with the win condition.