All tutorials Mighty Professional
Build a Game Engine · Advanced

Save Games & Serialization

The asset pipeline serializes cooked, read-only data that you can always rebuild from source. A save is the opposite: the player's mutable, one-of-a-kind world state, with no source to regenerate it from, that has to keep loading after you ship a patch that changed the format. Three things make it hard: you cannot write a pointer to disk, the format will change, and a crash mid-write must not eat the save.

Time~55 min LevelSenior PrereqsThe Gameplay Layer (generational handles, the object model), ECS (component data), and Asset Pipeline (the byte-level serializer, endianness, versioning). StackC++ & Rust
◂ Build a Game Engine Advanced · Persistence Back to the series ▸

01A save is mutable state, not assets

is one toolbox, but a save and a cooked asset are opposite jobs for it. A cooked asset is authored once, immutable at runtime, shared by every player, and regenerable from the source art. A save is written at runtime, unique to one player, and has no source: lose it and it's gone. Unreal makes the split explicit with a base class for exactly this, USaveGame, "a base class for a save game object that can be used to save state about the game"[1].

The save carries the versioning burden alone

A cooked asset can change format freely, the build just re-cooks it from the source DCC file. A save has no source to re-cook from, so when your data layout changes in a patch, old saves on players' disks must still load. That single constraint, covered in §5, is why save code is harder than asset code even though they share the same serializer. Everything else here, the object graph, delta saves, atomic writes, is in service of getting mutable state to and from disk safely and forever.

02The object-graph problem

Game objects point at each other: an AI's target, a projectile's owner, a node's parent. Those references are pointers in memory, and a pointer is just the address where an object happens to live this run, "pointer values usually change from run to run"[2]. Write a raw pointer to disk and on the next load it points at garbage. The fix is to serialize a reference as something position-independent and re-resolve it on load.

Three ways to encode a reference, from worst to best for a save[2]:

Fix-up is two passes, and the generation counter is load-bearing

Loading an object graph is two passes[3]: pass 1 instantiates every object so all targets exist, recording id → new-object; pass 2 walks the saved references and re-resolves each id to the freshly-created object. This re-resolution is called (or swizzling). The generation counter in a handle is what makes a stale reference safe: if the slot was freed and reused, the generations no longer match and the handle resolves to invalid, not to whatever now occupies the slot. This is the same handle from the Gameplay Layer, now crossing a save boundary. Note the two distinct failure modes: a missing target (the referenced object was not saved) needs a null-and-skip; a reused slot is what the generation check catches. They are not the same bug.

Save the graph, quit, reload. With ids, the references re-resolve through the handle table and reconnect. Flip on "store raw pointers" and the saved addresses point at nothing after reload, the dangling-reference crash this whole section exists to prevent:

Save flattens each reference into the blob. Reload brings the objects back at new addresses, so the references are momentarily unresolved (red). Fix up re-resolves them. With ids it reconnects cleanly through the id → object map; flip on raw pointers and fix-up has only the old, now-meaningless addresses, so the references dangle. That is why you serialize ids, never pointers.
Serialize a reference as a handle, re-resolve it on load
struct Handle { uint32_t index; uint32_t generation; };   // position-independent reference

// SAVE: write the {index, generation}, never the raw pointer.
void writeRef(Writer& out, Handle h) { out.u32(h.index); out.u32(h.generation); }

// LOAD is TWO passes:
//   pass 1: instantiate every saved object, recording id -> new slot (so all targets exist)
//   pass 2: re-resolve each saved handle through the table (the fix-up)
Entity* resolveRef(const HandleTable& table, Handle h) {
    const Slot& slot = table.slots[h.index];
    if (slot.generation != h.generation) return nullptr;   // stale: slot reused -> invalid, NOT the wrong object
    return slot.entity;                                  // the live pointer, valid this run
}
#[derive(Clone, Copy, Serialize, Deserialize)]
struct Handle { index: u32, generation: u32 }   // position-independent reference (serde derives the bytes)

// LOAD pass 2: re-resolve a saved handle to a live entity through the table.
fn resolve_ref(table: &HandleTable, h: Handle) -> Option<&Entity> {
    let slot = table.slots.get(h.index as usize)?;
    if slot.generation != h.generation { return None; }   // stale handle -> None, never the wrong entity
    slot.entity.as_ref()                              // pass 1 already re-instantiated every object
}

Serialization libraries enforce this rule for you. cereal refuses raw pointers outright and dedups shared references, data behind a shared_ptr "is serialized only once, even if several shared_ptr... that point to the same data are serialized," and is re-linked on load[4]. Unity's inline serializer goes the other way as a cautionary tale: it "does not support null or shared references, so any cycle in data can lead to unexpected results," which is exactly why ISerializationCallbackReceiver exists, to flatten a graph into parallel arrays of nodes plus parent indices by hand, the id-reference pattern applied manually[5].

03Save vs recompute

Persist authoritative state; recompute derived state on load. Saving derived state bloats the file and creates a second source of truth that can drift out of sync with the authoritative fields it was computed from.

Re-spawn runtime objects, don't blind-serialize them

An object spawned during play (a dropped item, a summoned creature) is saved as the fact of its existence plus its authoritative fields, then re-spawned on load, not serialized as a live engine object with all its transient machinery. SPUD, a shipping Unreal persistence library, does exactly this: runtime-spawned actors "will be automatically re-spawned on load"[6]. The same guidance says not to persist framework objects you can rebuild from the level (the game mode, the game state); reconstruct them instead. The rule of thumb: if you can recompute it from what you saved, don't save it.

04Full vs delta saves

The cooked level is already on disk, read-only and shared by every player. Re-storing all of it in each save is wasteful, so most level-based games persist only a delta over that baseline: the objects whose state changed from their authored default. Unreal's serializer has this built in, in its default mode "a property's value is not altered from its default... the property will not be serialized"[7].

Bethesda's Creation Engine is the canonical shipped example. A save stores change forms, the deltas over the baked plugin data, and references are stable Form IDs (three bytes in the save, with index FF reserved for runtime-spawned objects unique to that save)[8]. That same design is also why mods break saves: a reference baked against one load order resolves to the wrong plugin when the order changes, a real-world pointer-fix-up failure.

Delta is the common approach, not the only one

The practical delta is "changed from the class default," cheap to detect by comparing each object to its baseline. It is not a graph diff between two whole saves, which is expensive and rarely worth it; a single-scene or roguelike-run game legitimately snapshots its entire state, and an MMO keeps the authoritative state server-side. Match the strategy to the game, but know that for an open world the delta-over-baseline is what keeps each save small.

05Versioning & migration

This is the section that actually breaks in production. You will ship, then patch the save format, and players still have saves written by the old code. They must keep loading. The mechanism is a version tag plus : Casey Muratori frames it as "a version number in your struct and a function for migrating from an old version to a new version," explicitly like database migrations[9].

Pick a save version and load it into the current (v3) game. Older saves migrate up: a missing field gets a default, a renamed field is mapped. New code reads every old save:

On-disk save version:
Load a v1 save into v3 code: hp maps to the renamed health, and stamina and perks (added in v2 and v3) are filled with defaults. That is backward compatibility, new code reading old saves, the must-have for any shipped game. A v3 save loads as-is.
A versioned read: default fields that did not exist in older saves
constexpr uint32_t SAVE_VERSION = 3;       // bump on every format change

PlayerState loadPlayer(Reader& in) {
    uint32_t version = in.u32();           // the version tag is the FIRST thing in the blob
    PlayerState p;
    p.health  = in.f32();                  // every version (stored as "hp" in v1/v2; same bytes)
    p.stamina = version >= 2 ? in.f32() : 100.0f;       // added in v2 -> default for older saves
    p.perks   = version >= 3 ? in.readPerks() : Perks{}; // added in v3 -> default for older saves
    return p;                              // new code reads ANY old save by defaulting missing fields
}
const SAVE_VERSION: u32 = 3;               // bump on every format change

fn load_player(r: &mut Reader) -> PlayerState {
    let version = r.u32();                 // version tag first
    PlayerState {
        health:  r.f32(),                   // every version
        stamina: if version >= 2 { r.f32() } else { 100.0 },        // added in v2
        perks:   if version >= 3 { r.read_perks() } else { Vec::new() }, // added in v3
    }
}
Tagged formats tolerate drift; the long-tail traps are what bite

Positional binary needs an explicit version branch like the code above. A tagged format (Protocol Buffers, FlatBuffers) buys both compatibility directions cheaply: old code ignores unknown fields, new code defaults missing ones. But the rules are strict, and breaking them is where saves actually die: protobuf says never reuse a field number and reserve deleted ones so they cannot be recycled into a type-mismatched field[10]; FlatBuffers adds new fields only at the end and never deletes (only deprecates)[11]. The subtlest trap: editing a default value after ship silently rewrites the meaning of every old save that relied on the old default. Unreal's GUID-based custom versions exist precisely so two engine branches can evolve the format on independent tracks without colliding on one integer[12].

06Atomic writes

A save is overwritten in place at your peril. Crash, power-loss, or a killed process partway through the write and the file is half old data, half new, and unreadable, the worst outcome, because the previous good save is gone too. The fix is an : write a temp file in the same directory, force it to disk, then rename it over the real save.

Pick a strategy and crash mid-write. In-place corrupts the only save; temp-then-rename leaves the previous save fully intact, because the real file is never touched until the rename:

Strategy:
temp + rename: the real save.dat is never touched until a full write is renamed over it, so a crash leaves the intact previous save. in-place: the write overwrites the only copy, so a crash mid-write corrupts it. The rename is the atomic swap; the temp file must live in the same directory (filesystem) for it to be atomic.
Atomic save: temp file, fsync, rename
// Write a temp file in the SAME directory, flush to the platter, then atomically rename.
bool saveAtomic(const Path& dst, const Bytes& data) {
    Path tmp = dst.withSuffix(".tmp");     // same dir -> rename is atomic on the filesystem
    File f = openWrite(tmp);
    if (!f.writeAll(data)) return false;
    f.fsync();                              // force bytes to disk BEFORE the rename; flush() only hits the OS buffer
    f.close();
    return renameReplace(tmp, dst);          // POSIX rename(2) / Windows ReplaceFileW: the atomic swap
}
use std::io::Write;
// tempfile::persist() is the temp-then-rename; sync_all() is the fsync.
fn save_atomic(dst: &Path, data: &[u8]) -> std::io::Result<()> {
    let dir = dst.parent().unwrap();
    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;  // same filesystem as dst
    tmp.write_all(data)?;
    tmp.as_file().sync_all()?;                        // fsync BEFORE the rename
    tmp.persist(dst)?;                                // atomic rename over the real save
    Ok(())
}
Atomic write stops torn writes, not logical corruption

Temp-then-rename guarantees you never get a half-written file. It does not protect against a logically-invalid save, a migration bug that writes complete-but-wrong data. That is a different failure, covered by a checksum validated on load and a rolling backup slot (keep the previous save.bak). Name both: atomic write for torn writes, checksum plus backup for logical corruption. And fsync before the rename is not optional, flush() only moves data to the OS buffer; without the sync, a power-loss after the rename can leave the new file with stale contents[13].

07Saving live

An autosave fires mid-play. The save must be a consistent snapshot taken at a tick boundary, not mid-update with half the systems stepped. Capture the state at the end of a frame, then write it off-thread so the I/O does not hitch the game. The hard case is streaming: some cells are streamed out, their live objects do not exist in memory, so the snapshot merges the live objects' current state with the last-known persisted delta for the streamed-out regions.

A state save needs no determinism (unlike lockstep netcode)

This is the clean contrast with the netcode tutorial. Deterministic lockstep ships only inputs, so every machine must compute bitwise-identical results to stay in sync[14]. A save serializes state directly, so loading restores the world wholesale, no replay, no determinism requirement. The one exception is a replay-style save that stores an input log instead of state: that inherits lockstep's exact determinism constraint. State snapshots and command logs are different designs with different failure modes; do not conflate them.

What's intentionally missing

The production save system also needs: the streamed-out-region delta merge in full, save-thread synchronization, encryption and anti-tamper for competitive titles, cloud-save conflict resolution, per-chunk checksums, endianness byte-swapping for cross-platform saves (a PC save loaded on a console), and compaction of dead change-forms so a long playthrough's save does not grow without bound. The Asset Pipeline tutorial owns the byte-level format and endianness machinery this shares.

Wrong answers, and why: addresses cannot be pinned across runs and a bare index is fragile, so references are saved as generational handles and fixed up on load; an appended field needs a version branch or a tagged format, and refusing old saves discards progress; and a checksum detects torn-write corruption but only an atomic temp-rename prevents it.

08Pitfalls

Serializing raw pointersAddresses change per run. Save ids/handles; fix them up on load.
One-pass loadReferences resolve before their targets exist. Instantiate all, then fix up.
No version tagOld saves misread after a format change. Stamp a version; migrate.
Refusing old savesDeletes player progress. Migrate forward instead.
Editing a default after shipSilently rewrites the meaning of every old save's missing field.
Saving derived stateBloat plus a second source of truth that drifts. Recompute it.
In-place writesA crash mid-write corrupts the save. Temp + fsync + rename.
Checksum without atomic writeDetects corruption, does not prevent it. Do both, for different failures.

09What's next

That completes the persistence pair with Open-World Streaming: the world stays resident around the player, and the changes they make to it survive a crash, a quit, and a patch that changed the format. The full Build a Game Engine path, foundations through the 3D capstone, is on the series hub.

  1. WizardCell. "Unreal Engine Persistent Data Compendium." wizardcell.com. USaveGame as the base class for mutable game state, and the guidance not to persist rebuildable framework objects.
  2. Noel Llopis. "Managing Data Relationships" (2010), Games from Within. gamesfromwithin.com. Pointer values change run to run; the raw-pointer / array-index / handle encoding taxonomy and why handles win for serialization.
  3. "Automatic Serialization in C++ for Game Engines" (2022), IndieGameDev. indiegamedev.net. The two-pass load: instantiate all objects, then fix up references.
  4. cereal documentation, "Pointers." uscilab.github.io/cereal. Raw pointers are refused; shared references behind a shared_ptr are serialized once and re-linked on load.
  5. Unity Technologies. "ISerializationCallbackReceiver" and script serialization rules. docs.unity3d.com. Inline serialization does not support shared or cyclic references; flatten a graph to arrays of nodes plus parent indices (the id-reference pattern by hand).
  6. sinbad/SPUD (Steve's Persistent Unreal Data). github.com/sinbad/SPUD. Runtime-spawned actors are re-spawned on load rather than serialized as live objects.
  7. Christian Haase. "An Unreal Engine saving & loading system." medium.com. Delta serialization against the class default, only changed-from-default properties are written.
  8. UESP. "Skyrim Mod:Save File Format" and "Skyrim:Form ID." en.uesp.net. Change forms (deltas over the baked plugin data), three-byte save Form IDs, and the FF index reserved for save-local runtime objects.
  9. Casey Muratori, Handmade Hero, on serialization versioning (via Hacker News discussion). news.ycombinator.com. A version number in the struct plus a migration function, framed explicitly like database migrations.
  10. Protocol Buffers. "Language Guide (proto3)." protobuf.dev. Unknown fields are ignored and missing fields defaulted; never reuse a field number and reserve deleted ones.
  11. FlatBuffers. "Schema Evolution." flatbuffers.dev. New fields only at the end of a table; fields are deprecated, never deleted; changing a default rewrites the meaning of prior data.
  12. Epic Games. "Versioning of Assets and Packages in Unreal Engine." dev.epicgames.com. GUID-based custom versions for independent format tracks; a removed field is ignored and a new field holds its default.
  13. Elijah Lopez. "Avoid Data Corruption by Syncing to the Disk." blog.elijahlopez.ca. The write-temp, fsync, atomic-rename sequence, and why flush() is not enough without fsync. (POSIX rename(2) is atomic within a filesystem.)
  14. SnapNet. "Netcode Architectures Part 1: Lockstep." snapnet.dev. Lockstep ships only inputs and requires bitwise-identical simulation; a state-snapshot save restores directly and needs no determinism.

See also