Skip to content

Snapshots

Sometimes you need to save the world: capture every entity’s current field values, write them to disk, and reload them the next time the server starts. golem-engine’s snapshot system handles all of that—serializing entity state, detecting when your schema changes between a save and a load, and rebuilding live entities from the stored bytes.

Call SaveSnapshot on the server at any point—from inside OnTick, from an admin command, or on a schedule. The call returns immediately; the write happens in a background goroutine so the game loop never pauses:

errc := srv.SaveSnapshot(synced.SchemaFingerprint, "world.snap")
// Check the result asynchronously—or block if you're on shutdown path:
if err := <-errc; err != nil {
log.Printf("snapshot failed: %v", err)
}

synced.SchemaFingerprint is a constant generated by golem-bake from your entity schemas. It is embedded in the snapshot file header and checked on load so stale files are rejected before any entity state is touched.

The path is passed directly to the OS and resolves relative to the working directory of the running process—typically wherever you launched the server binary from. You can use any absolute or relative path. The directory must already exist; SaveSnapshot and Load do not create missing directories.

The file is written atomically: the server encodes everything in memory, writes a <path>.tmp file in the same directory, then renames it to the final path. A partial write (crash mid-way) cannot produce a corrupted snapshot.

Some entities should not be persisted, such as enemies that respawn from static spawner data or short-lived projectiles. Mark the entity type in its YAML schema with persistent: false:

entity: Projectile
persistent: false
vars:
speed:
type: float
sync: tick
tag: 1

The snapshot system checks this automatically and skips those entities. Entity types that do not set persistent at all default to included.

If you wrap generated SyncedX types in gameplay-owned entity types, register those wrappers once during startup, typically in an init() function in the wrapper package:

func init() {
synced.RegisterMonsterWrapper(func(sm *synced.SyncedMonster) golem.Entity {
return &monster.Monster{SyncedMonster: sm}
})
}

Then load the snapshot before the tick loop starts:

err := srv.LoadSnapshot("world.snap", synced.SchemaFingerprint, synced.RestoreEntity)
if errors.Is(err, snapshot.ErrFingerprintMismatch) {
log.Fatal("snapshot was created with an incompatible schema — delete it and start fresh")
}
if err != nil {
log.Fatalf("could not load snapshot: %v", err)
}

srv.LoadSnapshot calls snapshot.Load, restores each entity via the provided function, registers it with its original ID using CreateEntity, and advances the entity ID counter past the highest restored ID — all in one call.

synced.RestoreEntity is the generated helper that reconstructs a SyncedX struct for each entity type in your schema. If you register a per-entity wrapper with synced.RegisterXWrapper(...) before loading, RestoreEntity returns that wrapper instead of the bare synced struct. Entity types not found in the current schema are silently skipped, which is the expected behavior when a type has been removed.

Wrapper registrations are package-global generated hooks, so register every wrapped entity type once during startup, ideally in init() functions alongside those wrapper types. Passing nil to RegisterXWrapper clears a previously registered wrapper.

Every time you run golem-bake, a binary-compatibility fingerprint of your entity schemas is computed and emitted as synced.SchemaFingerprint. It covers only the things that can silently corrupt a snapshot on load: entity type names and the tag → type mapping for each variable.

When you load a snapshot, the stored fingerprint is compared to the current constant. If they differ, snapshot.Load returns snapshot.ErrFingerprintMismatch immediately—before any entity data is decoded.

Schema changeInvalidates snapshot?Why
Rename a variable (same tag, same type)NoBinary encoding is identical
Add a variable (new tag)NoAbsent fields load as zero values
Remove a variableNoLeftover bytes for that tag are discarded
Change sync modeNoDoesn’t affect encoding
Change persistentNoOnly affects future saves
Rename an entity typeYesRestoreEntity dispatches on type name
Change a tag numberYesOld bytes decode to the wrong field
Change a variable’s typeYesSame tag, wrong wire interpretation

There is no migration support. If the fingerprint does not match, delete the snapshot and rebuild world state from scratch (or from a different source, like a seed file).

See Entity schemas for tag and persistent schema keys.