Technical diary

A save that stores only what changed

The obvious way to save a game is to serialize the world, which makes every save a redundant copy of the level — and makes the save the level. Instead the static layer is never written at all: a load rebuilds from the authored files, then applies a diff of what actually drifted. An entity nobody touched costs zero bytes.

The obvious way to save a game is to serialize the world: walk the live state, write it out, read it back.

It is the wrong shape for a dungeon crawler, because most of what is in memory was never yours to begin with. The walls, the niches, the authored monster placements, the items sitting where a designer put them — all of it came from files that have not changed. Writing them into a save makes every save a redundant copy of the level, and worse, makes the save be the level: fix a wall in the editor afterwards and every existing save keeps the old one.

The decision

Three layers, and a save is only the third.

Layer File Role Saved?
Static structure .map walls, floors, doors, the grid itself never
Authored dynamic .ent spawn records: monsters, items, buttons never
Runtime drift .dsav what actually changed during play the only thing written

Loading reconstructs the level from .map + .ent exactly as a fresh start would, then applies the save on top. The save's job is not to describe the world; it is to describe the difference between the world as authored and the world as played.

The three level layers, and which one a save writes The static map file and the authored entity file are never saved. Only the runtime drift file is written. Loading reads the map, reads the entity spawn records, and then applies the save on top, which carries only the entities that changed plus the state that was born at runtime. THREE LAYERS — ONLY THE THIRD IS EVER WRITTEN .map STATIC STRUCTURE walls, floors, doors, the grid never saved .ent AUTHORED DYNAMIC spawn records: monsters, items, buttons never saved .dsav RUNTIME DRIFT what actually changed during play the only file written load the level you play 1 read .map the grid, exactly as authored 2 read .ent every spawn record, at its spawn state 3 apply .dsav only the entities that drifted, plus the runtime-born state stored whole A save describes the difference between the world as authored and the world as played. Because .map is never written, re-authoring a level’s structure needs no save-format change at all.
The save is an overlay, not a snapshot. Reconstruct from the authored files first, then apply only what drifted.

That splits runtime state cleanly in two. State with no baseline — party pose, the free-look offset, the item on the cursor, fog of war, character resources — was born at runtime and is stored whole. State with a baseline — monsters, items, buttons — is stored as a diff, and only when it differs from its spawn record.

The consequence worth stating plainly: an untouched entity costs zero bytes. Walk past a skeleton without disturbing it and the save says nothing about that skeleton. Version 6 dumped every floor item on every save; version 7 emits a row only when something moved.

One primitive, two modes

The first implementation had three ad-hoc save paths — one for monsters, one for items, one for buttons — which is how these things start and how they stay until someone notices they are the same problem. Version 7 collapsed all three into a single record with two modes, decided by the sign of its id:

  • Diff (id >= 0) — references a .ent baseline entity by its stable id and carries only the fields that drifted, applied onto the baseline the .ent load already built.
  • Spawn (id < 0) — a runtime entity with no baseline: a monster the editor placed after authoring, or an item the party dropped. Stored whole, so a load can recreate it from nothing.
Diff mode, spawn mode, and writing nothing at all Three entities and three outcomes. A skeleton nobody touched matches its spawn record and no row is written. A wounded skeleton writes a diff keyed by its stable id, carrying only the fields that changed. A rune dropped during play has no authored baseline, so it is stored whole as a spawn record the loader can recreate from nothing. WHAT EACH ENTITY COSTS A SAVE Skeleton #14 in .ent walked past, untouched THE SAVE WRITES nothing at all no row is written nothing identical to its spawn record Skeleton #31 in .ent fought, now wounded and alert THE SAVE WRITES a DIFF keyed by stable id ent 31 monster 9 5 hp 11.5 aware 1 only the fields that drifted Fire rune dropped in play no .ent baseline exists THE SAVE WRITES a SPAWN stored whole drop rune_fire 7 13 2 -1 the loader recreates it from nothing An untouched entity costs zero bytes — a save is proportional to what you did, not to the size of the level.
Three entities in one cell, three different outcomes. The one nobody touched is the one that costs nothing.

Identity, again

The diff is keyed by Entity::id, under the same discipline the async AI needed. Ids are assigned in .ent file-record order, before the by-cell sort the runtime uses for lookups, so an id survives the sort; removals never renumber, so a fresh id is always max + 1. A save keyed on those ids therefore stays valid across editing operations that reorder or delete other entities. Identity is not a position in an array here either.

The version ladder

The format is on version 22, reached about six weeks after the first save shipped, and the header documents every change back to v5 — what it added, what it looks like on disk, and what happens when an older save meets it.

That last clause is the part that matters. Every entry carries its own backward-compatibility rule:

  • v22 — monsters carry status effects. Older saves load their monsters unafflicted.
  • v16 — default uses and the spell list went per-hand. A pre-v16 flat line seeds both hands.
  • v14 — the unified effects list replaced the v13 shield line. The old line is still read, loaded as a ward effect.

The most interesting is v17. Resource maxima stopped being stored and became derived — max = base + k × statAvg — so the base is now the stored truth. A pre-v17 save has maxima but no bases, so the loader runs the formula backwards, subtracting the stat contribution under the project's live balance knobs to recover the base it must have had. Under unchanged knobs the maxima reproduce exactly.

That is a migration which reconstructs a field that never existed in the old format, from a field that no longer exists in the new one.

The counterpart to all this is knowing what not to save. The exhaustion latch is a live transient: it isn't written, it's re-derived from the restored stamina bar, so a save made while winded resumes winded without the format carrying a flag for it.

What it is on disk

Plain UTF-8 in the same record dialect as the .map and .ent files — ; comments, whitespace-separated tokens, one record per line:

; Dungeon save — dynamic level state (see SaveGame.h)
save version=22
save name=Second Level
save current=level2
party 7 12 1
look 0.14832 -0.02100 0
torch 0
char 0 42.000 58.000 30.000 30.000 12.000 24.000 5
equip 0 - torch sword - - -
level level2
ent 31 9 5 1 11.500 1 0 0.00 4.20 0.00 0.00 1
enteffect burn fire 3.200 6.000 6.000 0
item 15
drop rune_fire 7 13 2 -1
seen 6,12 7,12 7,13 8,13

The ent line is a diff: baseline monster 31 has moved to (9, 5), announced itself, dropped to 11.5 hit points, noticed the party, and holds a grudge against roster member 1. item 15 is a one-bit diff — that baseline item was lifted off the floor. drop is a spawn: a rune with no authored record at all. Everything else on the level is untouched, and so is simply absent.

The enteffect line is a small piece of format design worth noticing: it attaches to whichever entity line precedes it, so the reader hangs effects on the last entity it saw and no index has to be kept in step between the two.

What it cost

Every persistent feature costs a version bump. Twenty-two in six weeks is the real rate, and each is a format change, a migration path, a header note, and a manual check that an older save still loads. That is a standing tax on adding anything that has to survive a save.

The diff is only as stable as its baseline. A save records "entity 31 has 11.5 hp". If someone re-authors that level's .ent and entity 31 becomes a different monster, the save is quietly wrong rather than loudly broken. Nothing detects this. It has not bitten yet because one person authors the levels and plays them — which is exactly the condition that hides it.

Plain text is bigger, slower, and trivially cheatable — all accepted deliberately, since at this scale the parse cost is invisible and a single-player crawler has nothing to protect.

What it bought

  • Saves are small and mostly empty — proportional to what you did, not to how big the level is.
  • Levels stay editable. Because .map is never saved, re-authoring a level's structure needs no save-format change at all.
  • No save was ever orphaned. Across twenty-two versions, every older format still loads. That is a policy, not luck.
  • Saves are a debugging surface. Diffable, and hand-editable to reproduce a reported state directly instead of playing back to it.
  • One primitive covers three entity kinds. A fourth is a field on an existing record, not a fourth save path.

How it's proved

By hand, per version, documented in the commit that made the change. The v6→v7 unification was verified by driving the debug executable: an old save loading without crashing, a button toggle round-tripping, an item picked up and dropped, clean saves emitting zero rows for untouched baselines, and the migration producing no duplicated items.

Real verification, and entirely manual. There is no archived corpus of old saves loaded automatically on every build, so nothing catches a migration that silently regresses three versions later.

Still open

  • A save corpus in CI. One archived .dsav per historical version, loaded and asserted on every build, would convert "every older save still loads" from a policy into a fact. This is the most valuable missing test in the project.
  • Baseline drift is undetected. A hash of the .ent baseline written into the save and checked on load would at least turn a silent mismatch into a warning.
  • The parser is hand-rolled and tolerant by design — ignoring unknown lines is what makes forward compatibility work, but it also means a typo in a hand-edited save fails silently rather than loudly.

Landed

June–July 2026 · 22 format versions in six weeks

Topics

Serialization Migration Backward compatibility Stable ids

View source ↗
← Back to Dungeon