SCUA

How-to

Evolve your saved data safely

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), sign hash.sha256(blob) of the encoded blob instead of durable.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.

  • 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 Error and is never re-issued, so you reconcile against the durable side rather than blind-retry.