Game state outlives the code that wrote it. A player saves today, you ship a new build next week with a renamed field, and that old save still has to load. This page is about the part of that you control as a script author: designing your records and writing migrate hooks so old data turns into current data without breaking.
First, the boundary. SCUA organizes state into partitions, and the
host engine that embeds SCUA is what actually writes a whole
partition to disk and reads it back — relocating live state with no
work from you. For that path your script doesn't call "save" or "load"
itself (for writing your own save files from script, see the durable
module below). What your script decides is the shape of the
data and the rule for upgrading older shapes. When the host
loads an old partition, that data flows into your current records, and
the upgrade you describe here is what runs. The split is clean. The host
moves bytes; your records and hooks decide what those bytes are allowed
to become.
We'll simulate a load with an any-typed value standing
in for "data the host just read." Whatever crosses into a typed record
slot is checked once at that crossing, which is exactly what a real load
does.
#Additive changes are automatic
If the only thing you did between versions is add a field, and that field has a default, you don't have to do anything. Old data that lacks the field gets the default when it loads.
record Settings {
volume: int,
muted: bool = false, -- added in a later version
}
let old_save: any = { volume = 7 } -- written before `muted` existed
let s: Settings = old_save
print(`volume {s.volume}, muted {s.muted}`)
Run it:
$ scua settings.scua
volume 7, muted false
The old save has no muted. It loaded anyway, with
muted filled in from the default. That's the whole pattern
for additive evolution: new field, give it a default, done. (See Records and gradual types for
how defaults work.)
#Non-additive changes use a migrate hook
Renames, splits, and merges can't be inferred from a default, because
the data is in a different shape, not just missing a piece. For those
you write a migrate hook. It takes the old-shaped data and
returns the current record.
record Hero {
handle: string,
coins: int,
lives: int,
}
migrate Hero(old)
return {
handle = old.name ?? old.handle, -- renamed: name -> handle
coins = old.coins ?? (old.gold + old.silver), -- merged: gold + silver -> coins
lives = old.lives ?? 3, -- new field: 3 for old heroes
}
end
let v1: any = { name = "ed", gold = 500, silver = 20 }
let hero: Hero = v1
print(`{hero.handle}: {hero.coins} coins, {hero.lives} lives`)
Run it:
$ scua hero.scua
ed: 520 coins, 3 lives
Each field of the return handles one kind of change. The
?? operator is doing the work: it takes the left side
unless it's missing, then falls back to the right. So
old.name ?? old.handle reads name from a v1
save or handle from one that's already current.
old.gold + old.silver collapses two old fields into one.
old.lives ?? 3 gives heroes saved before lives
existed a starting value.
There's a sharp edge here worth stating plainly: a migrate hook must
return the complete current shape. Defaults are not
applied to a migrate's return. That's why lives is set
explicitly even though you might expect a default to cover it. The
hook's job is to produce a finished record, so name every field.
The hook only runs when the data isn't already in the current shape. Data that's already complete passes straight through:
record Hero {
handle: string,
coins: int,
lives: int,
}
migrate Hero(old)
return {
handle = old.name ?? old.handle,
coins = old.coins ?? (old.gold + old.silver),
lives = old.lives ?? 3,
}
end
let current: any = { handle = "ana", coins = 999, lives = 1 }
let hero: Hero = current
print(`{hero.handle}: {hero.coins} coins, {hero.lives} lives`)
Run it:
$ scua current.scua
ana: 999 coins, 1 lives
The values are untouched, so a save written by the current version costs nothing to load.
#The load is still a boundary
The migrate hook reshapes data, but it doesn't get to wave it through. Whatever comes out is still checked against the record before it becomes a trusted value. If you put a refinement on a field, a corrupt old save that migrates to a bad value is caught right at the load.
contract NonNeg(n) n >= 0 end
record Hero {
handle: string,
coins: int where NonNeg,
lives: int,
}
migrate Hero(old)
return {
handle = old.name ?? old.handle,
coins = old.coins ?? (old.gold + old.silver),
lives = old.lives ?? 3,
}
end
let corrupt: any = { name = "glitch", gold = -80, silver = 0 }
try
let h: Hero = corrupt
rescue err
print(`load rejected: {err.record}.{err.field} = {err.value}`)
end
Run it:
$ scua guarded.scua
load rejected: Hero.coins = -80
The migrate ran, produced coins = -80, and the
NonNeg refinement rejected it at the crossing. You catch
that as a fault with the record, field, and value attached, so a bad
load is a thing you can log and handle rather than a silent corruption.
Contracts has more on refinements,
and Errors and faults on catching
faults.
#Saving from a script:
the durable module
The host-writes-the-bytes model above is SCUA's relocation
path — moving a whole live partition, byte-for-byte, with the same code
on both ends. For the other case — writing a save file
your script controls, that a future build with a changed schema must
still load — there is the durable module. It turns a value
into a typed, versioned binary blob and back, entirely from script:
import durable
record Player { handle: string, wins: int where wins >= 0 }
record Save { season: int, players: { Player } }
let state: Save = { season = 3, players = [ { handle = "ed", wins = 12 } ] }
let blob = durable.encode(state, 2) -- a `bytes` blob, stamped with schema version 2
durable.encode(value, schema_version?) preserves SCUA's
value kinds — int vs float vs
decimal vs big, plus string,
bytes, and key — which JSON can't. Its on-disk
layout is decoupled from memory, so you can reshape your records and old
blobs still load. durable.decode(blob) rebuilds the values,
and assigning the result to a typed record runs the same boundary as a
live load — migrating and deeply validating every
nested record and { Record } element:
match durable.decode(blob)
Ok(v) -> do
let loaded: Save = v -- migrates + validates; a corrupt nested Player faults here
print(`season {loaded.season}, {len(loaded.players)} players`)
end
Error(e) -> print(`not a valid save: {e}`) -- a corrupt/foreign blob is a value-level failure
end
A blob carries the version you stamped, readable without decoding the payload — handy for deciding how to upgrade:
match durable.version(blob)
Ok(v) -> print(`this save is schema v{v}`)
Error(e) -> print(e)
end
durable.encode/decode are pure — they only
turn a value into bytes and back, no capability needed. To
put a save on disk, write those bytes with the fs capability:
fs.write("save.scd", durable.encode(state, 2)), and read
them back with fs.read("save.scd"). A value with no durable
form (a function, a partition reference, a path, a vector,
an Ok/Error) is a fault on
encode, not a silent omission — lossy saves are how data
quietly corrupts.
The format is self-describing and stores field names verbatim, so it's not the most compact on its own; for large saves, compress the bytes before writing them (gzip/zstd in your host) — the format compresses very well, since repeated record shapes are highly redundant.
#Verifying a save: integrity and tamper-evidence
Two different worries, two tools — both built on the durable format and SCUA's byte-determinism.
Did the save→load cycle keep everything?
durable.verify_roundtrip(value) encodes, decodes, and
re-encodes, and checks the bytes match exactly. A match on
its result tells you whether anything was lost — a precise QA/CI guard
that most languages can't offer (their re-serialization isn't
byte-stable):
match durable.verify_roundtrip(state)
Ok(_) -> ... -- nothing lost
Error(e) -> ... -- a kind the format can't preserve, or a cycle
end
Did someone tamper with the save?
durable.digest(value) is a stable SHA-256 of the content
(and hash.sha256(bytes) hashes any bytes). On its own a
digest only catches accidents — a cheater can edit the save and
recompute the hash. For real tamper-evidence, sign the digest
with a key the player can't get. The crypto module does
Ed25519:
import crypto
import hash
import durable
let keys = crypto.keypair(server_seed) -- server-side; seed is unpredictable, host-supplied
let sig = crypto.sign(durable.digest(state), keys.secret) -- the server signs the save's digest
-- ... the client stores `sig` next to the save; on load it re-derives the digest and checks ...
let ok = crypto.verify(durable.digest(loaded), sig, keys.public) -- public key only: verify, can't forge
This is the thin-backend pattern: the client keeps the save on its disk; the server holds only the secret key (and signs each save). The client can verify but not forge, so a small team gets cheat-resistant saves without standing up real backend infrastructure. Three cautions the cryptography demands:
- The secret key must never reach the client — sign on the server.
- A bare signature doesn't stop a player restoring an older, still-valid save. To prevent that, include a server-tracked sequence number in what you sign and check it on load.
durable.digest(value)covers the content, not the version stamp the blob carries. If you need to authenticate the exact bytes on disk (including the schema version), signhash.sha256(blob)of the encoded blob instead ofdurable.digest(state), and verify against the bytes you read back.
SCUA gives you the primitives; your host owns the key and the server.
#Putting it together
Here is one program covering all four ideas: an additive default, a rename-and-merge migrate, a current-shape value skipping the hook, and a boundary rejection.
-- 1. Additive change: a new field with a default. Older saves missing it load fine.
record Settings {
volume: int,
muted: bool = false, -- added in a later version
}
let old_settings: any = { volume = 7 } -- saved before `muted` existed
let s: Settings = old_settings
print(`volume {s.volume}, muted {s.muted}`)
-- 2. Non-additive change: a rename and a merge. A migrate hook reshapes old data.
-- The hook returns the COMPLETE current shape.
contract NonNeg(n) n >= 0 end
record Hero {
handle: string,
coins: int where NonNeg,
lives: int,
}
migrate Hero(old)
return {
handle = old.name ?? old.handle, -- v1 stored `name`; renamed to `handle`
coins = old.coins ?? (old.gold + old.silver), -- v1 had `gold` + `silver`; merged to `coins`
lives = old.lives ?? 3, -- brand-new field; give old heroes 3
}
end
let v1: any = { name = "ed", gold = 500, silver = 20 } -- an old save
let hero: Hero = v1
print(`{hero.handle}: {hero.coins} coins, {hero.lives} lives`)
-- 3. Already-current data skips the hook (it's already complete).
let v2: any = { handle = "ana", coins = 999, lives = 1 }
let hero2: Hero = v2
print(`{hero2.handle}: {hero2.coins} coins, {hero2.lives} lives`)
-- 4. The load is a boundary: the result is still checked. Corrupt old data is caught here.
let corrupt: any = { name = "glitch", gold = -80, silver = 0 }
try
let h: Hero = corrupt
rescue err
print(`load rejected: {err.record}.{err.field} = {err.value}`)
end
Run it:
$ scua persist.scua
volume 7, muted false
ed: 520 coins, 3 lives
ana: 999 coins, 1 lives
load rejected: Hero.coins = -80
The takeaway for designing data that lasts: prefer additive changes
with defaults, because they need no code. When you do have to reshape,
write a migrate hook that reads the old fields with ?? and
returns every field of the current record. Put refinements on the fields
that have invariants, so a bad load fails loudly at the boundary instead
of leaking a broken value into the rest of your game.
#Related
- Write handlers
that survive snapshots — the companion case: a partition snapshotted
while a request is in flight. A parked I/O call resumes as an
Errorand is never re-issued, so you reconcile against the durable side rather than blind-retry.