Skip to content
wannacode

Saving and loading

In short: A save is the world as key=value text with a version at the top and a checksum at the bottom; loading reads and checks everything into local variables and touches the game only at the end, so a broken save is refused without damaging it.

Problem

The game that forgets everything

A player has spent two hours in Greyford: picked up armour, killed the warden of the mine, collected gold. They close the terminal. The next morning the game starts from the beginning, and they do not come back. Saving and loading are what turn a game from a session into a journey.

The task looks simple (“write the variables to a file”), but three questions stand behind it:

  1. What is saved? Not the code and not the tables (items, enemies, skills: they are in the game). Only what the game has changed is saved: the hero, the turn, the map, the enemies, the quests. The rest is built again.
  2. How does loading know the file is good? The file may belong to another game, come from an older or newer version, be cut off halfway or edited by hand.
  3. What does it do when the file is not good? It does not crash and it does not stay half loaded.

The lesson builds a small world (a hero, a bag, a turn) and takes all three questions through it: the save as text, a version and a checksum, loading that first reads and checks, and six broken saves. Then it shows the same in the real game.

The idea

The world as text: key=value

A save is plain text, line by line: a key, an equals sign, a value. One line is one fact. This way the file can be read by eye, compared with another save in an editor and needs no library. Here are the types of the small world: a hero with a bag, a game and an error for saves.

class Slot {
constructor(id, count) {
this.id = id;
this.count = count;
}
}
class Hero {
constructor(name, level, hp, gold, x, y) {
this.name = name;
this.level = level;
this.hp = hp;
this.gold = gold;
this.x = x;
this.y = y;
this.inventory = [];
}
}
class Game {
constructor(hero, turn) {
this.hero = hero;
this.turn = turn;
}
}
class SaveError extends Error {}

And here is the save. At the top are the format and the version, then the world, and at the very bottom the checksum (more on it in a moment):

const SAVE_FORMAT = "ARCANUM";
const SAVE_VERSION = 1;
// The sum is made from the characters of everything above it; change a single letter and it no longer matches.
function checksum(text) {
let sum = 0;
for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007;
return sum;
}
// An empty list is written as a dash, so the line never has an empty value.
function joinOrDash(parts) {
return parts.length === 0 ? "-" : parts.join(",");
}
function sign(body) {
return body + "checksum=" + checksum(body) + "\n";
}
function saveGame(game) {
const hero = game.hero;
const lines = [];
lines.push("format=" + SAVE_FORMAT);
lines.push("version=" + SAVE_VERSION);
lines.push("turn=" + game.turn);
lines.push("hero.name=" + hero.name);
lines.push("hero.level=" + hero.level);
lines.push("hero.hp=" + hero.hp);
lines.push("hero.gold=" + hero.gold);
lines.push("hero.x=" + hero.x);
lines.push("hero.y=" + hero.y);
const bag = [];
for (const slot of hero.inventory) bag.push(slot.id + ":" + slot.count);
lines.push("hero.inventory=" + joinOrDash(bag));
return sign(lines.join("\n") + "\n");
}

The bag is one line: item:count, separated by commas. An empty bag is written as a dash: a line hero.inventory= with an empty value would look like a missing one. Each line is made with key=value, they are all joined with newlines, and at the end sign puts on the checksum. The program saves Iva and then the same game with an empty bag (only the two lines that change):

const game = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);
game.hero.inventory.push(new Slot("potion_heal", 2));
game.hero.inventory.push(new Slot("leather", 1));
process.stdout.write(saveGame(game));
game.hero.inventory.length = 0;
const again = saveGame(game).split("\n");
console.log(again[9]);
console.log(again[10]);
Output
format=ARCANUM
version=1
turn=212
hero.name=Iva
hero.level=3
hero.hp=27
hero.gold=42
hero.x=5
hero.y=2
hero.inventory=potion_heal:2,leather:1
checksum=624337035
hero.inventory=-
checksum=126903799

The second checksum is completely different although one line changed: that is its purpose.

More precisely, if you are curious

What the canon saves on top of our world: the seed and the state of the generator (seed, rng, ids), so that after loading the dice follow the same sequence; the levels (three maps, row by row, with what was seen and the decoration); the enemies with life, state and effects; the quests and the flags. These are not the tables of items and enemies: they are in the game, and a save keeps only the id (potion_heal:2), not the whole item.

What is not saved, because it is computed again: the stats with their layers (base, equipment, effects), the field of view (after loading the game calls computeFov) and everything built from the tables. Less in the save means fewer places for mistakes.

The idea

Version and checksum

Two lines protect the save from unpleasant surprises.

The version (version=1) says which variant of the format wrote the file. The game will change: there will be mana, there will be new lines. An old game that reads a new save must not guess; and the new game reading an old one must either know how, or refuse clearly. That is why the version is the second line and is checked before everything else except the header.

The checksum (checksum=...) is one number made from all the lines above it. Here it is in full:

// The sum is made from the characters of everything above it; change a single letter and it no longer matches.
function checksum(text) {
let sum = 0;
for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007;
return sum;
}

It walks over the characters of the text: the sum is multiplied by 31 and the code of the character is added, and the remainder of 1000000007 keeps it small. The multiplication matters. If it only added the codes, “ab” and “ba” would be the same number. Here is what it gives:

// The sum is made from the characters of everything above it; change a single letter and it no longer matches.
function checksum(text) {
let sum = 0;
for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007;
return sum;
}
console.log(checksum("ab"));
console.log(checksum("ba"));
Answer

The save is ready when the sum of everything above the last line is written at the very bottom. Whoever changes one character will see the sum not match.

Numbers in JavaScript are double, but sum * 31 stays below 2^53 (about 31 · 10^9), so the arithmetic is exact. charCodeAt(i) is the code of the character.
// An empty list is written as a dash, so the line never has an empty value.
function joinOrDash(parts) {
  return parts.length === 0 ? "" : parts.join(",");
}

The idea

Loading: first you read, then you touch

Loading is the mirror of saving and has one rule: until everything is read and checked, the game is not touched. Everything is read into local variables, and the assignment to the game is the last thing in the method.

First the checks. readValues takes the text and returns a table key → value, and at anything wrong it throws a SaveError with the message for the player:

// Digits only, at most nine.
function isNumber(text) {
if (text.length === 0 || text.length > 9) return false;
for (const c of text) {
if (c < "0" || c > "9") return false;
}
return true;
}
function need(values, key) {
if (!values.has(key)) throw new SaveError(`${key} is missing`);
return values.get(key);
}
function needNumber(values, key) {
const value = need(values, key);
if (!isNumber(value)) throw new SaveError(`wrong value for ${key}`);
return Number(value);
}
function readValues(text) {
const lines = text.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
if (lines.length === 0 || lines[0] !== "format=" + SAVE_FORMAT) throw new SaveError("this is not an Arcanum save");
const values = new Map();
for (let i = 0; i < lines.length; i++) {
const eq = lines[i].indexOf("=");
if (eq <= 0) throw new SaveError(`line ${i + 1} is not in the form key=value`);
values.set(lines[i].substring(0, eq), lines[i].substring(eq + 1));
}
const version = need(values, "version");
if (version !== String(SAVE_VERSION)) throw new SaveError(`version ${version}, but the game reads version ${SAVE_VERSION}`);
const last = lines[lines.length - 1];
if (!last.startsWith("checksum=")) throw new SaveError(`checksum is missing`);
const body = lines.slice(0, lines.length - 1).join("\n") + "\n";
const actual = checksum(body);
const stored = last.substring(9);
if (stored !== String(actual)) throw new SaveError(`the checksum does not match (stored ${stored}, computed ${actual}), someone has edited the file`);
return values;
}

The order matters: the header (format=ARCANUM), every line being key=value, the version, the checksum. The checksum is computed from everything above the last line and compared with the one stored there; need and needNumber are the guards of the keys (is it missing, and is the number a number).

Then the hero and the loading itself:

function readHero(values) {
const name = need(values, "hero.name");
const level = needNumber(values, "hero.level");
const hp = needNumber(values, "hero.hp");
const gold = needNumber(values, "hero.gold");
const x = needNumber(values, "hero.x");
const y = needNumber(values, "hero.y");
const hero = new Hero(name, level, hp, gold, x, y);
const bag = need(values, "hero.inventory");
if (bag !== "-") {
for (const part of bag.split(",")) {
const pieces = part.split(":");
if (pieces.length !== 2 || !isNumber(pieces[1])) throw new SaveError(`wrong value for hero.inventory`);
hero.inventory.push(new Slot(pieces[0], Number(pieces[1])));
}
}
return hero;
}
// Returns null when all went well, otherwise the text of the problem.
function loadGame(game, text) {
try {
const values = readValues(text);
const hero = readHero(values);
const turn = needNumber(values, "turn");
// Everything is read and checked: only now the game is touched.
game.hero = hero;
game.turn = turn;
return null;
} catch (error) {
if (!(error instanceof SaveError)) throw error;
return `The save is broken: ${error.message}. The game goes on without it.`;
}
}

readHero builds the hero from the table; loadGame is a short wrapper: it reads the hero and the turn and only then assigns them. SaveError is caught and becomes text; what leaves is the game, unchanged. The result is null on success or a message for the player. Here is a save and a load into another game:

const iva = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);
iva.hero.inventory.push(new Slot("potion_heal", 2));
iva.hero.inventory.push(new Slot("leather", 1));
const text = saveGame(iva);
const copy = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);
const error = loadGame(copy, text);
console.log(error === null ? "ok" : error);
console.log(heroLine(copy.hero));
console.log(saveGame(copy) === text ? "Writing the save again gives the same text." : "Writing the save again gives a different text.");
Output
ok
Iva: level 3, life 27, gold 42
Writing the save again gives the same text.

The save of the loaded game is the same text as the first one: that is why saving and loading are mirrors. Boris is gone, Iva is in the game.

Number("") is 0 and Number(" 7") is 7, so isNumber checks for itself that there are only digits (at most nine). catch (error) in JavaScript catches everything, so loadGame returns only our own SaveErrors and throws any foreign error again: a bug in the code must not be disguised as a “broken save”.
More precisely, if you are curious

The canonical loadGame does one more thing: if an error that is not a SaveError falls while reading (for example a hand-broken save with a correct checksum and a number of an unexpected shape on a line read with toInt), it turns it into “the fields are not in the form the game writes”. The rule “while I read, I do not touch the game” makes this safe: whatever error falls, the game is the same. Exactly such a save crashed the first version of the canon.

Returning null or a text is the style of the canon: the screen writes the result in the menu. In other code the same would be an exception that reaches the screen.

See it

Save, checksum, refusal

Here are saving and loading step by step. Iva is written line by line, and the sum grows with every line. Another game (Boris) loads the save: the checks run over the header, the version and all the lines, and the sum comes out the same. Then the gold is raised to 99999 and loading refuses.

// The sum is made from the characters of everything above it; change a single letter and it no longer matches.
function checksum(text) {
let sum = 0;
for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007;
return sum;
}
function sign(body) {
return body + "checksum=" + checksum(body) + "\n";
}
function saveGame(game) {
const hero = game.hero;
const lines = [];
lines.push("format=" + SAVE_FORMAT);
lines.push("version=" + SAVE_VERSION);
lines.push("turn=" + game.turn);
lines.push("hero.name=" + hero.name);
lines.push("hero.level=" + hero.level);
lines.push("hero.hp=" + hero.hp);
lines.push("hero.gold=" + hero.gold);
lines.push("hero.x=" + hero.x);
lines.push("hero.y=" + hero.y);
const bag = [];
for (const slot of hero.inventory) bag.push(slot.id + ":" + slot.count);
lines.push("hero.inventory=" + joinOrDash(bag));
return sign(lines.join("\n") + "\n");
}
function readValues(text) {
const lines = text.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
if (lines.length === 0 || lines[0] !== "format=" + SAVE_FORMAT) throw new SaveError("this is not an Arcanum save");
const values = new Map();
for (let i = 0; i < lines.length; i++) {
const eq = lines[i].indexOf("=");
if (eq <= 0) throw new SaveError(`line ${i + 1} is not in the form key=value`);
values.set(lines[i].substring(0, eq), lines[i].substring(eq + 1));
}
const version = need(values, "version");
if (version !== String(SAVE_VERSION)) throw new SaveError(`version ${version}, but the game reads version ${SAVE_VERSION}`);
const last = lines[lines.length - 1];
if (!last.startsWith("checksum=")) throw new SaveError(`checksum is missing`);
const body = lines.slice(0, lines.length - 1).join("\n") + "\n";
const actual = checksum(body);
const stored = last.substring(9);
if (stored !== String(actual)) throw new SaveError(`the checksum does not match (stored ${stored}, computed ${actual}), someone has edited the file`);
return values;
}
// Returns null when all went well, otherwise the text of the problem.
function loadGame(game, text) {
try {
const values = readValues(text);
const hero = readHero(values);
const turn = needNumber(values, "turn");
// Everything is read and checked: only now the game is touched.
game.hero = hero;
game.turn = turn;
return null;
} catch (error) {
if (!(error instanceof SaveError)) throw error;
return `The save is broken: ${error.message}. The game goes on without it.`;
}
}
Step by step, in words
  1. Iva’s game is in memory. To save it we write it as text, line by line as key=value. The format and the version come first, so loading can tell whether it is reading a save of this game and of this version.
  2. The lines of the world follow: turn, name, level, life, gold, position and bag. The bag is one line (item:count, comma-separated); an empty one is a dash. Each line is one fact the game needs to carry on from there.
  3. The last line is the checksum. It grows with every line (the number on the right): the sum is multiplied by 31 and the code of each character is added. Everything above it gives 624337035; change one character and the number is different.
  4. Another game (Boris) loads the save. Loading does not touch the game yet: it reads first. It checks the header (format), that every line is key=value, and the version.
  5. The sum is computed again from the same lines (the bar runs over them). It comes to 624337035, the same as stored: the file is as it was written.
  6. Only now, with everything read and checked, Boris’s game gets the hero from the save. Iva is loaded: 42 gold, level 3.
  7. Someone opens the file and raises the gold: hero.gold=42 becomes hero.gold=99999. The file is still readable text, every line is key=value. But the sum at the end was written for the old lines.
  8. Loading computes the sum again. Up to the gold the numbers are the same, from there on they differ (in orange), and at the end the result is 748631855. The stored one is 624337035: they do not match.
  9. Refusal: “The save is broken: the checksum does not match (stored 624337035, computed 748631855), someone has edited the file. The game goes on without it.” The game stays as it is: Iva with 42 gold. 99999 never reaches it.

The code next to the scene is checksum, sign, saveGame, readValues and loadGame from the program. At each step the lines that work at that moment light up.

The idea

Six broken saves

What is read is Iva’s save, but six times before that the file was broken in a different way. The program runs all of them into Boris’s game and prints the refusal for each. resign in it is “someone who knows how the sum is computed”:

// Removes the old sum and puts a new one: this is someone who knows how the sum is computed.
function resign(text) {
return sign(text.substring(0, text.lastIndexOf("checksum=")));
}
function heroLine(hero) {
return hero.name + ": level " + hero.level + ", life " + hero.hp + ", gold " + hero.gold;
}
const iva = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);
iva.hero.inventory.push(new Slot("potion_heal", 2));
iva.hero.inventory.push(new Slot("leather", 1));
const good = saveGame(iva);
const broken = [];
// Someone raised the gold by hand: the sum is old.
broken.push(good.replace("hero.gold=42", "hero.gold=99999"));
// The file of another game.
broken.push(good.replace("format=ARCANUM", "format=SOLITAIRE"));
// Written by a newer version of the game.
broken.push(good.replace("version=1", "version=2"));
// A line is missing, the sum is computed again.
broken.push(resign(good.replace("hero.gold=42\n", "")));
// A bad number, the sum is computed again.
broken.push(resign(good.replace("hero.level=3", "hero.level=three")));
// A line without an equals sign.
broken.push(good.replace("hero.hp=27", "hero.hp 27"));
const game = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);
for (let i = 0; i < broken.length; i++) {
console.log((i + 1) + ": " + loadGame(game, broken[i]));
}
console.log(heroLine(game.hero));
// The whole save goes in.
console.log(loadGame(game, good) === null ? "ok" : "no");
console.log(heroLine(game.hero));
Output
1: The save is broken: the checksum does not match (stored 624337035, computed 748631855), someone has edited the file. The game goes on without it.
2: The save is broken: this is not an Arcanum save. The game goes on without it.
3: The save is broken: version 2, but the game reads version 1. The game goes on without it.
4: The save is broken: hero.gold is missing. The game goes on without it.
5: The save is broken: wrong value for hero.level. The game goes on without it.
6: The save is broken: line 6 is not in the form key=value. The game goes on without it.
Boris: level 1, life 10, gold 7
ok
Iva: level 3, life 27, gold 42

Each refusal belongs to a check of its own:

No. What was touched Which check catches it
1 gold raised by hand, checksum is old checksum: the stored and the computed differ
2 the file of another game header: the first line is not format=ARCANUM
3 a save from version 2 version: it comes before the checksum
4 a line is missing, checksum is new and correct content: need does not find hero.gold
5 hero.level=three, checksum is new and correct content: needNumber does not see a number
6 a line without an equals sign shape of the line: already while splitting

The fourth and the fifth get past the checksum because they are signed again: that is why loading has a second layer. The checksum catches damage, and need and needNumber catch a file that is whole and wrong.

In the end Boris is untouched (Boris: level 1 ...) until the good save comes in: then he is Iva. None of the six changed him.

The idea

In the game

The small world is the same as in the canon, only smaller. Here is the real checksum. The computation is the same; the only difference is the type of the result:

function checksum(text) {
let sum = 0;
for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007;
return sum;
}

And here is the save of the whole game. Besides our lines there are seed, rng, ids and summary (the line for the slot menu: name, class, level and place), and for each of the three levels the rows of the map, what was seen, the decoration, the items, the enemies with their effects and the people:

function saveGame(game) {
const lines = [];
const put = (key, value) => lines.push(key + '=' + value);
const hero = game.hero;
put('format', SAVE_FORMAT);
put('version', SAVE_VERSION);
put('summary', t('slot.summary', { name: hero.name, heroClass: nm(hero.heroClass()), level: hero.level, place: placeName(game) }));
put('seed', game.seed);
put('rng', game.rng.state);
put('ids', game.ids.next);
put('turn', game.turn);
put('depth', game.depth);
put('victory', game.victory ? 1 : 0);
put('hero.name', hero.name);
put('hero.class', hero.classId);
put('hero.level', hero.level);
put('hero.xp', hero.xp);
put('hero.gold', hero.gold);
put('hero.hp', hero.hp);
put('hero.mana', hero.mana);
put('hero.x', hero.x);
put('hero.y', hero.y);
const base = [];
for (const stat of STAT_NAMES) base.push(hero.base.get(stat));
put('hero.base', base.join(','));
put('hero.skills', joinOrDash(hero.skills));
put('hero.cooldowns', cooldownsText(hero));
put('hero.effects', effectsText(hero));
const equipment = [];
for (const slot of hero.equipment.keys()) equipment.push(hero.equipment.get(slot) === '' ? '-' : hero.equipment.get(slot));
put('hero.equipment', equipment.join(','));
const inventory = [];
for (const slot of hero.inventory.slots) inventory.push(slot.id + ':' + slot.count);
put('hero.inventory', joinOrDash(inventory));
const stats = game.stats;
put('stats', [stats.turns, stats.kills, stats.blood, stats.dealt, stats.taken, stats.potions].join(','));
put('flags', joinOrDash(game.flags));
for (const quest of game.quests) put('quest.' + quest.id, quest.state + ':' + quest.stage);
for (const level of game.levels) {
const prefix = 'level.' + level.depth + '.';
for (let y = 0; y < level.height; y++) {
let seen = '';
let decor = '';
for (let x = 0; x < level.width; x++) {
seen += level.seen[y][x] ? '1' : '0';
decor += level.decor[y][x] === ' ' ? '_' : level.decor[y][x];
}
put(prefix + 'tiles.' + y, level.tiles[y].join(''));
put(prefix + 'seen.' + y, seen);
put(prefix + 'decor.' + y, decor);
}
const items = [];
for (const item of level.items) items.push(item.x + ':' + item.y + ':' + item.id + ':' + item.count);
put(prefix + 'items', joinOrDash(items));
put(prefix + 'enemies', level.enemies.length);
for (let i = 0; i < level.enemies.length; i++) {
const enemy = level.enemies[i];
put(prefix + 'enemy.' + i, [enemy.kind.id, enemy.x, enemy.y, enemy.hp, enemy.state, enemy.calm, enemy.lostTurns, enemy.phase].join(','));
put(prefix + 'enemy.' + i + '.effects', effectsText(enemy));
put(prefix + 'enemy.' + i + '.cooldowns', cooldownsText(enemy));
}
const npcs = [];
for (const npc of level.npcs) npcs.push(npc.id + ':' + npc.x + ':' + npc.y);
put(prefix + 'npcs', joinOrDash(npcs));
}
const body = lines.join('\n') + '\n';
return body + 'checksum=' + checksum(body) + '\n';
}

Reading in the canon is a class SaveReader: the constructor makes the same checks as our readValues, and text, int, ints and list are the guards. Here it is:

class SaveReader {
constructor(text) {
this.values = new Map();
const lines = text.split('\n');
while (lines.length > 0 && lines[lines.length - 1].length === 0) lines.pop();
if (lines.length === 0 || lines[0] !== 'format=' + SAVE_FORMAT) throw new SaveError(t('load.badHeader'));
for (let i = 0; i < lines.length; i++) {
const eq = lines[i].indexOf('=');
if (eq <= 0) throw new SaveError(t('load.badLine', { line: i + 1 }));
this.values.set(lines[i].substring(0, eq), lines[i].substring(eq + 1));
}
const version = this.text('version');
if (version !== String(SAVE_VERSION)) throw new SaveError(t('load.badVersion', { found: version, expected: SAVE_VERSION }));
const last = lines[lines.length - 1];
if (!last.startsWith('checksum=')) throw new SaveError(t('load.missing', { key: 'checksum' }));
const body = lines.slice(0, lines.length - 1).join('\n') + '\n';
const actual = checksum(body);
if (parseIntStrict(last.substring(9)) !== actual) {
throw new SaveError(t('load.badChecksum', { expected: last.substring(9), actual: actual }));
}
}
text(key) {
if (!this.values.has(key)) throw new SaveError(t('load.missing', { key: key }));
return this.values.get(key);
}
int(key) {
const value = parseIntStrict(this.text(key));
if (value === null) throw new SaveError(t('load.badValue', { key: key }));
return value;
}
ints(key, count) {
const parts = this.text(key).split(',');
if (parts.length !== count) throw new SaveError(t('load.badValue', { key: key }));
const result = [];
for (const part of parts) {
const value = parseIntStrict(part);
if (value === null) throw new SaveError(t('load.badValue', { key: key }));
result.push(value);
}
return result;
}
list(key) {
return splitList(this.text(key));
}
}

And the wrapper that protects the game. The assignment comes after the comment “Everything is read”, and catch turns any error into text:

// Returns null on success, or the error text.
function loadGame(game, text) {
if (text === null) return t('load.empty');
try {
const reader = new SaveReader(text);
const ids = new IdSource(1);
const hero = readHero(reader, ids);
const levels = [];
for (const def of MAP_DEFS) levels.push(readLevel(reader, def, ids));
const quests = createQuests();
for (const quest of quests) {
const pieces = reader.text('quest.' + quest.id).split(':');
if (pieces.length !== 2) throw new SaveError(t('load.badValue', { key: 'quest.' + quest.id }));
quest.state = pieces[0];
quest.stage = toInt(pieces[1]);
}
const stats = reader.ints('stats', 6);
const depth = reader.int('depth');
if (depth < 0 || depth > levels.length) throw new SaveError(t('load.badValue', { key: 'depth' }));
// Everything has been read: only now is the game touched.
game.hero = hero;
game.levels = levels;
game.useQuests(quests);
game.flags = reader.list('flags');
game.stats = { turns: stats[0], kills: stats[1], blood: stats[2], dealt: stats[3], taken: stats[4], potions: stats[5] };
game.depth = depth;
game.turn = reader.int('turn');
game.victory = reader.int('victory') === 1;
game.rng.state = reader.int('rng') >>> 0;
game.ids = new IdSource(Math.max(reader.int('ids'), ids.next));
game.messages = [];
if (depth > 0) computeFov(game.level(), hero.x, hero.y);
return null;
} catch (error) {
// Up to "only now is the game touched" the code only reads: whatever error comes up, it comes from the data.
const reason = error instanceof SaveError ? error.message : t('load.badData');
return t('load.broken', { reason: reason });
}
}

What else the game has around this code:

  • Slots. The menu writes to slot 1, 2 or 3 through io.save(slot, text) and reads through io.load(slot). In Node these are files in a folder saves, in the browser an entry in localStorage. The save itself does not know where it goes.
  • A broken slot for testing. With --memory-saves the saves are kept in memory and main puts the save from tamperedSave() into slot 3, in the language of the game: a save with hero.gold=99999 and a checksum that does not belong to this text. This way the recorded playthrough of the game shows the refusal: the menu says “The save is broken” and the game goes on.
  • A summary of the slot. slotSummary reads only the line summary=, without loading the world: this is how the menu shows “Iva, barbarian, lv4, Greyford” for each slot.
  • After loading. loadGame clears the messages and computes the visible area again with computeFov, and enterWorld says “Loaded from slot 1” and replaces the stack with the screen of the town or of the map (see the lesson on the screen stack).

Try it

Find the bug

A colleague decided it was simpler to put the values straight into the game while reading them. A save with a correct checksum but with hero.level=three is given:

const iva = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);
iva.hero.inventory.push(new Slot("potion_heal", 2));
iva.hero.inventory.push(new Slot("leather", 1));
const game = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);
const text = resign(saveGame(iva).replace("hero.level=3", "hero.level=three"));
console.log(loadGame(game, text));
console.log(heroLine(game.hero));
Output
The save is broken: wrong value for hero.level. The game goes on without it.
Iva: level 1, life 10, gold 7

Loading refuses, as it should, but the game is not the same: the name is Iva, while the level, life and gold are Boris’s. Which line is the first to touch the game before the save has been read to the end?

Pick the line with the bug.

More precisely

More precisely: checksums, versions and what else can go wrong

A checksum is not a signature. Ours mixes characters with a modulus; there are ready-made and stronger ones: CRC32 catches damage from disks and networks more reliably, and with SHA-256 it is almost impossible to swap the content unnoticed. But even they do not protect against a person who knows the algorithm and writes a new correct checksum: resign in the program does exactly that. Only a signature with a secret key (HMAC) would catch it, and in a single-player game on the player’s own computer there is no secret key and no point in such protection. The checksum here is for damage and clumsy editing. Whoever wants to cheat in a single-player game will do so, and that is their own business.

Versions and migrations. Every change in the format is a new version. Three ways: to refuse (our loading: “version 2, but the game reads version 1”), to read the old versions too with a built-in conversion (the exercise below) or to read them all forever. The last one grows: after years the loading holds code for a dozen versions. That is why a chain of “migrations” is often built: version 1 → 2 → 3, each step small and with a test.

A write cut halfway. The power goes while the file is being written: half a file is left on the disk. The checksum catches it (the last line is missing, “checksum is missing”), but the player’s save is lost all the same. It is better to write to a temporary file and rename it over the old one only when it is completely written; a rename is a single operation. Our io.save writes directly, because the lesson is not about file systems.

Why not JSON. JSON would do the job and you will see it in other lessons. Here the key=value text is deliberately simple: the output must be the same in four languages without libraries, and the whole format can be read on one screen. The price is that nested things (a bag, an enemy with effects) are glued together by hand with : and ,.

A binary format. Smaller and faster, but it cannot be read or compared by eye. For a text game with saves of a few kilobytes it is unnecessary.

Exercise

Version 2: mana

Solve the task in your editor. Start from the loading program.

Mana and two versions. The hero gets mana (mana, 10 by default). Version 2 of the format adds a line hero.mana right after hero.hp. The game now writes version 2 but still reads saves from version 1: they have no mana and the hero gets the default. Version 3 (and above) is refused with the same message as before, and readValues accepts a version between 1 and the newest. Make loadGame return null for both kinds of save.

Here is the save from version 1 (as the program of the lesson writes it), the helper function and the body:

// A save as version 1 of the game wrote it.
const V1_SAVE = [
"format=ARCANUM",
"version=1",
"turn=212",
"hero.name=Iva",
"hero.level=3",
"hero.hp=27",
"hero.gold=42",
"hero.x=5",
"hero.y=2",
"hero.inventory=potion_heal:2,leather:1",
"checksum=624337035",
].join("\n") + "\n";
function check(game, number, text) {
const error = loadGame(game, text);
console.log(number + ": " + (error === null ? "ok" : error));
console.log(heroLine(game.hero));
}
const source = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);
source.hero.inventory.push(new Slot("potion_heal", 2));
source.hero.inventory.push(new Slot("leather", 1));
source.hero.mana = 8;
const v2 = saveGame(source);
const game = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);
check(game, 1, V1_SAVE);
check(game, 2, v2);
check(game, 3, v2.replace("version=2", "version=3"));

The output is:

Output
1: ok
Iva: level 3, life 27, gold 42, mana 10
2: ok
Iva: level 3, life 27, gold 42, mana 8
3: The save is broken: version 3, but the game reads version 2. The game goes on without it.
Iva: level 3, life 27, gold 42, mana 8

More tasks

  • Autosave. Write to a separate slot auto every 100 turns. How does the menu tell that the slot is automatic?
  • A cut-off file. Remove the last line of a save (the checksum). What does loading say? Change io.save to write to a temporary file and rename it.
  • A summary of the slot. Add a line summary= with the name and the level and a function that reads it without loading the world, like slotSummary in the canon.
  • The same key twice. Now the last hero.gold= wins silently, because the table replaces. Make readValues refuse saves with a repeated key.

Test

Check yourself

1. A save is from version 2 and its checksum does not match, but you load it into a game that reads version 1. Which error does the player see, and why?
2. The checksum of “ab” is 3105 and that of “ba” is 3135. Why is it not simply the sum of the character codes (195 for both strings)?
3. Someone who knows how the checksum is computed raises the gold to 99999 and writes the checksum of the new text at the end. What happens on loading?
4. Loading reads everything into local variables and only then puts them into the game. Why?
5. The canon also saves the state of the random number generator (rng). Why?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·