SCUA

News

SCUA 0.18.0 is out

August 26, 2026

The biggest release since the JIT. You can run low-trust code in a sandbox, enum values say what they are instead of printing as numbers, and a saved session survives you editing the program that made it.

Two file formats changed in ways that break old files. Both are called out below. Both were affordable only because neither has shipped to anyone yet.

#Run code in a sandbox

import sandbox

let rule = sandbox.actor(user_supplied_source, { score = 0 })
let result = rule.ask("on_hit", { damage = 12 })

import sandbox compiles a source string into a fresh partition that holds nothing. No files, no network, no environment, no clock, and none of your program's capabilities however many it was granted. sandbox.run(source, input) gives you a one-shot answer. sandbox.actor(source, state) gives you an ordinary actor to tell and ask, for code you call repeatedly: a mod, an entity behaviour, a rule that keeps score.

This is for the code you cannot vouch for. Player-authored scripts and UGC, mods from a workshop, a rule an LLM just wrote, a formula a customer typed into a field. All of it is code you want to run and cannot read first, and the usual answer is either a review queue or a language you have to lock down yourself. Here the confinement is the default and there is nothing to remember to switch on.

What crosses the boundary is data. Numbers, strings, arrays, records, tagged values. A function cannot cross in either direction, because inside a sandbox a function is an index into its code and would mean something else entirely in yours. Nor can an actor reference, which would let the code inside send messages with your permissions.

Work, memory, source size and function count are all bounded, with defaults sized for something called every frame rather than a long-running script. Code inside cannot catch the work limit. Failures come back as a record with kind, line and message, because "the code is wrong", "it's too slow" and "it tried to hold too much" want three different responses.

There is no setting that relaxes the isolation. The tier is decided by which function you called, so there is nothing to misconfigure.

#Enum values know what they are

enum Colour { Red, Green, Blue }
print(Colour.Green)        -- Colour.Green, not 1

A flags mask says Perm.Read|Perm.Exec instead of 5, and it stays a mask. Combining, testing and removing flags all hand back the flags type, where before it collapsed to a plain integer the first time you touched it. The names show up in logs, in tostring, in interpolation, inside printed arrays and tables, and in error messages.

The number is still there whenever you use a variant as a position. tiles[dir] indexes, dir + 1 counts, Monster = 52 and Read = 1 << 0 mean what they always did, and .to_int() hands you the integer for a wire or a C boundary. One sentence covers it: an array index is a position; a table key is a value.

Arrays of variants stay packed, so a tile grid costs what it did when a variant was an integer. 200,000 values in 5.62 MB, the same as an array of ints.

Three things changed behaviour. Colour.Green == 1 is now false, and a compile error in typed code that tells you to write .to_int(). t[Colour.Green] and t[1] are different table keys. Printed output changed, so a test asserting on "1" will now see "Colour.Green".

Enum.variants() lists every variant in declaration order, and e.variant_name() / e.enum_name() give you the strings. Enum.from_int(n) checks its argument now instead of manufacturing a value that matched nothing; try_from_int answers nil when a number from outside may legitimately be unknown.

#Default values for parameters

fn greet(name, greeting = "Hello")
  print(greeting .. ", " .. name)
end

greet("Ada")             -- Hello, Ada

The rule is one sentence: a default fills the argument when it is missing or nil. So a parameter with a default is never nil inside the body, and a value that might not be there picks the default without an if at the call site. Only nil triggers it. false and 0 are values and pass straight through.

The default is an ordinary expression evaluated on each call that omits the argument, so fn f(xs = []) gives you a fresh array every time rather than one shared list. Defaults run left to right in the function's own frame, so a later one can use an earlier parameter: fn slice_from(s, start, stop = len(s)).

#Saves got three times smaller

A typed, versioned durable save of a five-field record used to cost about 88 bytes. It now costs about 28, which is smaller than the same record as JSON, smaller than a Python pickle of it, and still carrying the types neither of those keeps. Numbers and lengths are stored in as few bytes as their value needs, and a field name is written once per save rather than once per record. Saving and loading both got faster.

More kinds of value can be saved too: money, frame data tables, vectors, matrices, quaternions, colours, the geometry types, localized strings, and compiled regexes. The one that mattered most was a frame with a money column, the schema the data-table guide is built around, which previously could not be saved at all.

This is a format change and old saves do not load. A clean break rather than a compatibility layer, taken now because the format is pre-release. durable.digest values change for every value, so anything signed against an old digest needs re-signing.

#A saved session survives editing your code

For embedders using session hibernation. A saved session used to store each function by its position in the compiled program, so adding or removing a function anywhere in the script shifted those positions, and the session's handler resolved to whichever function had moved into its slot.

A session now records what each function is rather than where it sat. Adding, removing, renaming and reordering functions all leave saved sessions loadable, and a bugfix deploy binds them to the new code. Where a save genuinely cannot be honoured, the load refuses and names the function: "restock is not in this program, it was renamed or removed since the save was written".

One asymmetry is worth knowing. Editing the body of a named function is fine. Editing the body of an anonymous one invalidates saved references to it, because an anonymous function has no name to be recognised by, so a save cannot prove which of two lambdas it meant. Guessing is how you end up running the wrong one. If a function is stored in session state and you expect to edit it, give it a name.

Enum values in saves got the same treatment. A durable save records a variant by name, so reordering or renumbering a declaration no longer changes what existing saves mean, and renaming one is refused with a message that names it rather than reporting the file as corrupt. Cluster values work the same way, which matters most during a rolling deploy when two versions of your code are running at once.

#The rest of it

  • import stays inside the folder it is searched in. import "../other/thing" used to walk out of the importing file's directory and run a .scua file from anywhere on disk. To use modules from another directory, add it with --mod-path DIR, or depend on it by name with scua-pkg.
  • A deeply nested message can't take the process down. Copies into a receiving partition are capped at 256 levels, the same limit durable already used, and going past it is an ordinary catchable fault.
  • A data table owns its columns. Building a frame copies what you pass in, and column/column_names give you copies, so an edit to the source array no longer changes what the frame reports.
  • Past 255 locals in one function, the compiler names the binding that did not fit and suggests what to do, rather than panicking with a trace from inside itself.
  • tty = false in a capability file is obeyed under --allow-all, so "everything except the terminal" is a policy you can write.

#One note on the package manager

scua-pkg emits for scua 0.15 to 0.17, so package commands hold off until a scua-pkg release catches up with 0.18. It says so plainly rather than emitting something subtly wrong. Running programs never needs it.

The changelog has the full accounting.