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.
- Dynamic typing is great, but it's too easy to get wrong. Wrong shapes and unvalidated data slip through and bite at runtime.
- It's full of footguns for non-coders. A designer's small mistake becomes a bug that surfaces in QA.
- Lua lacks rich control flow, and enums. No switch statements, hand rolled enum hacks... ughh.
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.
- Types you add only where they pay for themselves.
- Persistable memory partitions, which can even move between machines.
- A proper language server and debugger, on day one.
- Ability to remove code at compile time based on flags
- Strong support for data types required for games
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.
- Crash recovery, instantly: snapshot the live world and restore it byte-for-byte — the autosave you never have to build.
- Time-travel debugging: keep snapshots as the game runs and jump back to any of them; because execution is deterministic, the rewound run replays exactly.
- Sims & ML: fork one world into thousands of independent variants from a single seed.
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.
- A thousand idle agents = a thousand blobs in object storage, around 100 KB each — not a thousand warm processes at tens of MB apiece.
-
It works mid-call. Parked on a 30-second model
request? Freeze anyway. When the answer lands, the host
wakes the session with the result: the script
resumes in its
Ok(answer)arm as if it had never been frozen. -
And it fails safe. If nobody holds the answer,
the call resumes
Error("interrupted")and is never re-issued — the same at-most-once rule migration follows.
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.
-
--allow-fs=DIRroots the filesystem at one folder...can't climb out. -
--allow-net=hostsopens HTTP and TCP to an allow-list, nothing more — the calls a script makes out. -
--allow-serve=ADDR:PORTis the separate, inbound authority: it lets a script be a server on that one address, and nothing else can listen. -
--allow-ttylets a script read the terminal:sys.readlineto ask a question,sys.readpasswordto take one without echoing it. A spawned partition never inherits it. -
--max-opsand--max-memstop a runaway or hostile script cold.
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
- Data crosses, code doesn’t. Not a function, not an actor reference.
- Fuel and memory are bounded, and the fuel limit can’t be caught from inside.
-
No setting relaxes any of it — and the sandbox never
receives
--allow-sandbox.
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}
-
scua schema tool.scua --type Args— JSON Schema for MCPinputSchemaand friends. -
scua pack --tier=compact— a generated teach-pack so a model can write SCUA first try.
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.
- Attach your own sink to route logs wherever your engine wants them.
- Opt into timestamps and structured fields when you need them.
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
--checkfor 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.
- Today: macOS, Linux, and WebAssembly.
- Coming: iOS and Android.
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.
- Evaluation — get SCUA into developers' hands and act on what they hit.
- Language — keep sharpening the language and the standard library.
- Performance — push the JIT further, more of the language on the fast path.
- Reach — iOS and Android, then the consoles.
- Public beta — open it up once it's ready.
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.