SCUA

News

SCUA 0.37.0: load it, check it, save it

October 21, 2026

SCUA 0.37.0 checks data where it enters your program and where it leaves. Decoders now take a type, so json.decode(text, Save) hands you a checked Save, or an Error that says what was wrong and where. Encoders take one too, and a save that could not load again never gets written. Games get a folder of their own for saves and settings, user:, and scua init game starts a project that already saves the safe way. Typed code also got cheaper: an annotation checks the value it is given one level deep, so passing a game state with 50,000 entries to a typed function costs what passing an empty one does.

#Load with a type

Give a decoder the type you expect and it checks the data at every level, including nested records, list elements and where rules:

import json

record Item { name: string, count: int where count >= 0 }
record Save { level: string, gold: int, items: { Item } = [] }

fn load(text) -> Result
  let save = json.decode(text, Save)?
  return Ok(`{save.level}: {save.gold} gold, {len(save.items)} items`)
end

print(load("{\"level\": \"keep\", \"gold\": 30, \"items\": [{\"name\": \"key\", \"count\": 1}]}"))
match load("{\"level\": \"keep\", \"gold\": 30, \"items\": [{\"name\": \"key\", \"count\": -2}]}")
  Ok(v) -> print(v)
  Error(e) -> print(e.message)
end
$ scua load.scua
Ok(keep: 30 gold, 1 items)
json.decode(text, Save): value.items[0].count violates Item's where rule (got -2)

Bad bytes and data that doesn't fit both come back as an Error, so ? deals with a corrupt file and a save from a broken build the same way. The error names the path to the value, and e.at gives the same path from inside the value, ready to put after your own prefix. durable.decode, toml.parse and ini.parse take the type too.

For hand-written files, where a typo would quietly fall back to a default, ask for unknown keys to be refused:

import toml

record Pity   { hard: int = 90, soft: int = 70 }
record Banner { name: string, pity: Pity = {} }

let text = "name = \"Legends\"\n[pity]\nhrad = 80\n"
match toml.parse(text, Banner, { unknown = "refuse" })
  Ok(b) -> print(b.pity.hard)
  Error(e) -> print(e.message)
end
$ scua config.scua
toml.parse(text, Banner): value.pity.hrad is not a field of Pity; did you mean hard?

parse(value, T) does the same for data you already hold, such as a table from a host or a message. It is the new name for as(value, T), and it never changes the value you give it.

#Save with a type

json.encode(value, T) and durable.encode(value, version, T) check the whole value before they write anything. If something has crept in that doesn't fit, or that has no saved form, such as a function left in a field typed any, you get an Error naming it, and no bytes:

import json

record Item { name: string, count: int where count >= 0 }
record Save { level: string, gold: int, items: { Item } = [] }

let s: Save = { level = "keep", gold = 30 }
s.items.push({ name = "key", count = 1 })
print(json.encode(s, Save)?)

let ui: any = s
ui.items[0].count = -1
match json.encode(s, Save)
  Ok(text) -> print(text)
  Error(e) -> print(`not saved: {e.message}`)
end
$ scua save.scua
{"level":"keep","gold":30,"items":[{"name":"key","count":1}]}
not saved: json.encode(s, Save): s.items[0].count violates Item's where rule (got -1)

The game keeps playing and the last good save stays on disk. Where a typed encode is written, the compiler also warns about any field of the type that could hold something unsaveable, so the problem shows up while you are writing the code.

The literal pushed into s.items above is a real Item, with its defaults filled. A table literal written into a typed slot, whether pushed, inserted, stored in a field or passed to a typed parameter, is now built as that record.

#A folder for saves

--allow-user=ID gives a program the folder its platform keeps for that app: %APPDATA%\ID on Windows, ~/Library/Application Support/ID on macOS and ~/.local/share/ID on Linux. The program reads and writes it with user: paths, beside the folder --allow-fs grants. While you develop, --user-dir=saves uses a folder beside the game instead, so a test build never touches the saves of the copy you play.

scua init game puts it together: a small game whose progress.scua loads with a typed decode, saves with a typed encode under a temporary name and renames it over the old file, and comes with a test that saves a representative state.

$ scua init game tinykeep
$ cd tinykeep
$ scua --user-dir=saves main.scua
level start, 10 gold
saved
$ scua --user-dir=saves main.scua
level start, 20 gold
saved
$ scua test
3 passed, 0 failed (1 file)

#Typed code that costs the same at any size

A type annotation on a parameter, a return or a let checks the value as it crosses: its kind and, for a record, each of its own fields. It no longer walks every nested record and list element. On a native x86 build, handing a typed function a state with a 50,000-entry ledger took 573 µs before this release and takes about 3 ns now, and a tick of ten typed systems over that state went from 8.3 ms to 0.3 µs. Data that comes from outside your program gets checked all the way down once, where it arrives, by a typed decoder or parse, and typed code trusts it from then on.

To check everything at every annotation while you test, run with --check=deep, or scua test --check=deep. It only adds faults, so code that passes with it passes without it.

#Upgrading

  • as(value, T) still works and warns. scua fmt --fix file.scua renames every call in a file to parse(value, T). The release after this one refuses as.
  • A typed let of loaded data no longer checks nested records. The compiler warns where that happens and suggests the typed decoder or parse.
  • Fault messages from a typed crossing now name it and the path, so code that matched the old text needs updating.

The changelog has everything else in 0.37.0, including faster float loops on x86-64 and a --serve-check for checking a server in CI without starting it.