The game can be lost, but it can't be won. The third Amulet is picked up with "You have all three Amulets!" Then the run goes on, with nothing left to find, until something kills the player.
This chapter ends the run in victory as the third Amulet is picked up, and gives every run a score. When a run ends, either way, the game writes its story to a file, the morgue file. And it keeps the best runs in a table of high scores.
The next image shows a victory on the deepest floor: a Rogue beside the dragon, the third Amulet just picked up, and the high scores, with this run at the top, in gold.

Every run in the table was played by the bot that Chapter 27 builds, this one too. So they're real scores from the finished game.
Chapter 26 in the book's repository holds this chapter's finished project, with the sound of a win in its assets folder. The chapter carries on in your own project from Chapter 25, or from the book's Chapter 25 folder.In this chapter, we will:
- End the run in victory as the third Amulet is picked up, with a screen and a sound of its own
- Give every run a score, from its gold, its depth and its Amulets, and more for a win
- Count the player's kills, by kind of monster, and keep the count in the save
- Write the morgue file when a run ends: its story, from the player's pack to the floor it ended on, in a folder the end screens open
- Keep the ten best runs in a table of high scores, shown when a run ends and on the title screen
There are four checkpoints along the way. Let's start with the plan.
Planning the Chapter
This chapter adds five files of code and a sound, and changes five files:
| File | Status | What it's for |
|---|---|---|
src/Words.h, src/Words.cpp |
New | Small helpers with words, moved out of Game.cpp for the morgue file to share |
src/Morgue.cpp |
New | The morgue file, written when a run ends |
src/HighScores.h, src/HighScores.cpp |
New | The ten best runs, kept in scores.txt |
assets/sounds/win_1.wav |
New, from the repository | The sound of a win |
data/sounds.txt |
Changed | The win's sound |
src/SoundEvent.h |
Changed | The win, as an event |
src/Game.h, src/Game.cpp |
Changed | The win, the score, the kills, the end of a run, and the screens that show it |
src/SaveGame.cpp |
Changed | The kills, kept in the save |
| The other files | Unchanged |
The next image shows how the files fit together at the end of the chapter. The morgue file's functions belong to the Game, in a file of their own, as the save's do. They share Words with Game.cpp. The high scores are a class of their own, which the Game holds. They read and write scores.txt through Files and Records, as the settings do.

Most of the work is in the morgue file, about 220 lines. Then comes Game.cpp, which grows by about 170 lines, after three small functions have moved out of it. The high scores come to about 120 lines, Words to 50, and keeping the kills in the save to about 55.
How a Run Ends
From now on, a run ends in one of two ways: the player dies, or picks up the third Amulet. Either way, three things follow. The save goes, since a finished run can't be continued; the run's story is written to its morgue file; and its score goes into the high scores, if it's good enough. Then the end screen waits for R to play again, or Esc to quit. The next image shows the two ways in, and what follows them.

Both ways meet in one function, endRun, which arrives with the win. At first, all it does is remove the save, and the morgue file and the high scores join it as the chapter adds them. The chapter adds the win and the score first, and then counts the kills, which the morgue file lists. Then it writes the morgue file, and last of all, it keeps the high scores.
Winning
The first part ends the run with the third Amulet, gives every run a score, and shows the score on the screen that ends the run, whichever way it ends.
The Third Amulet
Picking up an Amulet counts it, and the third says "You have all three Amulets!" But then the turn goes on, and so does the run. A won run needs a state of its own, as a dead one has. Open Game.h, and add Won below Dead in GameState:
Won, // the third Amulet found, and the run won
Winning is a function of its own, win, which pickUp will call. Add its declaration below finishTurn's:
void win();
A win has a sound, as a death has, so it's an event too. Open SoundEvent.h, and add it below Choose:
Win, // the third Amulet, and the run won
Its name goes last among the names. In SoundEvent.h, change "death", "choose", to "death", "choose", "win",. The static_assert below the names checks the last event against their number, so it must name the new last event: change SoundEvent::Choose to SoundEvent::Win. Left naming Choose, the check fails, and the build stops. One more than Choose is 33, and there are 34 names now.
The sound itself comes from the book's repository. Copy win_1.wav from the Chapter 26 folder's assets\sounds folder into your project's assets\sounds folder. It's in stereo, as the death's two sounds are. The bank converts it as it loads it, as it does theirs. In sounds.txt, in the comment at the top, turn death and choose into death, choose and win, and add the win's record at the end of the file:
[sound]
event = win
files = win_1.wav
Now win itself. In Game.cpp, add it below finishTurn:
// The run is won, with the third Amulet. Picking it up is a turn, as any
// pickup is, though no monster acts again. Victory, like death, ends the
// run, so the save goes
void Game::win()
{
++turn_;
log_.add("You have won!");
playSound(SoundEvent::Win);
state_ = GameState::Won;
endRun();
}
The increment ++turn_ counts the turn the pickup took, which finishTurn would have counted, along with the monsters' turn. The state becomes Won, which the rest of the game knows a won run by. Last, the call to endRun ends the run, as the next section explains.
The third Amulet ends the run at once, before the turn's end, so the monsters never get their turn. A dragon beside the hoard can't kill a player who has already won. In pickUp, add this below the call to std::erase_if, with a blank line in between:
// The third Amulet ends the run, at once: no monster acts again
if (player_.amulets == AMULETS)
{
win();
return;
}
The Amulet has already been counted, its message logged, and the item taken off the floor. The return skips finishTurn, which would let the monsters act.
The End, Either Way
A won run can't be continued, any more than a dead one can, so its save must go too. From now on, whatever follows the end of a run is the same, whichever way it ended. So it's done in one function, endRun, which both ends call. In Game.h, add the line void endRun(); below void win();.
In Game.cpp, add endRun below win:
// The run is over, by death or by victory. What follows is the same
// either way, so it's done here, for both
void Game::endRun()
{
removeSave();
}
For now, all endRun does is remove the save. The morgue file and the high scores will join it, later in the chapter. A death ends the run the same way: in finishTurn, change removeSave(); to endRun();.
The death screen's keys will do for the victory screen too: R to play again and Esc to quit. So their function gets a name that fits both. In Game.h, change handleDeadKey to handleEndKey.
In Game.cpp, open Find and Replace with Ctrl+H. Type handleDeadKey in the top box, and handleEndKey in the box below it, and click Replace All. It replaces two, the call in handleKey and the function's own name.
The function's comment should mention the victory too. Replace the comment above handleEndKey with this:
// The run is over, by death or victory, and its screen is up. Each key
// the screen names does what it says, and the others do nothing
The screen names its own keys, as in "Press R to play again, or Esc to quit.", so the comment doesn't list them again. The victory screen's keys go to handleEndKey as the death screen's do. In handleKey, add this line directly below case GameState::Dead::
case GameState::Won:
Two case labels in a row lead to the same code, so a key pressed on either screen goes to handleEndKey.
Closing the window in the middle of a run saves it. But a won run is over, as a dead one is, and its save is already gone. In handleEvent, find these lines:
// has a class, until they die
bool inRun = player_.classDef != nullptr &&
state_ != GameState::Dead;
Change them to these:
// has a class, until they die, or win
bool inRun = player_.classDef != nullptr &&
state_ != GameState::Dead && state_ != GameState::Won;
The victory screen is drawn as the death screen is, over the dungeon. In drawGame, add its branch below the death screen's:
else if (state_ == GameState::Won)
{
drawVictoryScreen();
}
The screen itself comes after the score, since it shows the score.
The Score
A score turns a run into a number, so one run can be set against another. Ours counts what a run achieved: its gold, how deep it went, its Amulets, and whether it won. Open Game.cpp, and add the score's numbers below REGENERATION, with a blank line in between:
// The score: the gold, and this much for each floor reached, for each
// Amulet, and for a win
constexpr int SCORE_PER_FLOOR = 50;
constexpr int SCORE_PER_AMULET = 500;
constexpr int SCORE_FOR_WIN = 2000;
In Game.h, add score's declaration below endRun's:
int score() const;
And in Game.cpp, add score below endRun:
// The run's score: its gold, SCORE_PER_FLOOR for each floor reached,
// SCORE_PER_AMULET for each Amulet, and SCORE_FOR_WIN more for a win.
// There are no stairs up, so the floor the player is on is the deepest
// they've reached
int Game::score() const
{
int total = player_.gold + SCORE_PER_FLOOR * level_.depth +
SCORE_PER_AMULET * player_.amulets;
if (state_ == GameState::Won)
{
total += SCORE_FOR_WIN;
}
return total;
}
The floor counts as deep as the run went, as the comment says. A run that ends on floor 5 with 300 gold and one Amulet scores 300, and 250 for the floors, and 500 for the Amulet: 1,050 in all. Any win is worth at least 3,950: 450 for the nine floors, 1,500 for the Amulets and 2,000 for the win, with the gold on top.
Design note: Rogue's score. In Rogue, the score is the gold in the purse, and nothing else. A player killed in the dungeon loses a tenth of it, and scores the rest. One who climbs back out with the Amulet of Yendor sells everything they carry, the Amulet for 1,000 gold pieces. After a message saying they've sold their loot at a great profit, they score the lot.
Ours counts the gold too, but adds the depth and the Amulets. So a run that went deep scores well, even if it found little gold.
The Victory Screen
The victory screen is a dialog over the dungeon, as the death screen is. It has a heading, a line that says how the run ended, the score, and the keys. The two screens will always show the same kinds of lines, so they're the same size, and one function draws the dialog for both. In Game.h, add the line SDL_FRect drawEndDialog() const; below SDL_FRect drawDialog(float width, float height) const;. Then add the line void drawVictoryScreen() const; below void drawDeathScreen() const;.
In Game.cpp, add drawEndDialog below drawDialog:
// The end screens' dialog, the same size for both: tall enough for all
// their lines, and the gaps between them
SDL_FRect Game::drawEndDialog() const
{
return drawDialog(600.0f, 7 * glyphs_.lineHeight());
}
The dialog is 600 pixels across, as the death screen's was. It's seven rows tall: the heading, a row of space, the line that says how the run ended, the score and the keys, with a row of space above and below them. The death screen's dialog comes from it now. In drawDeathScreen, change drawDialog(600.0f, 6 * row) to drawEndDialog().
The death screen gives the score below what killed the player, and its comment says so. Replace the comment above drawDeathScreen with this:
// The death screen: a dialog that says what killed the player, where, and
// when, and the run's score
Then add the score directly above the line that draws "Press R to play again, or Esc to quit.":
drawCentered("A score of " + std::to_string(score()) + ".", centerX, y,
Palette::TEXT);
y += row;
The victory screen's heading is gold, the color the side panel gives the Amulets. Below the line that says when the run was won, it's the death screen's, line for line. Add drawVictoryScreen to Game.cpp, below drawDeathScreen:
// The victory screen: a dialog that says the run is won, and when, and its
// score
void Game::drawVictoryScreen() const
{
float centerX = MAP_VIEW_W / 2.0f;
float row = glyphs_.lineHeight();
SDL_FRect box = drawEndDialog();
float y = box.y + row;
drawCentered("YOU HAVE WON", centerX, y, Palette::GOLD);
y += 2 * row;
drawCentered("All three Amulets of SDL are yours, on turn " +
std::to_string(turn_) + ".", centerX, y, Palette::TEXT);
y += row;
drawCentered("A score of " + std::to_string(score()) + ".", centerX, y,
Palette::TEXT);
y += row;
drawCentered("Press R to play again, or Esc to quit.", centerX, y,
Palette::DIM_TEXT);
}
In the preceding code, the screen says when the run was won, but not where, since the last Amulet always lies on floor 9. From here on, every line the chapter adds to one end screen goes in the other too.
Checkpoint: Winning takes a whole run, so for now, start a run that's almost won. In startAs, add three lines below player_.updateDefense();, for now: player_.amulets = 2;, then player_.hp = 1;, and last level_.items.push_back({ player_.pos, Item{ data_.findItem("amulet") } });, which puts the third Amulet at your feet. Press F5, with seed 23, and start a new run as the Fighter.
Pick the Amulet up with g. The log says "You have all three Amulets!", and then "You have won!", as the win's sound plays. The next image shows the victory screen.

The score is 3550: 50 for the first floor, 1,500 for the three Amulets, and 2,000 for the win. Press R, and the class menu asks for another run. It would be in a new dungeon, since only the game's first run uses the seed. So press Esc to quit instead.
Press F5 again, and start a new run as the Fighter, in seed 23's dungeon. This time, leave the Amulet where it is: take the six steps down to the jackals with j, and wait for them with the period. The first bite kills you, and the death screen gives the score: 1050, for the floor and the two Amulets in hand. Take the three lines out of startAs.
Counting the Kills
The morgue file will list what the player killed, which is part of any run's story. But the game hasn't counted it. This part counts the kills, by kind of monster, and keeps the count in the save. That way, a run continued later still has it.
A Count for Each Kind
The kills are a number for each kind of monster, kept by the kind's id. That's just what a std::map is for.
A map holds pairs, each a key and its value, here an id and a count. It keeps them in the order of their keys. Asked with [] for a key it doesn't have, it adds the key, with a value of 0. So the first kill of a kind needs no special care. Open Game.h, and add the map's header above #include <optional>:
#include <map> // std::map, for the kills, by kind
Add the function that counts a kill below score's declaration:
void countKill(const Monster& monster);
And add the kills themselves below command_, with a blank line in between:
// The monsters the player has killed, by kind: each kind's id, and how
// many. A std::map keeps them in the order of their ids
std::map<std::string, int> kills_;
In Game.cpp, add countKill below score:
// A monster the player has killed, counted by kind, for the morgue
void Game::countKill(const Monster& monster)
{
++kills_[monster.def->id];
}
The entry kills_[monster.def->id] is the count for the monster's kind, which the map makes 0 if the kind is new. Then ++ adds one to it.
Every Kill
The player kills in four ways: with a blow, a missile, a bolt of lightning, or a burst of fire. Each of them already shows a kill, or tells a monster that lived through it about the player. So the count goes beside that. In playerAttacks, add this line directly below showKilled(monster);:
countKill(monster);
In shootMissile, add this one directly below showKilled(*target);:
countKill(*target);
Lightning and fire alarm a monster that lives through them, and now they count one that doesn't. In castLightning, add this below the if that calls alarm(*monster);:
else
{
countKill(*monster);
}
In castFireball, add the same lines below its own if that calls alarm(*monster);:
else
{
countKill(*monster);
}
A monster caught in a dragon's fire dies all the same, but it isn't the player's kill, and isn't counted.
Kept in the Save
A saved run must keep its kills, or a run continued tomorrow would have forgotten them. They're a new key in the save, which makes it a new version. So an older save is never misread. Open SaveGame.cpp, and find these lines:
// 5 the bow and the quiver (Chapter 21), and version 6 the player's
// class (Chapter 22)
constexpr int SAVE_VERSION = 6;
Change them to these:
// 5 the bow and the quiver (Chapter 21), version 6 the player's class
// (Chapter 22), and version 7 the player's kills (Chapter 26)
constexpr int SAVE_VERSION = 7;
The kills go in the [run] record, on one line: each kind's id, a colon and its count, as in kills = kobold:3 jackal:2. That's the shape classes.txt gives a kit's arrows, arrow:30. A run with no kills has no line at all. In writeSave, add this directly below addField(text, "dice", rng_.state());:
if (!kills_.empty())
{
// Each kind's id, a colon, and how many, as in "kobold:3 jackal:2"
std::string kills;
for (const auto& [id, count] : kills_)
{
kills += (kills.empty() ? "" : " ") + id + ":" +
std::to_string(count);
}
addField(text, "kills", kills);
}
A structured binding, const auto& [id, count], takes each of the map's pairs apart, as Chapter 17's nameOf does. The key is named id, and the value count. The conditional operator puts a space before every kind but the first. Its result is a plain const char*. But adding the std::string called id to it makes a std::string, which the rest can be added to.
Reading the line back takes a function of its own, in two parts. Add the first in the unnamed namespace, below readSeed:
// The kills a "kills" value names, as in "kobold:3 jackal:2": each
// kind of monster by its id, and after a colon, how many
std::map<std::string, int> readKills(FieldReader& fields,
const GameData& data)
{
std::map<std::string, int> kills;
int line = fields.lineOf("kills");
std::istringstream words(fields.text("kills"));
std::string word;
while (words >> word)
{
size_t colon = word.find(':');
std::string id = word.substr(0, colon);
int count = 0;
bool counted = colon != std::string::npos &&
readWholeNumber(word.substr(colon + 1), count) &&
count >= 1;
The value is split into words with a std::istringstream, as a kit is, and each word at its colon. The id is everything before the colon, or the whole word, if there's no colon. The count is good only if there's a colon, then a whole number, and the number is 1 or more. Add the rest of the function below it:
if (data.findMonster(id) == nullptr)
{
fields.report(line, "no kind of monster is called '" + id +
"'");
}
else if (!counted)
{
fields.report(line, "'" + id + "' needs a count of 1 or "
"more after its colon");
}
else
{
kills[id] = count;
}
}
return kills;
}
Each mistake is reported with the line the kills are on. Any mistake means the save is damaged, as a mistake in any other key does.
The kills are read into a map of their own. They replace the run's only once everything has been read, as the rest of a saved run does. In loadSave, add this line directly below int turn = 0;:
std::map<std::string, int> kills;
Read them in the [run] record's branch, directly below the if that checks the dice:
if (fields.has("kills"))
{
kills = readKills(fields, data_);
}
Then add this line directly below turn_ = turn;, near the end of loadSave:
kills_ = kills;
A save without the line, from a run that killed nothing, leaves the map empty.
Checkpoint: Press F5, with seed 23. The title may offer a run saved by Chapter 25's game: press N for a new one, since that save is version 6, and this game reads version 7. Start the run as the Fighter, take the six steps down with j, and fight the jackals. Wait twice with the period, then strike down and to your right with n, down with j, wait once more, and strike down and to your right with n again. That's all three dead, so press Esc, and S to save and quit.
Open save.txt, in the folder %APPDATA%\GameCodeSchool\AmuletsOfSDL. Its [save] record says version = 7, and its [run] record ends with kills = jackal:3.
Now damage it: turn jackal:3 into jackel:3, and save the file. Press F5, and C to continue. The title says the saved run is damaged, and why: save.txt:11: no kind of monster is called 'jackel'. It's set aside as save.bad, as any damaged save is. So press N for a new run.
The Morgue File
The third part writes a run's story to a file when it ends: who played, how the run ended, what the player carried, what they killed, the last few messages, and the floor as far as they'd seen it. It's known as a morgue file, the name Dungeon Crawl gives it, since most runs end in death.
Words to Share
The morgue file needs two of Game.cpp's small helpers: withArticle, which puts "a" or "an" before a name, and capitalized. They're in Game.cpp's unnamed namespace, which no other file can see. So they move into files of their own, and listed, the other helper with words, goes with them. In Solution Explorer, right-click src, choose Add > New Item, and add a Header File (.h) called Words.h. Type this into it:
#pragma once
#include <string> // std::string, for the words
#include <vector> // std::vector, for a list of them
// Small helpers with the game's words, which the game and its morgue file
// share
// The name with "a" or "an" before it: "a jackal", but "an ogre", and "an
// Archer"
std::string withArticle(const std::string& name);
// The same text, with its first letter a capital, to start a sentence
std::string capitalized(std::string text);
// Words as a list: "a dagger, leather armor and a short bow"
std::string listed(const std::vector<std::string>& words);
Each helper is declared here, below its comment, and defined in Words.cpp. Outside an unnamed namespace, a function can be called from any file that includes its declaration. Add a C++ File (.cpp) called Words.cpp, and type its first two functions:
#include "Words.h"
#include <SDL3/SDL.h>
std::string withArticle(const std::string& name)
{
const std::string vowels = "aeiou";
bool vowel = !name.empty() &&
vowels.find(static_cast<char>(SDL_tolower(name[0]))) !=
std::string::npos;
return (vowel ? "an " : "a ") + name;
}
std::string capitalized(std::string text)
{
if (!text.empty())
{
text[0] = static_cast<char>(SDL_toupper(text[0]));
}
return text;
}
In the preceding code, withArticle has changed a little on the way. It lowers the name's first letter with SDL_tolower before looking for it among the vowels. So a class whose name starts with a capital vowel, such as an Archer, gets "an" too. Add listed below them:
std::string listed(const std::vector<std::string>& words)
{
std::string text;
for (size_t i = 0; i < words.size(); ++i)
{
if (i > 0)
{
text += i + 1 == words.size() ? " and " : ", ";
}
text += words[i];
}
return text;
}
The old copies go from Game.cpp. In Game.cpp, delete everything from // Words as a list down to the closing brace of listed, and the blank line above it. Then delete everything from // The name with "a" or "an" before it down to the closing brace of withArticle, and the blank line above it. Last, delete everything from // The same text, with its first letter a capital down to the closing brace of capitalized, and the blank line above it.
The game still calls all three, and finds them through their header now. Add it to Game.cpp, below #include "RoomGen.h":
#include "Words.h"
Nothing else in Game.cpp changes, since the calls are the same calls.
The End of a Run
Every run's end writes its morgue file, by death or by victory, so it's written in endRun. The morgue file's two functions are the Game's own, since they read most of it. But they go in a file of their own, Morgue.cpp, as the save's went in SaveGame.cpp. Open Game.h, and add their declarations below countKill's, with a blank line in between:
// The morgue file, in Morgue.cpp
void writeMorgue();
std::string morgueText() const;
The Game keeps the file's name, which stays empty if the file can't be written. Then the end screens can tell whether there's a file to mention. Add this below kills_, with a blank line in between:
// The morgue file written when the run ended, for the end screens
std::string morgueName_;
In Game.cpp, in endRun, add the line writeMorgue(); below removeSave();. The call to removeSave stays first, so the run can't be continued, whatever happens to the morgue file.
The File's Name
Add a C++ File (.cpp) called Morgue.cpp. It starts with a comment that says what it holds, and its includes:
// The morgue file: a run's story, written when it ends, by death or by
// victory. It's plain text, in the player's own folder, under morgue,
// named by the moment the run ended, so every run keeps its own. These
// are Game's own functions, in a file of their own, as the save's are
#include "Game.h"
#include <algorithm> // std::replace, for the folder's slashes
#include <string> // std::string, for the file's text
#include <vector> // std::vector, for the rows of the map
#include "Files.h"
#include "Words.h"
The file ends with a map of the floor, drawn in the glyph view's characters, # for a wall and . for a floor. So it needs a function that gives each kind of terrain its character. Add it below the includes, with a blank line in between, in an unnamed namespace with the number of messages the file keeps:
namespace
{
// How many of the last messages the morgue file keeps
constexpr int LAST_MESSAGES = 10;
// The character a cell's terrain is in the glyph view
char terrainGlyph(Terrain terrain)
{
switch (terrain)
{
case Terrain::Floor:
case Terrain::CaveFloor:
return '.';
case Terrain::StairsDown:
return '>';
case Terrain::ClosedDoor:
return '+';
case Terrain::OpenDoor:
return '\'';
default:
return '#';
}
}
}
In the preceding code, a secret door is a #, since it's a wall until it's found. An open door is a ', as the glyph view draws it. The ' is written '\'', with a backslash, since a plain ' would end the character before it began.
Writing the file comes first. Its name is the moment the run ended, to the second. So every run keeps a file of its own, and the files sort in the order the runs ended. Add writeMorgue below the unnamed namespace:
// Writes the morgue file, and keeps its name, for the end screens. SDL
// gives the date and time, on the player's own clock. A run that can't be
// written down is logged, and the game goes on: it's over anyway
void Game::writeMorgue()
{
SDL_Time now = 0;
SDL_DateTime when;
if (!SDL_GetCurrentTime(&now) || !SDL_TimeToDateTime(now, &when, true))
{
SDL_Log("No morgue file: %s", SDL_GetError());
return;
}
char name[64];
SDL_snprintf(name, sizeof(name), "morgue/%04d-%02d-%02d-%02d%02d%02d.txt",
when.year, when.month, when.day, when.hour, when.minute,
when.second);
if (!SDL_CreateDirectory(userPath("morgue").c_str()) ||
!writeTextFile(userPath(name), morgueText()))
{
SDL_Log("No morgue file: %s", SDL_GetError());
return;
}
morgueName_ = name;
}
In the preceding code, SDL_GetCurrentTime gives the time now, as an SDL_Time, a count of nanoseconds since 1970. And SDL_TimeToDateTime turns it into a date and a time of day. Its true asks for the player's own time zone, rather than UTC, the world's standard time.
Then SDL_snprintf writes the name into the array name, as printf would print it, but never more than the array holds. Each %02d is a whole number, two digits wide, with a zero in front if it needs one, and %04d the same, four digits wide. So a file is called something like morgue/2026-10-19-210805.txt.
The folder is made by SDL_CreateDirectory, which succeeds without doing anything if it's there already. The file is written by Chapter 17's writeTextFile. Either can fail, on a full disk, say. Then the reason is logged, and there's no file. That doesn't stop anything: the run is over anyway.
The Run's Story
The story is one long string, built a section at a time. The first section says who played, how the run ended, and its score. Add the start of morgueText below writeMorgue:
// The run's story: who, how it ended, and its score and seed; the player
// at the end, and their pack, with everything in it named; their kills, by
// kind; the last messages; and the floor, as far as they'd seen it
std::string Game::morgueText() const
{
std::string text = "Amulets of SDL: the story of a run\n\n";
std::string who = withArticle(player_.classDef->name);
if (state_ == GameState::Won)
{
text += capitalized(who) + ", who found all three Amulets of SDL, "
"and won, on turn " + std::to_string(turn_) + ".\n";
}
else
{
text += capitalized(who) + ", killed by " + player_.killedBy +
" on floor " + std::to_string(level_.depth) + ", on turn " +
std::to_string(turn_) + ".\n";
}
text += "A score of " + std::to_string(score()) + ", with " +
std::to_string(player_.gold) + " gold and " +
std::to_string(player_.amulets) + " of " +
std::to_string(AMULETS) + " Amulets. The seed was " +
std::to_string(seed_) + ".\n\n";
In the preceding code, withArticle makes the class's name "a Fighter", and capitalized makes it "A Fighter", to start a sentence. The seed is there so the same dungeon can be played again, with the seed in Command Arguments.
The player comes next, as the side panel shows them: their health, strength, defense and damage, and what they had in hand, on their back, and ready to shoot. Add this below the line with the seed, with a blank line in between:
int low = player_.minDamage() + player_.strength;
int high = player_.maxDamage() + player_.strength;
text += "Health " + std::to_string(player_.hp) + " of " +
std::to_string(player_.maxHp) + ", strength +" +
std::to_string(player_.strength) + ", defense " +
std::to_string(player_.defense) + ", damage " +
std::to_string(low) + "-" + std::to_string(high) + ".\n";
text += wieldingText() + ". " + wearingText() + ". " + bowText() +
".\n\n";
The damage is worked out as the side panel works it out, with the strength added to it. And wieldingText, wearingText and bowText are the side panel's own lines, each made a sentence with a period.
The pack is listed with its letters, as the inventory lists it, but with every potion and scroll by its true name. It no longer matters what the player knew. Add this below it, with a blank line in between:
// The pack, everything in it named, now that it doesn't matter
text += "The pack:\n";
const std::vector<Item>& items = player_.inventory.items();
for (size_t i = 0; i < items.size(); ++i)
{
text += " " + std::string(1, Inventory::letterFor(
static_cast<int>(i))) +
") " + itemName(items[i], true) + "\n";
}
if (items.empty())
{
text += " nothing\n";
}
The function itemName is given true for its second argument, trueName, which Chapter 22 gave it for the class menu. And std::string(1, ...) is a string of one character, the item's letter, which the rest can be added to.
The kills come next, a line for each kind of monster, with the names in a column and each count after its name. Add this below the pack, with a blank line in between:
// The kills, each kind's name and how many, in a column
int total = 0;
size_t widest = 0;
for (const auto& [id, count] : kills_)
{
total += count;
widest = SDL_max(widest, data_.findMonster(id)->name.size());
}
text += "\nKills, " + std::to_string(total) + " in all:\n";
for (const auto& [id, count] : kills_)
{
std::string name = data_.findMonster(id)->name;
name.resize(widest, ' ');
text += " " + name + " " + std::to_string(count) + "\n";
}
The first loop adds up the kills and finds the longest name, and the second writes a line for each kind. Each name is made as long as the longest by resize, which adds the character it's given, here a space, to the end of a string. So every count starts in the same column. The map keeps the kinds in the order of their ids, so the kinds come in alphabetical order.
The last ten messages tell how the run ended, in the game's own words. Add this below the kills, with a blank line in between:
// The last messages, as the log shows them
text += "\nThe last messages:\n";
const std::vector<MessageLog::Entry>& entries = log_.entries();
size_t first = entries.size() > LAST_MESSAGES
? entries.size() - LAST_MESSAGES : 0;
for (size_t i = first; i < entries.size(); ++i)
{
text += " " + entries[i].withCount() + "\n";
}
The first message written is ten from the end, or the very first, in a run with fewer than ten. Each is written as the log shows it, with its count, as in "You kill the jackal! x2".
The Floor, in Text
Last comes the floor: everything the player had explored, the traps they'd found, the items they'd seen, the monsters in sight, and the player. It starts as a grid of spaces, a string for each row. The explored cells are drawn into it. Add this below the messages, with a blank line in between:
// The floor, as the player had seen it, drawn as the glyph view draws
// it, with the traps they knew of, the items they'd seen, and the
// monsters in sight, cut down to the part with anything in it
std::vector<std::string> rows(MAP_H, std::string(MAP_W, ' '));
for (int y = 0; y < MAP_H; ++y)
{
for (int x = 0; x < MAP_W; ++x)
{
Terrain terrain = level_.map.at(x, y);
bool wall = terrainGlyph(terrain) == '#';
bool shown = level_.map.isExplored(x, y) &&
(!wall || level_.map.bordersFloor(x, y));
if (shown)
{
rows[y][x] = terrainGlyph(terrain);
}
}
}
The vector rows starts as MAP_H strings, each MAP_W spaces long. A wall is drawn only if it borders a floor, as in the glyph view, so the solid rock between the rooms stays blank.
The traps, the items and the monsters are drawn over the floor, and the player over everything. Add this below it:
for (const Trap& trap : level_.traps)
{
if (trap.known)
{
rows[trap.pos.y][trap.pos.x] = '^';
}
}
for (const FloorItem& floorItem : level_.items)
{
if (floorItem.seen)
{
Point cell = floorItem.pos;
rows[cell.y][cell.x] = itemLook(floorItem.item).glyph;
}
}
for (const Monster& monster : level_.monsters)
{
bool seen = level_.map.isVisible(monster.pos.x, monster.pos.y);
if (seen && monster.isAlive())
{
rows[monster.pos.y][monster.pos.x] = monster.def->glyph;
}
}
rows[player_.pos.y][player_.pos.x] = '@';
In the preceding code, a cell is picked out with its row first, as in rows[y][x]. Each row is a string, and x picks a character in it. An item is drawn with the character itemLook gives it in the glyph view, and a monster with its kind's glyph.
The whole floor is 64 cells across and 36 down, and a run that ended early saw little of it. So the file keeps only the rows and the columns with anything in them. Add this below it, with a blank line in between:
// The rows and columns with anything in them: the player at least
int top = MAP_H;
int bottom = 0;
int left = MAP_W;
int right = 0;
for (int y = 0; y < MAP_H; ++y)
{
for (int x = 0; x < MAP_W; ++x)
{
if (rows[y][x] != ' ')
{
top = SDL_min(top, y);
bottom = SDL_max(bottom, y);
left = SDL_min(left, x);
right = SDL_max(right, x);
}
}
}
The four sides start the wrong way around: top at the bottom and bottom at the top, and the same for left and right. So the first cell with anything in it sets all four. Every cell after it can only push them further out. The player is always there, so at least one cell is found. The next image shows the four sides around a floor.

Last, the rows inside the four sides are written, each cut to the columns between them. Add this below it:
text += "\nThe floor, as far as it had been seen:\n";
for (int y = top; y <= bottom; ++y)
{
std::string line = rows[y].substr(left, right - left + 1);
line.erase(line.find_last_not_of(' ') + 1);
text += " " + line + "\n";
}
return text;
}
The function substr takes the columns from left to right from each row. Then find_last_not_of finds the last character that isn't a space, and erase cuts off everything after it. So no line ends in spaces.
The End Screens
Both end screens say that the run's story is written, a line below the score. They don't say where, though. The file's full path starts with the folder SDL chose for the player's own files, in a part of the disk that File Explorer hides, and on Windows, that path has the player's user name in it. It's too long for one line, and it would show the name to anyone who sees the screen.
So the line offers a key instead, O, which opens the morgue folder, with the new file in it. In Game.cpp, in drawDeathScreen, add these lines directly above the line that draws "Press R to play again, or Esc to quit.":
if (!morgueName_.empty())
{
drawCentered("The run's story is in your morgue folder: press O to "
"open it.", centerX, y, Palette::DIM_TEXT);
}
y += row;
A file that couldn't be written leaves morgueName_ empty, and the line blank. The victory screen gets the same lines, copied. From drawDeathScreen, copy everything from if (!morgueName_.empty()) down to the y += row; below it, into drawVictoryScreen, directly above its own line that draws "Press R to play again, or Esc to quit.".
The new line is the longest either screen has had, 61 characters, which is too wide for the dialog's 600 pixels. So the dialog grows, wider, and a row taller for the new line. In drawEndDialog, change 600.0f, 7 * to 700.0f, 8 *.
The O key goes to handleEndKey, with the screen's other keys, but it does something only when there's a file to show. In handleEndKey, add this below the if that handles R:
else if (key.key == SDLK_O && !morgueName_.empty())
{
openMorgueFolder();
}
The comment above handleEndKey needs no change. It says that each key the screen names does what it says, and the screen names O only when there's a file to open.
Opening the folder is the morgue's job, so its function goes with the morgue file's. In Game.h, add the line void openMorgueFolder() const; below std::string morgueText() const;. Then add the function to Morgue.cpp, below writeMorgue:
// O, on an end screen: opens the morgue folder, with whatever the system
// opens folders with. SDL_OpenURL takes a file:// address, which has
// forward slashes, a slash before a Windows drive's letter, and %20 for a
// space, as in a Mac's Application Support folder
void Game::openMorgueFolder() const
{
std::string path = userPath("morgue");
std::replace(path.begin(), path.end(), '\\', '/');
if (path.empty() || path.front() != '/')
{
path = "/" + path;
}
std::string url = "file://";
for (char c : path)
{
if (c == ' ')
{
url += "%20";
}
else
{
url += c;
}
}
if (!SDL_OpenURL(url.c_str()))
{
SDL_Log("Couldn't open the morgue folder: %s", SDL_GetError());
}
}
The function SDL_OpenURL asks the system to open an address, as if the player had clicked a link to it. An address that starts with https:// opens in the web browser. One that starts with file:// names a file or a folder on the computer itself. The system opens it with whatever opens such things, which for a folder on Windows is File Explorer. So the morgue folder opens in a window of its own, beside the game.
A path isn't an address, though. An address separates its parts with forward slashes, where a Windows path has backslashes, so std::replace turns each backslash into a forward slash. It's what <algorithm> was included for, at the top of Morgue.cpp. The address's path must also start with a slash, and a Windows path starts with its drive's letter. So a slash goes in front, and the address comes out as something like file:///C:/Users/you/AppData/Roaming/GameCodeSchool/AmuletsOfSDL/morgue.
A path that already starts with a slash, as on a Mac or on Linux, is left as it is. An address can't hold a space, either, and a Mac keeps the game's folder under Application Support, so the loop that builds url writes each space as %20, the code that stands for one. If the folder can't be opened, SDL_OpenURL returns false, and the reason is logged. Nothing else changes, since the file is there either way.
Design note: Crawl's morgue. Dungeon Crawl Stone Soup writes a morgue file when a game ends, in a folder of its own, and the end of the game says where, below the score. What goes in it is up to the player: an option,
dump_order, lists its sections, twenty of them unless the player says otherwise. Among them are the inventory, the last messages, a picture of the screen, and the monsters killed. It keeps twenty messages, unlessdump_message_countsays how many. And it keeps notes, which the game logs as it's played, such as each floor entered for the first time.
Ours keeps less, and always the same sections, which is plenty for a game of nine floors.
Checkpoint: Put the first checkpoint's two lines for the Amulets back in startAs for now, player_.amulets = 2; and the line that puts the third at your feet, but not the health. Press F5, with seed 23, start a new run as the Fighter, and kill the three jackals, as at the last checkpoint. Then walk back up to the Amulet, with k six times, and pick it up. The victory screen says that the run's story is in your morgue folder. Press O, and the folder opens in File Explorer, with the new file in it, named for the moment you won.
The folder is morgue, in %APPDATA%\GameCodeSchool\AmuletsOfSDL, beside the save. Open the file, in Notepad or in Visual Studio, and it begins like this:
Amulets of SDL: the story of a run
A Fighter, who found all three Amulets of SDL, and won, on turn 19.
A score of 3550, with 0 gold and 3 of 3 Amulets. The seed was 23.
Health 31 of 32, strength +1, defense 4, damage 4-10.
Wielding a long sword. Wearing chain mail. No bow ready.
The pack:
a) a potion of healing
Kills, 3 in all:
jackal 3
The last messages:
Move with the arrow keys, the numpad, or h j k l y u b n.
The jackal wakes up! x3
The jackal hits you for 1.
You kill the jackal!
The jackal hits you for 1.
You kill the jackal! x2
You see the Amulet of SDL here.
You pick up the Amulet of SDL.
You have all three Amulets!
You have won!
It ends with the floor: the room where the run began, and won, the jackals' room below it, and the open door you walked through on the way down.
The floor, as far as it had been seen:
#################
#...............#
#...............#
#...............##
#.......@.......'.
#...............##
#...............#
#...............#
########'##..####
#.## .###
#.......#
#.......#
#.......#
#.......#
#.$.....#
#.......#
#'###'###
#.# .#
#.# #
#.#
Take the two lines out of startAs.
High Scores
The last part keeps the ten best runs in a table, so that every run has something to aim at, even one that won't win. The end screens show the table, with the run that just ended in gold. So does the title screen, once there's a run in it.
A Table of the Best
The high scores are a class of their own, HighScores. It reads and writes scores.txt, in the player's own folder, beside the save. Add a Header File (.h) called HighScores.h, and type its first part, one run's line in the table:
#pragma once
#include <string> // std::string, for a run's class and fate
#include <vector> // std::vector, for the scores
// One run's line in the high scores
struct HighScore
{
int score = 0;
std::string className; // as in Fighter
std::string fate; // "won", or "killed by a jackal on floor 3"
int turns = 0;
};
Add the class below it, with a blank line in between:
// The best runs, best first, at most KEPT of them, kept in scores.txt in
// the player's own folder
class HighScores
{
public:
static constexpr int KEPT = 10;
// Reads scores.txt. With no file yet, there are no scores. A mistake
// is logged, and that score is left out: scores aren't worth stopping
// the game for
void load();
// Adds a run, if it's one of the best. Returns its place, from 0 for
// the best, or -1 if it didn't make the list
int add(const HighScore& run);
// Writes scores.txt. Returns false if it couldn't
bool save() const;
const std::vector<HighScore>& all() const;
private:
std::vector<HighScore> scores_;
};
In the preceding code, KEPT is a static constexpr member, as the sound bank's STREAMS is: one number for the whole class, known to the compiler. The scores are kept best first, and all hands them out as a reference to const. So the screens can read them without copying them, and can't change them.
Now add a C++ File (.cpp) called HighScores.cpp, and type its includes and the file's name:
#include "HighScores.h"
#include <SDL3/SDL.h>
#include "Files.h"
#include "Records.h"
namespace
{
const std::string SCORES_FILE = "scores.txt";
}
The file is made of the records the data files use, a [score] record for each run. So load reads it with readRecords and a FieldReader, as loadSettings reads settings.txt. Add the start of load below the unnamed namespace:
void HighScores::load()
{
scores_.clear();
std::string text;
if (!readTextFile(userPath(SCORES_FILE), text))
{
return;
}
std::vector<std::string> errors;
for (const Record& record : readRecords(text, SCORES_FILE, errors))
{
FieldReader fields(record, SCORES_FILE, errors);
if (record.type != "score")
{
fields.report(record.line, "unknown record [" + record.type +
"]");
continue;
}
Then add the rest of it:
HighScore run;
run.score = fields.number("score", 0);
run.className = fields.text("class");
run.fate = fields.text("fate");
run.turns = fields.number("turns", 0);
if (fields.finish())
{
add(run);
}
}
for (const std::string& error : errors)
{
SDL_Log("%s", error.c_str());
}
}
A record with a mistake is logged and left out, since a damaged table of scores isn't worth stopping the game for. Each good one goes in through add. It keeps the table in order, and no longer than KEPT, whatever the file holds.
A run goes in below every score at least as good as its own. So of two runs with the same score, the earlier keeps the higher place, which seems only fair: it got there first. Add add below load:
// The run goes after every score at least as good as its own, so of two
// runs that scored the same, the earlier keeps the higher place. The list
// is then cut back to KEPT
int HighScores::add(const HighScore& run)
{
size_t place = 0;
while (place < scores_.size() && scores_[place].score >= run.score)
{
++place;
}
if (place >= KEPT)
{
return -1;
}
scores_.insert(scores_.begin() + place, run);
if (scores_.size() > KEPT)
{
scores_.pop_back();
}
return static_cast<int>(place);
}
In the preceding code, place counts the scores at least as good as the run's. If there are KEPT of them, the run didn't make the list, and add says so with -1. Otherwise the run is inserted at its place, and the last run is dropped, if the table now holds one too many. The next image shows a run of 2194 going into a full table.

The new run goes in below the 2194 already there, and the 1823 at the bottom drops off.
Last, save writes the table, and all hands it out. Add them below add:
bool HighScores::save() const
{
std::string text = "# Amulets of SDL's high scores, best first. The game "
"writes this file\n# when a run ends.\n";
for (const HighScore& run : scores_)
{
text += "\n[score]\n";
text += "score = " + std::to_string(run.score) + "\n";
text += "class = " + run.className + "\n";
text += "fate = " + run.fate + "\n";
text += "turns = " + std::to_string(run.turns) + "\n";
}
return writeTextFile(userPath(SCORES_FILE), text);
}
const std::vector<HighScore>& HighScores::all() const
{
return scores_;
}
The file starts with a comment, as the data files do. So anyone who opens it can see what it is, and that the game writes it.
Entering a Score
The Game keeps the table, and the place its own run took in it, so the end screens can show that run in gold. Open Game.h, and add the header below #include "GlyphView.h":
#include "HighScores.h"
Add the function that enters the run's score below endRun's declaration:
void enterScore();
Then add the table, and the run's place in it, below morgueName_, with a blank line in between:
// The best runs so far, and this run's place among them, once it's
// ended, if it made the list
HighScores highScores_;
int scorePlace_ = -1;
Every Game reads the table as it's made, so the title screen can show it. In Game.cpp, in the constructor, add this line directly below sounds_.setVolume(settings_.volume, settings_.muted);:
highScores_.load();
The run's end enters its score, after the morgue file. In endRun, add the line enterScore(); below writeMorgue();. Then add enterScore below endRun:
// The run's score goes in the high scores, if it's one of the best, and
// they're saved at once
void Game::enterScore()
{
HighScore run;
run.score = score();
run.className = player_.classDef->name;
run.fate = "won";
if (state_ == GameState::Dead)
{
run.fate = "killed by " + player_.killedBy + " on floor " +
std::to_string(level_.depth);
}
run.turns = turn_;
scorePlace_ = highScores_.add(run);
if (scorePlace_ >= 0)
{
highScores_.save();
}
}
In the preceding code, a run's fate is "won", or what killed the player, and where, in the death screen's words. A run that made the list is saved at once, since the player may close the game from the end screen.
The Table on the Screen
The end screens show the table below the run's own lines, in a dialog wide enough for its longest lines. Open Game.cpp, and add the dialog's width below REGENERATION, with a blank line in between:
// The end screens' dialog is wide enough for the high scores
constexpr float SCORES_DIALOG_W = 860.0f;
The dialog grows once more, to fit the table: wider, and taller by a row for each run in it. In drawEndDialog, find this line:
return drawDialog(700.0f, 8 * glyphs_.lineHeight());
Change it to these:
int scores = SDL_max(1, static_cast<int>(highScores_.all().size()));
return drawDialog(SCORES_DIALOG_W, (12 + scores) * glyphs_.lineHeight());
The dialog has twelve rows for the screen's own lines, the table's heading, and the spaces between them. It also has a row for each run in the table, or one for "None yet.", if it's empty.
The table itself is drawn by one more function, drawHighScores, which the end screens and the title share. It draws the table from a height on the screen, y, downward. Then it returns the height below it, where the screen's next line goes. In Game.h, add its declaration below drawTitle's:
float drawHighScores(float y) const;
On each end screen, the table goes below the line about the morgue folder, with a row of space above it. In drawDeathScreen, add these lines directly below the if that draws that line:
y += 2 * row;
y = drawHighScores(y);
The victory screen gets the same two lines, copied. From drawDeathScreen, copy everything from y += 2 * row; down to y = drawHighScores(y);, into drawVictoryScreen, directly below its own if that draws the line about the morgue folder.
The title screen shows the table below its keys, but only if there's room. A saved run that couldn't be loaded fills the screen with its reasons, and leaves none. In drawTitle, add this at the end, below the loop that draws the keys, with a blank line in between:
// The best runs so far, if there are any, and if there's room: the
// reasons a saved run couldn't be loaded leave none
if (!highScores_.all().empty() && loadErrors_.empty())
{
drawHighScores(y + 2 * row);
}
On the title screen, no run has just ended, so scorePlace_ is -1, and no line is gold.
Now drawHighScores itself, in three parts. The first draws the heading, or says there's nothing to show yet. In Game.cpp, add it below drawTitle:
// The high scores, from y down, in the middle of the map view, or of the
// window on the title screen: a heading, and each run's place, score,
// class and fate, as a block lined up on the left. The run just ended is
// in gold, if it made the list. Returns where the next line would go
float Game::drawHighScores(float y) const
{
float row = glyphs_.lineHeight();
float centerX = state_ == GameState::Title ? SCREEN_W / 2.0f
: MAP_VIEW_W / 2.0f;
drawCentered("HIGH SCORES", centerX, y, Palette::TEXT);
y += 2 * row;
const std::vector<HighScore>& scores = highScores_.all();
if (scores.empty())
{
drawCentered("None yet.", centerX, y, Palette::DIM_TEXT);
return y + row;
}
Each run's line is made next, with its place, its score and its class lined up in columns. Add this below it:
size_t widestClass = 0;
for (const HighScore& run : scores)
{
widestClass = SDL_max(widestClass, run.className.size());
}
std::vector<std::string> lines;
for (size_t i = 0; i < scores.size(); ++i)
{
const HighScore& run = scores[i];
std::string place = std::to_string(i + 1) + ".";
std::string points = std::to_string(run.score);
std::string className = run.className;
place.insert(0, 3 - SDL_min(size_t(3), place.size()), ' ');
points.insert(0, 6 - SDL_min(size_t(6), points.size()), ' ');
className.resize(widestClass, ' ');
lines.push_back(place + points + " " + className + " " +
run.fate + ", on turn " +
std::to_string(run.turns));
}
The place is made three characters wide, and the score six, with spaces put in front of them. So the numbers line up on the right. The spaces are three, or six, less the number's length. And SDL_min keeps that from going below zero for a number that's already as long, or longer.
A size_t can't be negative, so 3 minus 4 wouldn't be -1 but a huge number. Then insert would try to put that many spaces in. The class is padded on its right, to the longest class's length, as the morgue file pads the kills' names.
Last, the lines are drawn as a block, lined up on the left and centered as a whole, with the run that just ended in gold. Add the rest below it:
size_t widest = 0;
for (const std::string& line : lines)
{
widest = SDL_max(widest, line.size());
}
float x = centerX - widest * glyphs_.charWidth() / 2.0f;
for (size_t i = 0; i < lines.size(); ++i)
{
bool thisRun = static_cast<int>(i) == scorePlace_;
glyphs_.drawText(lines[i], x, y,
thisRun ? Palette::GOLD : Palette::DIM_TEXT);
y += row;
}
return y;
}
The member scorePlace_ is the run's place, from enterScore, or -1, which matches no line. The function returns y below the last line, so the end screens can put their last line below the table.
Checkpoint: Put the first checkpoint's three lines back in startAs for now: two Amulets, 1 health, and the third Amulet at your feet. Press F5, with seed 23, start a new run as the Fighter, and win it with g. The victory screen's table has one run in it, yours, in gold. Press Esc to quit, and F5 again: the title screen shows the table below its keys.
Press N, and choose the Mage this time. Leave the Amulet where it is, take the six steps down to the jackals, and wait with the period until the first bite kills you. The death screen shows both runs, the Mage's below the Fighter's, and in gold. The next image shows it.

Press Esc, and F5 once more. The title screen lists both runs, and neither is in gold, since neither has just ended. Take the three lines out of startAs, and play a real run or two, to see where they land.
Playing the Game
The game can be won now, and every run is worth something, so it's worth playing for the score. Gold counts, so it's worth the walk to pick it up. Every floor deeper is worth 50. The Amulets count most of all: each is worth 500, more than all nine floors together. And a win is worth 2,000 more.
When a run ends, the morgue file is waiting. It's the place to find out what really happened: what was in the pack that might have saved you, the messages just before the end, and the map of the floor you died on. The potions you never drank are there by their true names, which can be a hard thing to read after the fact.
The table of high scores fills up as you play, and then it gets harder to join. A run that makes the list is in gold on its end screen, and the title screen shows the best ten each time the game starts.
Understanding the Code
A run ends by death, in finishTurn, or by victory, when pickUp counts the third Amulet and calls win, before any monster acts. The victory has a state of its own, GameState::Won, and handleEndKey takes the keys for both end screens. Both ends call endRun, which removes the save, writes the morgue file and enters the score. The score is the gold, SCORE_PER_FLOOR for each floor, SCORE_PER_AMULET for each Amulet and SCORE_FOR_WIN for a win. Both drawDeathScreen and drawVictoryScreen show it, in a dialog from drawEndDialog, the same size for both.
The player's kills are counted by countKill, in a std::map from each kind's id to its count. They come from every blow, missile, bolt and burst that kills. The save keeps them in its [run] record, as kills = jackal:3, which makes it version 7. And readKills reads and checks them.
The morgue file is written by writeMorgue, in Morgue.cpp, in a folder made by SDL_CreateDirectory. It's named for the moment the run ended, with SDL_GetCurrentTime, SDL_TimeToDateTime and SDL_snprintf. Its text comes from morgueText: who played and how the run ended, the score and the seed, the player and their pack by true names, the kills in a column, the last ten messages, and the floor in the glyph view's characters, cut down to the rows and columns with anything in them. The helpers withArticle, capitalized and listed moved into Words, so Game.cpp and Morgue.cpp can share them. In place of the file's path, the end screens offer O, which calls openMorgueFolder to hand the folder to SDL_OpenURL as a file:// address.
The HighScores class keeps the best KEPT runs, best first, in scores.txt, in [score] records read by load and written by save. Its add puts a run below every score at least as good, and says where it went, or -1. The Game loads the table as it's made, and enterScore adds the run that just ended, saving the table if the run made it. The end screens' dialog, from drawEndDialog, grows to fit it. And drawHighScores draws it, on the end screens and below the title's keys, with the run that just ended in gold, at scorePlace_.
Experimenting
Try each of these, and put each back afterward:
- A longer story. In
Morgue.cpp, setLAST_MESSAGESto100. Play a run to its end, and the morgue file holds up to the last hundred messages, as many as the log keeps: all of a short run's story. - The whole floor. In
morgueText, changebool shown = level_.map.isExplored(x, y) &&tobool shown =, so every cell is drawn, explored or not. End a run on the first floor, and its morgue file has the whole floor, rooms you never found and all. - Ties to the newer run. In
add, changescores_[place].score >= run.scoretoscores_[place].score > run.score. Put the first checkpoint's three lines back instartAs, and win twice. The second win goes above the first, with the same score, since a tie no longer keeps the earlier run's place.
The first shows how short ten messages are, and the second how much of a floor a run never sees. The third shows the one character that decides who keeps a tied place.
Common Errors and Fixes
If the game didn't build, or doesn't behave as described, the problem is probably one of these.
C2607: static assertion failed, in SoundEvent.h, on the line of the static_assert. It still names SoundEvent::Choose, which is no longer the last event. So the events and the names seem out of step. Change it to SoundEvent::Win.
C2662: 'int Game::score(void)': cannot convert 'this' pointer from 'const Game' to 'Game &', in drawDeathScreen, and again in drawVictoryScreen. The end screens are const, so they can only call const functions. But score was declared without const. Add const after score(), in Game.h and in Game.cpp.
C2668: 'withArticle': ambiguous call to overloaded function, in Game.cpp, many times, most of them with anonymous-namespace in the name, and the same for capitalized and listed, with C2088 after many of them. The helpers' old copies are still in Game.cpp's unnamed namespace, and Words.h declares the new ones. So the compiler can't tell which is meant. Delete the old copies, as the chapter said to when the helpers moved to Words.cpp.
C3861: 'withArticle': identifier not found, in Morgue.cpp, with the same for capitalized. The file doesn't include Words.h, so it has never heard of them. Add #include "Words.h" below #include "Files.h".
The victory screen is up, but R and Esc do nothing. The victory's keys never reach handleEndKey, since handleKey has no case GameState::Won:. Add it directly below case GameState::Dead:. Closing the window still works, since a won run isn't saved.
The high scores only ever show the run that just ended, and the title screen never shows them. The table is never read. So each run's end starts with an empty table, adds itself, and saves the table, over the old one. The constructor needs highScores_.load();, below the line that sets the volume. The runs lost before the fix are lost for good, since each save wrote a table of one.
FAQ
Q) Why does the third Amulet end the run at once, when Rogue makes a winner climb back up to the surface?
A) The game has no stairs up, and the climb would be eight floors more, with nothing on them left to find. Each would be made new, since a floor isn't kept once the player leaves it. Rogue's floors aren't kept either, so its way back up is a dungeon of its own, and part of its challenge. Ours makes finding the Amulets the whole job, so the third one ends the run.
Q) Why are the kills kept by each kind's id, rather than by its name?
A) The id is the kind's name in the data files, as in giant_white_rat, the same in every file that names it. It has no spaces, so a line of them can be split at its spaces. A name, such as "giant white rat", is for the player to read. The morgue file shows the names, looked up by their ids.
Q) Why is a morgue file named for the time, rather than numbered?
A) A number would have to be kept somewhere, and read and written at every run's end, for no gain. The time needs nothing kept, and puts the files in the order the runs ended, which is the order they're usually wanted in. It makes every name different, too, since no player ends two runs in the same second. A bot can, and Chapter 27 says what that costs.
Q) Can't a player just edit scores.txt, and put themselves at the top?
A) Yes, in a minute, with Notepad. A table of high scores on a player's own computer is a record of their own runs, kept for their own pleasure. They're welcome to spoil it. Games that compare players' scores keep them on a server the players can't edit, which is a different job.
AI Exercise (Optional)
Dungeon Crawl's morgue files keep notes, which the game logs as it's played, among them each floor entered for the first time. If you'd like the morgue file to say when the player reached each floor, with an AI's help, open your AI chatbot of choice, and try a prompt like this:
"I'm writing a roguelike in C++ with SDL 3. When a run ends, Game::morgueText() const builds a morgue file, a text file of the run's story, from the Game's members. The Game counts the player's kills in a std::map<std::string, int> kills_, and keeps them in its save, a text file of records, as one line in the [run] record, kills = kobold:3 jackal:2, read back by a function readKills, which reports any mistake. The save has a version number, SAVE_VERSION, now 7. The player goes down the stairs in Game::descend(), or falls through a trapdoor, and level_.depth is the floor they're on, from 1 to 9. I'd like the morgue file to list the turn on which the player reached each floor, and to keep that in the save, so a continued run doesn't forget it. Show me every change, with each curly brace on its own line, and explain it."
Notice what the preceding prompt does. It names both ways down, the stairs and the trapdoor, and it asks for the save. So the answer can't miss them.
When the answer comes back, check that it raises the save's version, since the save gains a key. It should read the turns back with checks, as readKills does. Check that the first floor is counted from turn 0, though the player never arrives on it. And check that both ways down record the floor, the trapdoor too, though it's rare enough that you may have to play a while to test it.
Summary
The third Amulet ends a run in victory now, with a screen and a sound of its own, before any monster can act again. Every run has a score, from its gold, its depth and its Amulets, and 2,000 more for a win. The game counts the player's kills, by kind, and keeps them in the save, now version 7. When a run ends, either way, the save goes, the morgue file tells the run's story, from the pack to the floor it ended on, and the run goes into the table of the ten best, if it's good enough, in gold on its end screen. In Chapter 27, the game gets a player that never tires: a bot that plays thousands of runs without a window, and reports how they went, so the game can be balanced by experiment.
