SCUA

How-to

Embed SCUA in a host program

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 = 0 means unlimited (no memory bound). So for an untrusted script you must pass a positive mem_cap — the memory guard is off by default.
  • op_budget = 0 means the built-in finite default — the CPU kill-switch is always armed even at 0.

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 librarymath, 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 outbound net capability — the http client (http.get/http.post) and net.dial. hosts is a comma-separated allowlist ("api.example.com,cdn.example.com"), "" for any host, or NULL to 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);                                 // revoke
  • scua_grant_serve(p, bind) grants the separate serve capability — the ability to listen: http.serve, net.listen, net.accept. bind names 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_net never 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 address

    Grant serve only 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 grant serve at 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.