SCUA is built to run inside another program: a game engine, a server, a tool. The host owns the process, the files, and the network; SCUA runs scripts and holds their state. This page shows how a C (or C-compatible) host drives the runtime: creating state, saving and loading it, running scripts, and exposing host functions to those scripts.
Everything here comes from the public C header
include/scua.h and the worked example
examples/embed.c, which builds, persists, reloads, and
scripts a partition end to end.
#The model
You link one static library (libscua.a) and include one
header (include/scua.h). The runtime is written in Zig, but
a host only ever sees C. Explicitly marked experimental sections of the
header are not yet stable ABI commitments.
For packed native results, the experimental output-buffer recipes cover fixed-length buffers, single-use prefix output, owned C allocations and callback-safe copying. Retained C-owned resources still use userdata and binding-specific update functions, not array adoption.
This page covers the "run a script, read the results" model — the
right fit for a tool or a request handler. If your host is a
game engine that owns the frame loop and wants to call
into the script each frame (update(dt),
draw()), read Drive a script from your
engine after this one: it builds on everything here and adds the
host→script call and the durable-handle API.
#Building against the library
Compile your host with include/ on the include path and
link the static library with your own C/C++ toolchain. The default
library is self-contained (no other dependencies) — with a few platform
notes. The optional -Dffi=true pilot additionally links
libffi; see Call native
libraries.
# macOS
cc -std=c11 -Iinclude examples/embed.c lib/libscua.a -o embed
# Linux — add -lm (the runtime uses the C math library)
cc -std=c11 -Iinclude examples/embed.c lib/libscua.a -lm -o embed
# Windows — link ws2_32 (the runtime uses Winsock); the library is lib/scua.lib
cl /I include examples\embed.c lib\scua.lib ws2_32.lib
./embed
(If you have this language's source checkout rather than the
distribution package, zig build embed builds and runs the
same example through the build system instead.)
The unit of state is a partition: a self-contained
graph of tables, arrays, and scalars. The host reads and writes it by
path, runs scripts against it, and can serialize the whole thing to a
flat blob. At this layer the host never holds a live pointer into the
partition. You address data by /-separated path string,
which keeps the host safe across garbage collection and reloads (the
data can move in memory without invalidating anything you hold, because
you hold nothing).
#Create and free a partition
For native objects containing C-side state, the source checkout also
has an experimental runtime-owned userdata API and
paired C/SCUA example: run zig build embed-userdata. See Bind runtime-owned userdata
for its lifecycle and current limits.
The source checkout also has experimental checked buffer
sessions for compiled bindings:
zig build embed-access runs
examples/embed_access.c. It demonstrates zero-staging-copy
F64 output, host read spans, copied error diagnostics, save refusal
while borrowed, and automatic cleanup of a deliberately leaked native
session. This path needs no libffi. It is P1 validation, not a supported
binding generator.
Prepare handles and convert arguments before
scua_access_begin_native; acquire exact-kind spans, call C,
then always scua_access_end before constructing results or
calling SCUA. No allocation, script entry, persistence or resource
destruction is allowed during the interval, even through another
partition on that thread. Native leaks become faults after automatic
release; host sessions require explicit release. Writes are not rolled
back on failure. See the experimental section of
include/scua.h for token, overlap, thread, alignment and
diagnostic rules.
After releasing a session, a native callback can use the experimental
scua_native_error_message(ctx, status, message, length) to
attach copied detail. It can enrich the first matching error once; later
errors cannot replace it. Supply valid UTF-8: the helper reads at most
512 bytes and truncates long messages at a character boundary with
.... Keep secrets out of messages. Empty detail retains the
static fallback. The private prefix-output builder used in runtime tests
is not a public C API.
For callback-capable compiled calls, the private
zig build embed-callback callback-exercises fixture
demonstrates an explicit scratch snapshot and copied fresh output. No
session or managed pointer crosses script re-entry. Callback mutations
affect the original, not the snapshot; faults discard output without
rolling back side effects. See the source checkout's examples/callback-exercises.md
for its abort policy and limits.
#include "scua.h"
scua_partition *p = scua_partition_new();
// ... use p ...
scua_partition_free(p);
#Read and write data by path
Writes create any missing intermediate tables. Reads safe-navigate: a
path that resolves to nothing returns SCUA_ERR_NOT_FOUND
rather than failing.
scua_set_str(p, "player/profile/name", "Ed", 2);
scua_set_int(p, "player/profile/level", 42);
scua_set_float(p, "player/stats/health", 99.5);
scua_set_bool(p, "player/flags/admin", 1);
int64_t level = 0;
scua_get_int(p, "player/profile/level", &level); // level == 42
int64_t missing = 0;
scua_get_int(p, "player/profile/missing", &missing); // returns SCUA_ERR_NOT_FOUND
Integers are exact 64-bit, so a large id round-trips without loss.
Strings copy into a buffer you provide; scua_get_str always
reports the full length, so on SCUA_ERR_RANGE you can
resize and call again:
char name[16];
size_t name_len = 0;
scua_get_str(p, "player/profile/name", name, sizeof(name), &name_len);
name[name_len] = '\0';
#Save and load
This is the part that makes SCUA worth embedding for stateful work.
scua_persist compacts the partition into a flat blob. The
host owns those bytes and writes them wherever it likes (a file, a
database, a network message). SCUA itself never touches the filesystem.
scua_load reinstates a partition from the blob, at a new
address, with no fix-up step:
uint8_t *blob = NULL;
size_t blob_len = 0;
scua_persist(p, &blob, &blob_len);
// write blob[0..blob_len] to disk however you want
scua_partition_free(p);
scua_partition *q = scua_load(blob, blob_len); // reinstated, possibly in a new process
scua_blob_free(blob, blob_len);
The blob is position-independent: cycles and shared references inside the partition come back intact, so you can save a player on one machine and resume them on another without writing any serialization code. That is the property the whole design is built around. See Core concepts for why it holds.
When the bytes pass through storage you don't fully control, sign
them: give the partition your deployment's keys with
scua_set_blob_keys, and scua_persist signs the
save; load it with scua_load_keyed, which refuses a save
that changed after it was written. See Signing
saves with your deployment's keys.
Records in the partition come back as the same record types, with
their methods, once you load a script over them with
scua_load_script: the blob records each record type it
holds by name, and the script's declarations are matched against it. If
the script changed one of those records (a field added, removed or
retyped, or the record renamed), scua_load_script returns
SCUA_ERR_FORMAT and its error text names the record and
both field lists. See Hibernate
a session.
#Run a script against the partition
A script sees the partition's data as a global called
world, the same graph the host reads and writes by path. So
the host sets inputs, runs the script, and reads the results back:
char errbuf[128];
scua_eval_file(q, "examples/embed_script.scua", 0, 0, errbuf, sizeof(errbuf));
// the script read `world` and wrote a result the host now reads:
int64_t score = 0;
scua_get_int(q, "player/profile/score", &score);
A script's return value comes back at the path
__return:
const char *expr = "let lvl = get(world, \"player/profile/level\")\nreturn lvl * 2\n";
scua_eval(q, expr, strlen(expr), 0, 0, errbuf, sizeof(errbuf));
int64_t ret = 0;
scua_get_int(q, "__return", &ret); // ret == 84
scua_eval and scua_eval_file are protected
calls. A lexer, parser, compile, or runtime error returns a
non-SCUA_OK code and writes a located message
(line N: ...) into errbuf; the host shows it
and carries on. A bad script can't crash the host:
const char *broken = "set(world, \"oops\", n + missing_var)\n";
if (scua_eval(q, broken, strlen(broken), 0, 0, errbuf, sizeof(errbuf)) != SCUA_OK)
fprintf(stderr, "script error: %s\n", errbuf);
A script can also stop on purpose. sys.exit(0) returns
SCUA_OK. A failure the script chose returns
SCUA_ERR_FAILED: sys.fail(reason),
sys.exit(n) with n not 0, or ? or
return Error(e) at the script's top level.
errbuf then holds line N: and the reason, or
exit N for a bare sys.exit. For a top-level
Error, __return holds the Error's
value, so the paths __return/kind and
__return/message read with the usual accessors. Nothing is
written to the host process's stderr. SCUA_ABI_VERSION is
1 from this release, because these calls used to return
SCUA_OK.
#Bound an untrusted script
Two arguments — mem_cap and op_budget — let
the host cap what a script may consume. Their 0 values mean
different things, so read this carefully for untrusted
input:
mem_cap = 0means unlimited (no memory bound). So for an untrusted script you must pass a positivemem_cap— the memory guard is off by default.op_budget = 0means the built-in finite default — the CPU kill-switch is always armed even at0.
mem_cap caps the extra live memory a script may hold,
counting every actor the script creates as well as its top-level code.
Over it, the call returns SCUA_ERR_NOMEM instead of letting
the script exhaust the host:
// a script that tries to grow without bound is stopped at the cap, not OOM-killed:
scua_eval(q, hog_src, strlen(hog_src), 64 * 1024, 0, errbuf, sizeof(errbuf)); // SCUA_ERR_NOMEM
op_budget caps the reduction steps (loop iterations and
calls) any single turn may run. When a script exceeds it, an uncatchable
kill-switch stops it with SCUA_ERR_BUDGET — so a runaway
loop is bounded in CPU, not just memory, and can't wedge the host
thread:
const char *spin = "while true do end\n";
scua_eval(q, spin, strlen(spin), 0, 100000, errbuf, sizeof(errbuf)); // SCUA_ERR_BUDGET
The default op_budget (selected by 0) is a
large but finite ceiling, so the kill-switch is always armed even when
you don't set one. Lower it for untrusted input; raise it for a script
you trust to run a long, legitimate computation. For a trusted,
host-owned loop that runs unbounded work each frame (a game's
update/draw), pass
SCUA_OP_BUDGET_UNLIMITED to disarm the kill-switch entirely
— a host-only, explicit choice (a script can never reach this argument,
so it can't disarm its own budget). The budget is per turn, so
it also bounds each actor or coroutine turn the script spawns, not only
its top-level body.
#Let scripts call host functions
The host can expose a C function under a name; the script then calls
it like any other function. The function receives a context it queries
for arguments and sets a result on, plus a userdata pointer
you supplied at registration:
static void host_add(scua_ctx *ctx) {
int64_t *calls = (int64_t *)scua_userdata(ctx);
if (calls) (*calls)++;
scua_return_int(ctx, scua_arg_int(ctx, 0) + scua_arg_int(ctx, 1));
}
int64_t calls = 0;
scua_register(q, "host_add", host_add, &calls);
scua_eval(q, "set(world, \"sum\", host_add(40, 2))\n", /*len*/ 32, 0, 0, errbuf, sizeof(errbuf));
// world.sum == 42, and calls == 1
Host functions are not part of a saved partition (they're raw process
addresses), so after scua_load you re-register them on the
new partition.
Registering a function on a partition is what gives that partition
the right to call it. Every actor the script starts takes that right
from whoever started it, so its handlers can call your functions too. A
sandbox the script starts with sandbox.actor or
sandbox.run never has it: it cannot reach your functions by
name, cannot be sent one, and a call that somehow reached one would be
refused with cannot call a host function here.
Which thread a host function runs on. Unless you
turn workers on, everything a partition's script does — its top level,
and every actor handler and task it starts — runs on the thread that
called into SCUA (scua_eval, scua_load_script,
scua_pcall and the rest), one piece at a time. So a host
function is only ever called on that thread, never from two threads at
once for one partition, and it can read and change your engine's state
without locking. The one way to get two at once is yours: if you drive
two partitions from two threads and register the same function on both,
it can run on both together. A host that wants its frame to bound the
script's work, or its own clock to pace it, drives the drains itself,
and one that wants the script's partitions on several cores turns workers
on; a function registered with scua_register_module
still runs only on your thread then.
scua_arg_bool and scua_to_bool give a
value's plain truthiness: 0 for nil and false,
1 for anything else. That includes an Ok/Error
Result and a flags set, which a script's own
if refuses with an error. A C function can't raise that
error, so these two keep the plain rule. When you need to know whether a
Result succeeded, look at the value itself rather than its truthiness;
the simplest way is to have the script hand you
r matches Ok, a real boolean.
When a binding exposes many functions (a whole graphics or
audio library), register them under a namespace instead
of as bare globals, so scripts call module.name and your
hundreds of functions don't crowd the global namespace:
scua_register_module(q, "gfx", "draw_rectangle", host_draw_rectangle, NULL);
// the script calls it as a field of the module:
scua_eval(q, "gfx.draw_rectangle(0, 0, 64, 32)\n", /*len*/ 33, 0, 0, errbuf, sizeof(errbuf));
A registered module is read-only to scripts, like a
built-in module: gfx.draw_rectangle = nil stops with an
error, in the main program and in every actor, because every piece of
code that uses gfx shares the one record. If you want
scripts to have a table they can change, pass a plain table as a value
instead (for example with scua_set_* into
world) rather than registering a module.
The entry script reaches gfx by name directly. Code in
an imported module brings it in the same way it would
the standard library — import gfx — so you can organize
host-API-using script code across files instead of cramming it all into
the entry:
-- draw.scua
import gfx
fn scene() gfx.draw_rectangle(0, 0, 64, 32) end
return { scene = scene }
Imports resolve relative to the entry file's own directory. When the
modules live elsewhere, a package's examples/ directory
importing the module in its parent, say, add the directory with
scua_add_module_path before running the script. It is the
host-side twin of the CLI's --mod-path:
scua_add_module_path(q, "/path/to/sqlite-package"); /* tried after the entry file's own directory */
scua_eval_file(q, "/path/to/sqlite-package/examples/query.scua", 0, 0, errbuf, sizeof(errbuf));
Directories are tried after the entry file's own directory, in the
order you add them. A module found in two of them is refused with both
named, as on the command line, rather than one silently winning. The
list is host-side state like a grant: it is not part of a save, so add
it again after scua_load. Pass NULL to clear
it.
#Use a project's dependencies
When the script belongs to a project with dependencies (a
scua.toml and a deps/ directory that
scua-pkg filled), point the host at the project with
scua_use_project. Imports then resolve through the
project's lock exactly as they do when you run the script with
scua:
char err[512];
if (scua_use_project(q, "/path/to/my-project", err, sizeof(err)) != SCUA_OK) {
fprintf(stderr, "project refused: %s\n", err); /* e.g. E-RECORD-HASH-MISMATCH: … */
return 1;
}
if (scua_eval_file(q, "/path/to/my-project/main.scua", 0, 0, err, sizeof(err)) != SCUA_OK)
fprintf(stderr, "%s\n", err); /* e.g. E-FILE-HASH-MISMATCH: … */
Every dependency is checked the way the command line checks it: the
lock has to be readable, each package's release record has to match what
the lock recorded, and each file a script imports has to match its
record. A file that has been changed since it was fetched is refused
with E-FILE-HASH-MISMATCH and never runs. The error codes
and messages are the command line's own.
- Return codes.
SCUA_OKwhen the project is in use. A project with nodeps/or no lock is alsoSCUA_OK: it has nothing vendored, which is howscuatreats it too.SCUA_ERR_NOT_FOUNDwhen the path is not a directory.SCUA_ERR_FORMATwhen the lock or a release record is refused, with the reason inerr. A failed call leaves no project in use, so the imports then fail to resolve rather than resolving unchecked. - A changed file is reported by the
scua_evalorscua_eval_filethat imports it:SCUA_ERR_FORMAT, withE-FILE-HASH-MISMATCHin that call's error buffer. - Order. The entry file's directory first, then the
project's dependencies, then any
scua_add_module_pathdirectories. - Calling it again replaces the project in use;
NULLstops using one. Like the module path list, it is not part of a save: call it again afterscua_load.
⚠️ Don't reach a dependency with
scua_add_module_path("…/deps/…"). It finds the
same files but checks none of them, so a vendored file someone edited
after it was fetched runs without a word, where
scua_use_project and the command line both refuse it.
A project's vendored dependencies are not used when you
embed. The scua CLI reads
deps/scua-deps.lock and resolves each locked package from
deps/<scope>/<name>/, checking every file
against the hash its release recorded. The embedding API does neither,
so an import of a vendored package that works under scua
fails in a host with "cannot resolve module". Until it can, add each
locked package's directory yourself:
scua_add_module_path(q, "/path/to/project/deps/scua/jsonschema"); /* one per locked package */
⚠️ That finds the same files but skips the check the CLI
makes on them. Under scua, a vendored file that no
longer matches the hash its release recorded is refused with
E-FILE-HASH-MISMATCH. Added as a module path, the same
tampered file runs, silently. Only point a host at a vendored tree you
know has not been modified since it was fetched.
#Expose constants
A binding often has named integer constants too — key codes, blend
modes, flags. Bind them as fields of the same module
record with scua_register_consts, and the script
reads gfx.KEY_SPACE as a plain field — no call, no
setup:
static const scua_const gfx_keys[] = {
{ "KEY_SPACE", 32 }, { "KEY_RIGHT", 262 }, { "KEY_LEFT", 263 },
};
scua_register_consts(q, "gfx", gfx_keys, sizeof(gfx_keys) / sizeof(gfx_keys[0]));
scua_eval(q, "set(world, \"k\", gfx.KEY_SPACE)\n", /*len*/ 31, 0, 0, errbuf, sizeof(errbuf));
// world.k == 32
There are single-value forms too
(scua_register_const_int / _float /
_bool). Constants are scalars only by design (a number
carries no authority into the sandbox), and they aren't part of a save —
re-register them after scua_load, just like host functions.
Registering a name already taken under the module (a function or another
constant) is rejected rather than silently overwritten. In a hot loop,
hoist a constant into a local (let RIGHT = gfx.KEY_RIGHT)
the same way you would local floor = math.floor in Lua.
If you ship a declarations file
so the editor type-checks gfx.*, include the constants
there too — scua_write_decls emits a typed
const NAME: <type> = value line for each
(int/float/bool, matching how it
was registered), which keeps the closed gfx namespace from
flagging gfx.KEY_SPACE as an unknown field.
#Hand the script an opaque resource
Sometimes a host function needs to give the script a native resource — a texture, a font, an open handle — that the script holds and passes back to later calls but never looks inside. Register a resource type (with an optional finalizer that frees it), return it from one function, and accept it in another:
static const scua_HostType TextureType = { "Texture2D", unload_texture /* finalizer */ };
static void host_load_texture(scua_ctx *ctx) { // returns an opaque handle
Texture2D *t = load_texture_somehow(...);
scua_return_handle(ctx, t, &TextureType);
}
static void host_draw_texture(scua_ctx *ctx) { // takes it back, type-checked
Texture2D *t = (Texture2D *)scua_arg_handle(ctx, 0, &TextureType);
if (t) draw(t);
}
From the script's side the value is opaque: hold it
in a variable or table, pass it to host functions that accept it —
that's all. type() on it returns "value", and
any other operation (field access, arithmetic) faults. The raw pointer
never lives in script memory; SCUA keeps it host-side and hands the
script a tagged handle, so a saved partition never carries a stale
pointer (re-create resources after scua_load, like host
functions).
#Give your editor the host API (declarations)
By default your editor can't see host functions — they're registered
from C, so hovering gfx.draw_rectangle in a script shows
nothing and the type checker treats gfx as untyped. You fix
that with a declarations file: a tooling-only
description of your host API that the language server and checker read
but never run (the model is TypeScript's .d.ts / Lua's
---@meta).
The easiest way to produce one is to let the host emit it from its
own registry. Register your natives with a signature and a one-line doc,
then call scua_write_decls:
scua_register_module_doc(p, "gfx", "draw_rectangle", host_draw_rectangle, NULL,
"draw_rectangle(x: int, y: int, w: int, h: int, color: Color)", /* signature (no `fn`) */
"Draw a filled rectangle."); /* one-line doc */
/* ... register the rest ... */
scua_write_decls(p, "gfx", "decls/gfx.scua"); /* writes a --!declare file for the `gfx` module */
That writes a file like:
--!declare gfx
-- Draw a filled rectangle.
fn draw_rectangle(x: int, y: int, w: int, h: int, color: Color) end
Point your project's scua.toml at it:
[tooling.declarations]
gfx = "decls/gfx.scua"
(The key on the left is just a free label; the namespace a script
actually sees comes from the --!declare gfx marker inside
the file.)
Now, in any script in that project,
gfx.draw_rectangle(...) hovers with its
signature and doc, jumps to its declaration, and is
type-checked — a wrong argument count or type is
flagged in the editor. The script is unchanged (it still calls the bare
gfx global your host registers), and nothing about runtime
changes: declarations never compile or run, and a project without them
simply gets the old untyped behaviour. (Add record
declarations to the file for any host types your signatures mention,
like Color above — by hand, or extend your generator.)
For an opaque resource the script holds but can't
look inside — a texture, a sound, a socket, a database connection (the
opaque handles above)
— declare a handle type. It has no fields and no
constructor; the script only passes it back to your functions:
--!declare gfx
handle Texture -- an opaque host resource
fn load_texture(path: string) -> Texture end
fn draw_texture(tex: Texture, x: int, y: int) end
Then load_texture returns a Texture,
draw_texture only accepts one, and the editor flags passing
the wrong handle type (a Sound where a Texture
is wanted) or reading a field off it (tex.width) —
mirroring what the runtime already enforces, with no runtime cost. Each
handle type is distinct by name.
A declarations file is for tooling only: running one with
scua is refused (it's not a program), and it grants no
capabilities or authority — it only tells the editor what your host API
looks like.
#The script/host boundary
A SCUA script cannot open a file, read stdin, make a network call, or
read environment variables. There is no ambient I/O in the language, on
purpose. Everything a script touches comes from one of two places: the
world data the host set, or a host function the host
registered. So a script is sandboxed by default, and the host decides
exactly what it can reach.
For a host, that means the integration pattern is always the same:
pull whatever the script needs into world (or expose it as
a registered function), run the script, then read the results back out.
The host does the I/O; the script does the logic. This is what lets the
same script run unchanged on a server, in a tool, or inside a game, and
it's why an untrusted script can't reach past the data you handed
it.
The script does get the pure standard library —
math, rand (seeded, deterministic),
str, json, the exact-number modules, and so
on. Those are safe by construction: no clock, no filesystem, no network.
What it does not get is anything that touches the outside
world, unless you grant it — which is the next section.
#Grant a capability: file access
When a script genuinely needs to read or write files, you grant it
the fs capability, rooted at a directory. After that the
script can import fs and read/write within that
directory — and only within it. Without the grant,
import fs is a compile error, so file access can never
appear in a script you didn't deliberately enable.
scua_grant_fs(q, "/var/data/tenant-42"); // root the capability at one directory
const char *src =
"import fs\n"
"match fs.read(\"config.json\")\n"
" Ok(text) -> set(world, \"config\", text)\n"
" Error(why) -> set(world, \"config_error\", why)\n"
"end\n";
scua_eval(q, src, strlen(src), 0, 0, errbuf, sizeof(errbuf));
The script reaches config.json under the granted root,
but fs.read("../other-tenant/secret") comes back as an
Error — .. and absolute paths are rejected, so
it cannot climb out. Root a different partition at a different directory
and neither can reach the other's. Pass "" to revoke.
The grant is host-side state: it is not saved by
scua_persist. After scua_load, re-grant the
capability (just as you re-register host functions) — capabilities are
dropped on persistence and re-bound by the host on reactivation, on
purpose: authority is a live decision the host makes, not something
baked into a blob. See Read and write
files for the fs surface from the script side.
fs isn't the only capability. Two more grants cover the
network, and every grant follows the same default-deny,
re-grant-after-load rule as fs:
scua_grant_net(p, hosts)grants the outboundnetcapability — thehttpclient (http.get/http.post) andnet.dial.hostsis a comma-separated allowlist ("api.example.com,cdn.example.com"),""for any host, orNULLto revoke. This is the embedder's side of--allow-net.scua_grant_net(q, "api.example.com,cdn.example.com"); // outbound HTTP/TCP to these hosts scua_grant_net(q, ""); // any host scua_grant_net(q, NULL); // revokescua_grant_serve(p, bind)grants the separateservecapability — the ability to listen:http.serve,net.listen,net.accept.bindnames the address the script may bind ("127.0.0.1:8080"),"*"for any bind, or""to revoke. Listening is deliberately its own authority:scua_grant_netnever confers it, so a script that can call out still can't open a port unless you say so.scua_grant_serve(q, "127.0.0.1:8080"); // the script may bind exactly this addressGrant
serveonly when the script itself opens the port. More often the host owns the socket — accepting connections in its own reactor — and drives the script's handler per request through the normal eval/pcall path. Then you don't grantserveat all; you drive the script's async I/O yourself, which is the next section.
#Give the script its data
(res:)
A game's sprites and levels, or a tool's templates, are the program's
own data. A script reads them as res: paths, such as
fs.read("res:maps/keep.json"), from a stack of read-only
layers. They need no grant: a script with layers but no
scua_grant_fs can import fs and read
res: paths, and nothing else. See Ship a program's data with it for the
script side.
- The project's
res/folder is the bottom layer, as on the command line.scua_eval_fileandscua_load_script_fileadd it: theres/folder beside the project'sscua.toml, or beside the script. scua_mount(p, path, …)adds a pack file or a directory above it.scua_mount_memory(p, data, len, label, release, ctx, …)adds a pack held in your memory. Leave the bytes alone untilreleaseruns. It runs exactly once: when the layer is dropped, or before the call returns if the mount is refused.
char err[256];
if (scua_mount(q, "/opt/mygame/base.pak", err, sizeof err) != SCUA_OK)
fprintf(stderr, "mount failed: %s\n", err); /* e.g. E-PACK-CORRUPT: … */
scua_mount_memory(q, patch_bytes, patch_len, "patch1", free_patch, NULL, err, sizeof err);
The rules:
- One stack per process. Every partition reads the same layers.
- Mount before any partition starts an actor. The
first actor fixes the stack, and a later mount returns
SCUA_ERR_BUSY. - Each layer goes above the ones already there, and the higher layer wins.
- A pack is checked before anything reads it. A
refused one returns
SCUA_ERR_FORMATwith theE-PACK-…code and the reason inerrbuf. - A pack inside the directory you granted with
scua_grant_fsis refused, since the script could rewrite it for its next run.
scua_res_layers(p, out, n) returns the layers, top
first, with the same fields fs.mounts() gives a script.
scua_res_reset() drops every layer and runs the memory
layers' release, once every partition has been freed.
An actor's save records the layers it ran with. Reviving one with
scua_load_actor refuses it (-SCUA_ERR_FORMAT,
with the layer named in the log) when a layer it had is not mounted. A
layer that changed is reported instead, in
scua_load_report's version 3 fields
(res_changed_count and res_changed).
scua_set_res_revive(p, SCUA_RES_MISSING_ALLOW) revives
without the missing layers, and SCUA_RES_STRICT refuses a
changed one too. scua_actor_stats version 2 counts each
outcome.
Ship a game as one file. Append a pack to your
executable and mount the executable itself:
scua_mount(q, own_path, …). A pack is found from the end of
the file, so the bytes in front of it, the program, are ignored. Build
the pack with scua res build res/ -o assets.pak, then
append it with
cat mygame assets.pak > mygame-release.
#Give the
script a folder for saves and settings (user:)
A game's saves and a tool's settings belong to the person using it,
not to the program. A script writes them as user: paths,
such as fs.write("user:saves/slot1.json", text), in a
folder you grant with scua_grant_user. That folder is a
second writable root beside the one scua_grant_fs gives,
and a script that has only this folder can still
import fs.
char err[512];
int rc = scua_use_project(q, "/opt/mygame", err, sizeof err); /* reads scua.toml, so the [app] id is known */
const char *id = scua_app_id(q); /* "com.example.mygame", or NULL */
rc = scua_grant_user(q, id, NULL, SCUA_USER_NOWAIT, err, sizeof err);
if (rc == SCUA_ERR_USER_LOCKED)
show_message("The game is already running.");
else if (rc != SCUA_OK)
fprintf(stderr, "%s\n", err); /* E-USER-…: the reason */
pathNULL is the folder the platform keeps for the app ID, as--allow-userfinds it:%APPDATA%\IDon Windows,~/Library/Application Support/IDon macOS,~/.local/share/IDon Linux. On Android and under WebAssembly there is no such folder, so passpath.- A
pathis the folder itself, with nothing added: a sandbox container, an app's files folder plus the ID, or a Steam user's subfolder. idNULL grants the folder without an ID, or under the project's[app] idwhen it declares one. An ID that differs from the declared one is refused withE-USER-MISMATCH, because it would be a different, empty save folder.
The folder is created (mode 0700), marked with a
.scua-user file holding the ID, locked so that a second
copy of the program cannot write it at the same time, and checked to
belong to the user, as a scua run checks it. Nothing is
printed: a refusal is a return code, with the E-USER-… code
and the reason in errbuf.
| return | E-USER-… codes |
|---|---|
SCUA_ERR_TYPE |
E-USER-ID (a bad ID, or both id and
path NULL), E-USER-MISMATCH |
SCUA_ERR_NOT_FOUND |
E-USER-NOPARENT (the folder above it is missing: an
unplugged drive), E-USER-NOBASE |
SCUA_ERR_USER_LOCKED |
E-USER-LOCKED: another run holds the folder |
SCUA_ERR_OTHER |
E-USER-FOREIGN, E-USER-LINK,
E-USER-OWNER, E-USER-OVERLAP |
SCUA_ERR_RANGE |
a flag bit scua.h does not define |
Without SCUA_USER_NOWAIT a held folder is waited for,
about 5 seconds, which blocks the calling thread. A game that grants
before its window opens passes the flag and shows its own message.
SCUA_USER_ADOPT marks an existing platform folder that has
no marker yet, such as the one Steam Cloud downloads on a second
machine.
The rules:
- Grant before the load. Without a project, the
declared ID is checked at the first
scua_eval_fileorscua_load_script_fileof a program whose project declares one: that call returnsSCUA_ERR_TYPEwithE-USER-MISMATCHinerrbuf. - Grant again after
scua_load, which returns a new partition, as for every grant. - Grant before
scua_load_actor. An actor's save records the ID its folder was granted under, never the path, and the revived actor gets the folder only when you hold one under the same ID. - The lock is held until the last partition holding the grant
lets it go: actors the partition spawned keep the folder after
scua_revoke_user. Release it before you delete the folder: on Windows the lock is an open file inside it. scua_user_folder(p)is the folder's resolved path, for your own code: valid until the next grant or revoke onp.
A script that writes user: paths and runs without the
grant stops at the first such call, naming what is missing, and a
user: path that arrives as data (a file name read from a
list) is an Error value.
#Drive the script's async I/O
By default a capability call blocks: fs.read or
http.get runs to completion before the script moves on, and
a scua_eval blocks the calling thread while it does. That's
the right model for a CLI or a tool. But a host with its own
reactor — a game loop, an async server — doesn't want a
script's http.get blocking its thread. So the host can
install an I/O platform: a script's capability calls
inside an actor handler turn then park instead of blocking, the
host performs the I/O on its own schedule, and the host resumes the turn
when the result is ready.
You install it once with scua_set_io_platform, handing
over a context pointer and a submit callback:
int scua_set_io_platform(scua_partition *p, void *ctx, scua_io_submit_fn submit);
When a handler-turn capability call parks, the runtime calls your
submit synchronously, with a
token that identifies the request and the details of what
was asked:
typedef void (*scua_io_submit_fn)(void *ctx, uint64_t token,
const char *module, const char *op,
const char *arg0, size_t arg0_len);
module/op are NUL-terminated
("http"/"GET",
"fs"/"read");
arg0/arg0_len is the first string argument
(the URL or path) and is not NUL-terminated. Copy
whatever you need before returning — the pointers aren't yours to keep.
Your submit just records the request (queues it on your
reactor) and returns immediately; it must not block.
Later, when your reactor has the result, you
complete the token. Pick the completion that matches
the parked op — each hands the script the right
Ok/Error shape:
int scua_io_complete_bytes(scua_partition *p, uint64_t token, const void *data, size_t len); // Ok(bytes) — fs.read
int scua_io_complete_text (scua_partition *p, uint64_t token, const void *str, size_t len); // Ok(string) — fs.read_text (must be valid UTF-8)
int scua_io_complete_http (scua_partition *p, uint64_t token, int status, const void *body, size_t len); // Ok({status,body}) — http.get/post
int scua_io_complete_nil (scua_partition *p, uint64_t token); // Ok(nil) — fs.write
int scua_io_complete_error(scua_partition *p, uint64_t token, const char *msg); // Error(msg) — any failure
Each returns 1 if it was delivered, or 0 if the token is no longer
live — timed out, reloaded, or already completed. That 0 is
a defined no-op, not an error: completing a token
twice, or one that a snapshot already retired, is safe.
Completing a token only stages the result. To actually
resume the parked turn(s), call
scua_io_poll, which drives one drain cycle and flushes
output:
char errbuf[128];
if (scua_io_poll(p, errbuf, sizeof(errbuf)) != SCUA_OK)
fprintf(stderr, "resumed turn faulted: %s\n", errbuf); // a located message
So the loop your reactor runs is: install the platform once → a call
parks and fires submit → you do the I/O →
scua_io_complete_* by token → scua_io_poll to
resume. To uninstall, call
scua_set_io_platform(p, NULL, NULL), and everything goes
back to blocking (only handler turns ever parked — a top-level
scua_eval blocks regardless).
user: paths stay inline unless you say
otherwise. Your submit gets a path as the script
wrote it, user:saves/slot1.json, and a reactor that roots
every path at its own directory would write a file of that name there.
So a user: read or write parks only after
scua_io_platform_caps(p, SCUA_IO_CAP_USER) (or'd with any
other bits you declare); then you resolve the path against
scua_user_folder(p) yourself. Without the bit it runs to
completion where it is called.
This is the embedder's side of --io=async: the host owns
the socket and the event loop, and the script's
http.get/fs.read become non-blocking without
the script changing at all. It also inherits the snapshot rule: if a
partition is persisted while a token is still parked, the retired call
resumes with Error({ kind = "interrupted" }) (or
kind = "timeout" if its deadline had passed) and is
never re-issued — so a mid-flight request is never
silently re-sent across a reload.
#Drive the script from a game loop
By default every host call finishes the work it starts.
scua_pcall(update) returns once update has run
and every task and partition it set going has nothing left to do, with
wait skipping ahead in no real time. That suits a tool. A
game wants something else: the script's waits measured in game time, a
fixed slice of each frame for the script's background work, and whatever
doesn't fit carried to the next frame. Four calls give a host that
control:
scua_set_drain_mode(p, SCUA_DRAIN_EXPLICIT); // a host call runs the call, and nothing else
scua_set_clock(p, 0); // the script's time is the game's
scua_set_fault_hook(p, on_fault, NULL); // told of each fault once
scua_post_open(p, 1024); // other threads may post (see below)
scua_load_script(p, src, len, 64u << 20, 1000000, err, sizeof err);
scua_Handle update = scua_get_global(p, "update");
scua_turn_budget budget = { sizeof budget, 0, 2000000 }; // 2 ms of each frame
scua_turn_report report = { sizeof report };
while (running) {
if (!paused) scua_advance_clock(p, (int64_t)(dt * time_scale * 1000));
scua_Handle dt_h = scua_num(p, dt);
scua_pcall(p, update, &dt_h, 1, NULL, err, sizeof err); // update alone
scua_run_turns(p, &budget, &report, err, sizeof err); // tasks and partitions, within budget
render();
}
scua_partition_free(p);
scua_set_drain_mode(p, SCUA_DRAIN_EXPLICIT):scua_pcall,scua_io_polland the load itself run only what they are asked to, so a script whose top level starts a task that loops forever still loads. Set it beforescua_load_script; it is kept over a hot reload.scua_run_turnsruns ready tasks and partition turns until nothing is left or the budget is spent: a number of turns, or a number of nanoseconds of wall time. A turn that is running finishes (itsop_budgetbounds it), and what hasn't run stays queued for the next call. The report says how many turns ran, whether more are ready, and when the next timer comes due.scua_set_clockandscua_advance_clockmake the script's clock the host's.now()reads it, and await(500ms)comes due when the host's clock has moved half a second, however many frames that takes. Pausing is not advancing; slow motion is advancing by less. The clock never runs backwards. A host clock bounds the default mode too, so even withoutSCUA_DRAIN_EXPLICITa script that ticks forever returns from every call.scua_set_fault_hookhears each fault a partition's turn or a spawned task survives, once, as it happens. The call that ran it returnsSCUA_ERR_OTHERwith the first one, and later calls don't repeat it, so one broken enemy doesn't fail every frame after it.
A script written for this keeps its background work in tasks and partitions and lets the game's clock pace it:
spawn(fn()
while true do
think() -- every 100 ms of game time, paused when the game is
wait(100ms)
end
end)
Posting from other threads. Every call above belongs
to the thread that owns the partition. The one exception is
scua_post_text(p, id, msg, len): once
scua_post_open has made an inbox, any thread (a loader, a
network thread) may post a text message to a partition. It is copied
into the inbox and delivered at the start of the owner thread's next
drain, in the order it was posted. A full inbox returns
SCUA_ERR_LIMIT, so the poster sees the backpressure. Stop
every posting thread before freeing the partition.
Engine objects in partitions. A host function a partition's handler calls can make and read runtime-owned resources of the types registered on your partition, just as it can from the main script. Such an object stays in the partition that made it: a message carrying one faults. Objects a partition makes are destroyed when they're closed or when the partition stops.
examples/embed_frames.c is the whole loop, with a loader
thread and a fault, and runs with zig build embed-frames.
Everything still runs on the owner thread: a host function is never
called on two threads at once.
#Run the script's partitions on several cores
A game's AI, pathfinding and procedural generation are partitions
that mostly compute. Turn workers on before the load, and each
scua_run_turns runs the script's partitions on your thread
and the workers together:
scua_set_drain_mode(p, SCUA_DRAIN_EXPLICIT);
scua_set_workers(p, 4); // your thread and three workers
scua_register_module_mode(p, "phys", "raycast", raycast, host, SCUA_PARALLEL_READ);
scua_register_module_mode(p, "phys", "impulse", impulse, host, SCUA_DEFERRED);
scua_register_module_mode(p, "noise", "perlin", perlin, NULL, SCUA_ANY_THREAD);
scua_register_module(p, "audio", "play", play, host); // main-thread: the default
scua_load_script(p, src, len, 64u << 20, 1000000, err, sizeof err);
while (running) {
scua_advance_clock(p, dt_ms);
scua_pcall(p, update, &dt_h, 1, NULL, err, sizeof err); // serial: the main script
scua_run_turns(p, &budget, &report, err, sizeof err); // PARALLEL: partitions on four threads
scua_apply_deferred(p, err, sizeof err); // serial: queued engine writes, in order
physics_step(); // serial: callbacks may call into SCUA
render();
}
Each frame now has a parallel phase,
scua_run_turns, which returns once every worker has
stopped, and a serial phase, everything else, where
your engine and the main script run as they always have.
Every host function runs as its mode says:
| Mode | Called from a worker, it | Use it for | You promise |
|---|---|---|---|
SCUA_MAIN_THREAD (the default) |
runs on your thread, within the same scua_run_turns,
while the calling partition waits |
anything that changes engine state: audio, UI, creating resources | nothing new |
SCUA_PARALLEL_READ |
runs at once, on the worker | raycasts, visibility, map lookups | it only reads, and what it reads doesn't change during
scua_run_turns |
SCUA_DEFERRED |
returns nothing at once; the call runs later, in
scua_apply_deferred |
forces, moves, spawning effects, sounds at a position | its effect can wait for the serial phase, and it needs no answer |
SCUA_ANY_THREAD |
runs at once, on whichever thread called | noise, maths, string helpers | it touches no engine state at all |
Leave a function at the default and it is correct with workers on; it
just runs on one thread. Classify the busy ones, and
scua_get_parallel_stats counts how many calls your thread
ran for the workers, so you can see which.
- Deferred calls carry copies of their arguments:
nil, booleans, numbers, strings and bytes. Pass an engine object by its
id.
scua_apply_deferredruns them in a fixed order — partitions with an order key (scua_set_order_key) first, in key order, then the rest by id, and each partition's calls in the order it made them — so the same frame gives the same world on two workers or twelve. A parallel-read function reads the engine as it was when the phase began: it doesn't see this phase's deferred writes. - During the parallel phase every call on your
partition returns
SCUA_ERR_PHASE, from any thread. A host function's ownscua_arg_*andscua_return_*calls are unaffected, andscua_post_textworks throughout. A functionscua_apply_deferredruns may not run the script either. Engine callbacks that call back into the script, such as physics contacts, belong in the serial phase, where they work exactly as before. - Engine objects a worker's host function creates are
initialized on your thread while the worker waits, unless the type's
initializer is thread-safe and you say so with
scua_userdata_set_any_thread. Objects are destroyed on your thread, never a worker's. - Faults reach the fault hook on your thread, once each.
- Repeatable runs:
scua_set_workers(p, 0)or1runs everything on your thread, every mode in place, exactly as without workers. For tests,scua_set_schedule_order(p, "shuffle:7")runs each round's partitions in a different legal order; a game whose results change with it depends on an order SCUA doesn't promise. - The worker count takes effect at the next
scua_load_script, and freeing the partition stops the workers. - Where it works: macOS and Windows. The Linux
library is built without libc, so it can't start threads inside your
process, and
scua_set_workers(p, n)withnabove 1 returnsSCUA_ERR_THREADthere; your script runs on one thread, unchanged.
examples/embed_multicore.c is a frame with six agents on
four threads, and runs with zig build embed-multicore.
#Running under a WASI host (WebAssembly)
That "the host owns I/O" design is what lets SCUA run on platforms with no operating system of their own. The CLI cross-compiles to WebAssembly with no source changes:
zig build wasm # produces zig-out/scua.wasm
Run it under any WASI host — here, wasmtime:
wasmtime run --dir=.::. zig-out/scua.wasm script.scua
The interesting part is --dir. A WebAssembly module has
no ambient filesystem; it can only reach directories the host
preopens for it. So --dir=.::. is the host
granting the sandboxed module access to the current directory — and
nothing else. The module physically cannot open a path outside it,
because the capability to do so was never handed in. That's the same
default-deny, host-grants-authority model the embedding API uses,
enforced here by the runtime itself: SCUA brings no event loop and no OS
assumptions, and the host decides what the script can reach. The same
.scua file runs unchanged native, embedded in a C host, or
sandboxed in WebAssembly.
A program's per-user folder is a second preopen, named to
--user-dir exactly as the module sees it. There is no
platform folder under WebAssembly, so --allow-user alone
stops with E-USER-NOBASE, and file locks do not exist
there, so the run says it cannot lock the folder:
wasmtime run --dir=.::. --dir=./saves::/saves zig-out/scua.wasm --user-dir=/saves game.scua