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.
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.entbaseline entity by its stable id and carries only the fields that drifted, applied onto the baseline the.entload 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.
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
shieldline. 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
.mapis 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
.dsavper 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
.entbaseline 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.