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). That header is the stable
contract; the runtime is written in Zig, but a host only ever sees
C.
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 lib/libscua.a with your own C/C++ toolchain. The
library is self-contained (no other dependencies) — with two platform
notes:
# 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
./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
#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.
#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);
#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.
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.
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));
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 }
#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.
#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).
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 Error("interrupted") (or
Error("timeout") if its deadline had passed) and is
never re-issued — so a mid-flight request is never
silently re-sent across a reload.
#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.