SCUA
1 / 1
→ or swipe · code runs live
games taught us this

SCUA

Nvidia's graphics cards were built for games and ended up running AI. What games learned about live state and sandboxing is what agents need now. SCUA is that language, built out of shipping them.
Ed Morley — CTO, 25 years in games.
Unabated Gamesunabated-games.com · LinkedIn
Press → or swipe to begin.
▸ Every snippet on these slides runs live, right here in your browser.
the problem

Scripting makes or breaks development velocity in games.

Almost every game grows a scripting layer, so designers can build, data can be defined, and costly rebuild cycles are avoided. The language is usually Lua, which as a small highly embeddable scripting language, is easy to learn and use. But it carries a lot of footguns.
None of this is the designer's fault, or even really Lua's. It's just a product of its time.
what i wanted instead

Lua's ease. Without the surprises.

So I built SCUA. The parts I love about Lua stay: it drops into a C or C++ host, and people who don't write code can still write gameplay. The parts that bite, I am fixing.
All of it built to get out of your way, with as little friction as I could manage, on every platform.
the tour

What we'll cover

The basics
types, control flow, functions, errors
Partitions
isolated state that persists, moves, and hibernates
Messaging
tell and ask, no shared state, no races
Durable saves
typed, versioned, validated on load
Contracts
rules that tell you why they failed
Enums & matching
named states, matched by name
Money & big numbers
exact decimals, unbounded scale
Gamedev math
vectors, quats, matrices, built in
Objects & interfaces
no class ceremony
Safe I/O, sandboxing & agents
grants, budgets, untrusted code, one-shot tool shape
Embed in C/C++
one lib, one header
LSP & debugger
VS Code and Zed
Performance
Lua-pace interpreted, LuaJIT-pace with the JIT
Everywhere
macOS, Linux, WebAssembly
the basics · types

Typed where it helps. Loose where it doesn't.

The pain
Dynamic languages let typos and wrong shapes through until runtime. Static ones make you spell out types everywhere, even for a quick script a designer just wants to try.
SCUA
Annotations are optional and erased at runtime. Add them on the data that matters; leave the rest loose. A wrong type is caught before the game runs, never changes behaviour.
types.scuaeditable
the basics · loops

Looping that doesn't fight you.

The pain
Lua counts from 1, so off-by-one bugs come from the language itself — and you're always translating against a 0-based world around it.
SCUA
Zero-based, like the rest of your stack. Walk a collection and get the index and value together, or count over a half-open range.
loops.scuaeditable
the basics · enums & matching

Give your states real names.

The pain
A status kept as a loose string or a magic number. Easy to misspell, easy to compare wrong, and nothing tells you the full set of values it can hold.
SCUA
An enum spells out the possible states up front. match dispatches on them by name and reads like a table of cases, instead of a chain of string or number checks scattered through the code.
state.scuaeditable
the basics · pattern matching

Match can see inside the value.

The pain
A C-style switch compares one value and stops there. It can't look inside the thing you matched.
SCUA
A pattern can test a literal, read a variant's payload, and pull its fields apart, all in the same arm.
input.scuaeditable
the basics · functions

Multiple return values, unpacked in a line.

The pain
Returning "a result and whether it worked" usually means an out-parameter, a tuple type, or a throwaway struct.
SCUA
Return several values, bind them in one line. Type the signature if you want the checker watching.
functions.scuaeditable
the basics · errors

Failures are values. Bugs are caught.

The pain
When errors are exceptions, any call can throw one, and you only find out at run time. Lua's pcall can catch it, but it wraps each risky call in a closure and a status check.
SCUA
Two clear paths. A failure you expect is an Ok/Error value, passed up with ?. A real bug is a fault you catch with try … rescue, and the program keeps running.
errors.scuaeditable
the basics · modules

Split your code, and pull in batteries.

The pain
Left to grow, a script becomes one long file: everything in one place, hard to navigate and hard to reuse.
SCUA
import pulls in a module's exports — whether it ships in the box (list, str, json, math, and more) or it's a file of your own.
scores.scuaeditable
state · partitions

A partition is a world of its own.

The idea
A partition holds one self-contained bundle of state: an entity, a match, a player's whole world. Nothing outside can reach into its memory, and anything you send to it is copied, never shared.
Why it matters
No shared mutable state means a whole class of races can't happen. And because the state is laid out position-independently, the entire partition serialises to a flat blob, with no code from you.
isolation.scuaeditable
state · persistence

Snapshot, fork, rewind. For free.

A partition's whole state is position-independent bytes, so the host can snapshot it to a flat blob and bring it back exactly — no serialization code to write, no schema to keep in sync. That one capability quietly solves a pile of hard game problems.
Snapshotting captures the state inside your partitions, on the same build. A save file that must survive the game updating — new fields, renamed records — is the durable format's job, coming up.
state · dev ops

It can even move between servers.

Those same bytes travel. Serialise a player's partition on one instance, send it, and rehydrate it on another, mid-session. You don't write a serialiser, and there's no schema to keep in step.
Instance A
player partition
player#42
hp75
gold1200
pos3, 8
level7
just the bytes
Instance B
awaiting…
player#42
hp75
gold1200
pos3, 8
level7
◆ 1.2 KB blob
Drain a node for deploy, rebalance load, follow a player across regions. A network call that was in flight when you snapshot travels too: it resumes on the new node as Error("interrupted") and is never re-issued behind your back, so a half-finished POST can't be sent twice.
state · hibernation

An idle session can cost nothing.

Most sessions spend their lives waiting for the next message. Those same relocatable bytes mean you don't keep a process warm for that: freeze the session to a blob, drop it from memory, and bring it back when something happens. The script never finds out it was gone.
Freezing takes a fraction of a millisecond to a few milliseconds depending on session size; waking is sub-millisecond plus a script recompile. Host-driven through the C ABI — see "Hibernate a session" in the manual.
state · messaging

They talk by message: tell and ask.

Partitions never share memory, so they pass messages. tell fires and forgets; ask waits for a reply, or gives up after a timeout. Request and response, without callbacks or promises.
actors.scuaeditable
state · fan-out

Run many at once with wait_all.

The same idea, now for I/O. Hand wait_all a list of jobs and it runs them together, then gives you back every result in order. No async, no await, no promise colouring creeping up your call stack — the gap Promise.all tries to paper over. One job failing never cancels the rest; its Error just sits in that slot.
fanout.scuaeditable
Need a fixed number in flight at a time? map_all(xs, f, { concurrency = 8 }) maps over a whole list under a cap. Both wait for everything to settle.
state · serving

SCUA can be a server.

Hand http.serve a handler and the host does the rest — it accepts connections, terminates TLS, frames the HTTP. Your script owns one function: a request in, a response out. It only exists under --allow-serve; without the grant the name isn't there to call.
server.scua
$ curl localhost:8080/healthok
req is { method, path, headers, body: bytes }; the response body is bytes too. Run it with --allow-serve=127.0.0.1:8080 and nothing can listen anywhere else.
data · records

Data with a shape you can count on.

A Lua table is a free-for-all: any key, any type, added anywhere. A record pins the shape down, named fields, types where you want them, defaults filled in, while keeping that same lightweight feel. Now the structure is known up front, to you, your editor, and the compiler.
player.scuaeditable
data · durable saves

Saves that survive your code changing.

The relocation blob is for moving live state. The durable format is the other half: a typed, versioned save you reload weeks later, after your records have moved on. Loading it back into a type validates and migrates every level of the data, all the way down a tree, so stale or corrupt fields are caught where they live instead of crashing you later.
save.scuaeditable
Old saves keep loading as fields are added (defaults) or reshaped (a migrate hook) — and a tamper-evident signature is a couple of lines more.
data · visibility

Send each client only what it should see.

A server holds the whole truth; a client should get a cut-down view. Mark the fields that must never cross the wire with a gate, and project builds the safe copy. No hand-written "remember to strip these" code to get wrong — the secret simply isn't in the bytes you send.
gates.scuaeditable
Gates strip the marked fields at every depth — nested records and lists too. Open by default, with a static report of who can see what.
data · localization

One config, in every player's language.

A live-ops event, a shop, a reward — write it once with placeholders, then render bakes the exact payload a player should see. Localized strings live next to the data, collapse to the active locale, and pick the right plural form per language.
reward.scuaeditable
The source names abstract slots — never the player's shape — so it stays portable. Locale-aware dates and gender are built in too.
data · contracts

Contracts put rules around your data.

The pain
"Can the player finish this level?" The rules end up scattered across gameplay code. A validator stops at the first failure, so you can't show a checklist. And the UI ends up duplicating the same logic to draw the objectives.
SCUA
Write the rules once as a named contract. Calling it returns a report of every clause, passed or failed, each with a reason, so the gameplay check and the objective panel read from one source. And since it's just a named value, you can swap a different contract in for a test or a dev build.
data · contracts

One rule set. A full report.

Here LevelComplete checks a player against a world: enough gold, boss down, under par time. Calling it hands back every clause with its pass/fail and reason, ready to gate progress or draw the objective checklist.
objectives.scuaeditable
data · contracts

And a fast yes/no when that's all you need.

matches runs the same clauses but skips the report and the allocation. Cheap enough to ask every frame, for every entity: "can this thing sprint right now?"
sprint.scuaeditable
data · contracts

Or pin the rule to the data itself.

Attach a contract to a field with where, and it's checked the moment data crosses into that type: a save load, a message from the host, a typed binding. Bad data is rejected right there, with a clear reason, instead of corrupting something downstream. Run it and watch the load get refused.
health.scuaeditable
numbers · money

Currency that doesn't lose pennies.

The pain
Floats can't hold 0.1 exactly. Add a few in-game purchases or a soft-currency balance and the totals drift. It's the oldest bug in game economies, and in finance.
SCUA
Write 4.99d for an exact decimal, or 19.99 USD for real money that carries its currency. It won't add euros to dollars or a bare number to a price, and it totals to the penny. Good for game wallets, and for the business apps you'll inevitably build around your game.
wallet.scuaeditable
numbers · scale

Numbers that don't run out.

The pain
Idle and incremental games blow past a 64-bit integer in an afternoon. After that you're hand-rolling a bignum or watching the score wrap to nonsense.
SCUA
big(...) handles whatever scale your prestige loop reaches, with the operators you'd expect. 2⁷⁰ is already past a sextillion; a googol (10¹⁰⁰) is no trouble either. It won't overflow, and there's no library to pull in.
idle.scuaeditable
gamedev math

Vectors, quaternions, matrices. Built in.

Not a library you import and wire up. Dot, cross, normalise, rotate, transform: these are values the VM understands.
math.scuaeditable
expressions

Conditionals are values.

The pain
Lua's cond and a or b trick quietly returns the wrong thing when a is false or nil. The honest version forces a mutable variable and a four-line if.
SCUA
if and match produce a value, so you assign or return the result directly. You skip the throwaway variable, and the bug that usually rides along with it.
expr.scuaeditable
compile-time

Build-time config, not runtime branches.

The pain
"Pro vs free", "debug build", "this platform only": these usually become runtime ifs that ship in every build, or a mess of preprocessor #ifdefs.
SCUA
comptime if picks a branch at build time from flags you pass in. The branch you don't take is physically removed. No runtime cost, and the dead code can't even reference things this build doesn't have.
scua -D tier=pro game.scua
output pro edition
objects

Methods, without the class ceremony.

The pain
Most languages make you declare a class, a constructor, and a self just to give a bit of data some behaviour.
SCUA
A function whose first parameter is a record becomes a method you can call dot-style on it. No class to declare, no constructor, no self.
objects.scuaeditable
interfaces

One function over many types.

A sword and a potion are different records. Both have a describe method, so both satisfy the Item interface, and one show function handles a mixed bag of them. You never wrote a base class or planned a hierarchy.
inventory.scuaeditable
safety

No file or network access, unless you grant it.

The pain
Giving scripts the full standard library is trivial when embedding Lua. Safe sandboxing via a custom _ENV and removing io/os requires manual work and is easy to get wrong.
SCUA
A script can't touch files or the network unless you grant the capability. Without a grant, import fs is a compile error, not a runtime surprise.
safety · untrusted code

Low-trust code can run in a sandbox.

Player scripts and UGC, workshop mods, a rule a model just wrote.
mods.scua
import sandbox

-- source text from a workshop mod, or a model's answer
let rule = "fn main(hit) return hit.damage * hit.combo end"

match sandbox.run(rule, { damage = 12, combo = 3 })
  Ok(score) -> print(`score: {score}`)
  Error(e)  -> print(`rejected: {e.kind}`)
end
stdoutscore: 36
safety · untrusted code

The sandbox holds nothing.

No files, no network, no environment, no clock, and none of your program’s capabilities however many it holds.
mods.scua
-- this program holds fs; the sandbox does not
match sandbox.run("import fs  fn main(i) return 1 end")
  Error(e) -> print(`nosy mod: {e.kind}`)
end
stdoutnosy mod: compile_failed
safety · agents

One-shot tools an agent can rely on.

stdin or CLI flags in, one JSON result out, diagnostics on stderr, a chosen exit status. The same record declaration validates args and emits a JSON Schema for callers.
tool.scua
import sys

record Args {
  a: int where a in 0:1000000,
  b: int where b in 0:1000000,
}

let args = sys.parse_args(Args)
sys.emit({ ok = true, sum = args.a + args.b })
stdout{"ok":true,"sum":5}
observability

Logging that vanishes when it's off.

Five levels, trace up to severe. Set a floor and everything below it is removed at compile time, arguments and all, so a disabled log costs nothing.
scua --log=info game.scua
output[info] player Mara joined [warn] hp low
embedding

Drop it into your engine. One lib, one header.

Your engine owns the loop. SCUA runs scripts and holds their state. Set inputs, run a script, read the results back, save the partition, all from C or C++.
host.c
output hp=75, saved 42056 bytes
tooling

A language server and a real debugger.

  • LSP: inline errors, hover types, go-to-definition, find references, rename, format on save.
  • Debugger over DAP: breakpoints, step in/over/out, the call stack, and variables.
  • scua fmt: one canonical format, with --check for CI.
  • scua test: a test runner in the box.
  • scua schema / scua pack: JSON Schema for tool records; a teach-pack for models.
  • Editors: VS Code and Zed extensions ship with it.
The stuff you expect from a modern language, that scripting usually skips.
portability

Easy to ship. Easy to embed.

SCUA travels as one self-contained static binary, no runtime to install and no shared libraries, or as a library you link straight into your engine. Either way: no awkward build steps, and nothing to vendor. Ease of integration is the whole point.
Every "▶ Run" on these slides was SCUA, compiled to WebAssembly, running in your browser.
performance · jit

Friendly to write. Fast when it counts.

Scripting usually means trading speed for ease. SCUA doesn't ask for the trade. The interpreter on its own matches or exceeds Lua's — the bar scripting interpreters are measured against. Python isn't close, on either tier. Flip on --jit=on and hot loops compile to native code, on both ARM64 and x86-64, and it's the same story against LuaJIT.
Interpreted
matches or exceeds the Lua interpreter
JIT on
the same story against LuaJIT
Same answers
byte-for-byte identical, on or off
The JIT is opt-in and deterministic, so you can toggle it without a second thought — and it compiles out of the build entirely on platforms that forbid runtime code generation. Leave it off and everything still runs, just interpreted.
what's next

The road ahead.

evaluating scua

Want to try it?

SCUA is in private evaluation now. If you build games, engines, or AI systems and this looks useful, I'd love to get it in your hands and hear what you think, especially what doesn't work.
Get in touch
Ed Morley