Embed SCUA in a host program shows
the simplest integration: set some data, run a whole script with
scua_eval, read the results. That fits a tool or a request
handler. A game engine wants the opposite shape — the
engine owns the frame loop and calls into the script each frame
(update(dt), draw()), the way love2d calls
your love.update. This page shows that callback model, plus
the durable-handle API a host uses to hold script values across frames
without ever touching a raw pointer.
Everything here is exercised end to end by
examples/embed.c (built with zig build embed),
so the snippets match working code.
#Load a script once, then call it each frame
scua_eval compiles and runs a whole script every call,
and its top-level definitions vanish afterwards. For a frame loop you
want the opposite: compile once, then call the script's functions
repeatedly. That's scua_load_script:
scua_partition *g = scua_partition_new();
scua_set_float(g, "player/x", 0.0);
const char *game =
"fn update(dt)\n"
" set(world, \"player\", { x = get(get(world, \"player\"), \"x\") + dt * 60.0 })\n"
"end\n"
"fn draw()\n"
" return get(get(world, \"player\"), \"x\")\n"
"end\n";
char errbuf[128];
scua_load_script(g, game, strlen(game), 0, SCUA_OP_BUDGET_UNLIMITED, errbuf, sizeof(errbuf));
scua_load_script runs the top-level body once (here,
just defining update and draw) and then
keeps the compiled program and its globals on the
partition. The two functions now persist across calls — a thing a
sequence of scua_eval calls does not give you.
Re-calling scua_load_script replaces the loaded script,
which is your hot-reload path during development.
The last two arguments are the same mem_cap /
op_budget bounds as scua_eval, and they apply
to every later call too. SCUA_OP_BUDGET_UNLIMITED disarms
the CPU kill-switch for a trusted, host-owned loop that runs unbounded
work each frame. It is a host-only choice — a script can never reach
this argument, so it can't disarm its own budget. For an
untrusted script, pass a finite budget and a positive
mem_cap instead.
While a script is loaded the partition is in "script mode": call its functions with
scua_pcall;scua_evalis rejected (the two models don't mix on one partition).scua_persiststill works — it checkpoints the data and keeps the loaded script usable.
#Get a function, call it, read the result
Look a global up by name to get a handle to it, then call it. A handle is opaque and cheap to hold; pin the ones you call every frame so they survive across calls:
scua_Handle update = scua_pin_global(g, scua_get_global(g, "update"));
scua_Handle draw = scua_pin_global(g, scua_get_global(g, "draw"));
Now the frame loop — the engine owns it:
for (int frame = 0; frame < 3; frame++) {
uint32_t scope = scua_scope_enter(g); // bracket this frame's transient handles
scua_Handle dt = scua_num(g, 1.0 / 60.0); // a host-made argument value
scua_pcall(g, update, &dt, 1, NULL, errbuf, sizeof(errbuf)); // update(dt)
scua_Handle r;
scua_pcall(g, draw, NULL, 0, &r, errbuf, sizeof(errbuf)); // x = draw()
double drawn = scua_to_num(g, r);
// ... hand `drawn` to your renderer ...
scua_scope_leave(g, scope); // release this frame's transient handles in bulk
}
scua_pcall is protected: if the script faults,
the call returns a non-SCUA_OK code and writes a located
message into errbuf — the partition stays usable and the
loop carries on, instead of the error crashing your engine. Arguments
are passed as handles (scua_int / scua_num /
scua_bool / scua_string wrap a host value),
and the single return value comes back as a handle you read with
scua_to_* (pass NULL to ignore it).
The same primitive serves engine callbacks of any shape — a log callback, an input handler — anywhere the engine needs to invoke a named script function.
#Hold script values across frames with handles
The host never holds a raw pointer into a partition: the data moves when the garbage collector compacts it and when you save and reload. Instead you name values through handles, which SCUA re-resolves on each call, so a collection between calls is harmless. Navigate from the root and read through the handle:
uint32_t scope = scua_scope_enter(g);
scua_Handle root = scua_root(g); // the `world` data root
scua_Handle profile = scua_field(g, scua_field(g, root, "player"), "profile");
scua_Handle level_h = scua_field(g, profile, "level");
int64_t level = scua_to_int(g, level_h); // re-resolves every call
scua_scope_leave(g, scope);
Handles come in two lifetimes:
- Transient — what the accessors above return. They
live in the current scope and are freed in bulk at
scua_scope_leave. Open a scope around per-frame work so transient handles don't accumulate. - Pinned —
scua_pin_globalpromotes a handle to outlive the scope (a cachedplayer, a callback target). Release it withscua_unpin_global. Theupdate/drawhandles above are pinned because the loop reuses them every frame.
A pinned handle keeps resolving across a garbage collection
and across an in-place scua_persist (an autosave).
If a handle's slot is later reused, the stale handle resolves to the
defined error SCUA_ERR_STALE_HANDLE rather than silently
aliasing new data — use-after-free is caught, not undefined. (Handles
are not carried across scua_persist →
scua_load: a reloaded partition is fresh, and the host
re-acquires its handles, just as it re-registers host functions.)
scua_typeof / scua_handle_valid let you
check a handle before reading it; scua_field /
scua_index navigate records and arrays;
scua_to_int / _num / _bool /
_lstring read scalars.
#Return a struct: multiple values
A native that returns a Vector2 or a Color
doesn't need out-parameters or world side effects. Declare
how many values it returns, then push them:
static void host_mouse_pos(scua_ctx *ctx) { // GetMousePosition() -> (x, y)
scua_return_begin(ctx, 2);
scua_return_float(ctx, 320.0);
scua_return_float(ctx, 240.0);
}
The script binds them with a multiple-assignment:
let mx, my = MousePos()
For a named struct, build a record instead and return it:
static void host_get_color(scua_ctx *ctx) { // GetColor() -> Color
scua_partition *p = scua_ctx_partition(ctx);
scua_Handle c = scua_new_table(p);
scua_table_set(p, c, "r", scua_int(p, 255));
scua_table_set(p, c, "g", scua_int(p, 128));
scua_return_value(ctx, c);
}
And to read a record argument (a position handed to
DrawTextEx(font, text, pos, …)), take the argument as a
handle and navigate it:
scua_partition *p = scua_ctx_partition(ctx);
scua_Handle pos = scua_arg(ctx, 2);
double x = scua_to_num(p, scua_field(p, pos, "x"));
double y = scua_to_num(p, scua_field(p, pos, "y"));
#Or pass a vec value
directly
If the script works in vectors —
vec2/vec3/vec4 — a native can
read and return them as first-class vec values, so the script keeps
swizzles and vec math on both sides (no flattening, no
{x, y} record stand-in):
static void host_draw_circle(scua_ctx *ctx) { // draw_circle(center: vec2, radius: float)
scua_partition *p = scua_ctx_partition(ctx);
float c[4];
int n = scua_to_vec(p, scua_arg(ctx, 0), c, 4); // n == 2 for a vec2; 0 if not a vec
draw_circle_at(c[0], c[1], scua_to_num(p, scua_arg(ctx, 1)));
}
static void host_get_mouse(scua_ctx *ctx) { // get_mouse() -> vec2
float v[2] = { mouse_x(), mouse_y() };
scua_return_vec(ctx, v, 2);
}
The script then writes
rl.draw_circle(vec2(400, 225), 30) and
let p = rl.get_mouse(); print(p.x).
(scua_field/scua_arg_bytes don't resolve on a
single vec — scua_to_vec is how you read one; the bulk
{ vec2 } buffer path is for arrays of them.)
#Pass a buffer to the GPU: pin its bytes
A native receives a raw (ptr, len) buffer — a mesh, a
vertex array, pixels — with scua_arg_bytes, and returns one
with scua_return_bytes:
static void host_upload_mesh(scua_ctx *ctx) {
const uint8_t *data; size_t len;
if (scua_arg_bytes(ctx, 0, &data, &len) == SCUA_OK)
gpu_upload(data, len);
}
When the host itself needs the raw bytes of a script-held buffer — to hand a pointer to a C API outside a native call — pin it. A pin is the one bounded place a raw arena pointer crosses to C: while it's held the garbage collector won't move the buffer and the partition won't persist, so the pointer stays valid.
const uint8_t *ptr; size_t len;
scua_pin_bytes(g, buf_handle, &ptr, &len);
glBufferData(GL_ARRAY_BUFFER, len, ptr, GL_STATIC_DRAW); // safe: the buffer can't move now
scua_unpin(g);
Keep pins short, and don't run script (scua_pcall) while
one is held. A scua_persist attempted mid-pin is
refused.
#Build that buffer in the script: typed numeric arrays
The cleanest way to get a packed float* is to have the
script build it. A normal array is boxed and can't be a
float*, but an element-typed
array — { f32 }, { vec2 },
{ u8 }, … — stores its numbers packed and contiguous. The
script fills it with ordinary push, and on the C side it's
still an array (scua_typeof reports
SCUA_TYPE_ARRAY); scua_buffer_info reports the
element kind, element size, and count, and scua_arg_bytes /
scua_pin_bytes hand you the packed bytes directly:
-- in the script
let verts: { f32 } = []
verts.push(1.5)
verts.push(2.5)
verts.push(3.0)
UploadVertices(verts) -- or keep it in `world` for the host to pin
// a native consuming it as a float* — no per-element marshalling
static void host_upload_vertices(scua_ctx *ctx) {
const uint8_t *p; size_t len;
if (scua_arg_bytes(ctx, 0, &p, &len) == SCUA_OK)
glBufferData(GL_ARRAY_BUFFER, len, (const float *)p, GL_STATIC_DRAW);
}
A native can also build and return a typed buffer from a
contiguous block it already has (pixels from an image loader, a computed
mesh) with scua_new_buffer(p, SCUA_ELEM_U8, data, count) —
the script receives a packed array it can index, push onto, or hand
back. Pushing a value that doesn't fit the element kind returns
SCUA_ERR_TYPE, the same check the script-side store does.
The runnable examples/embed.c demo does all of this end to
end.
#Callbacks: when a native calls back into the script
Some library functions take a callback — a log handler, an audio
processor, an input hook. The pattern is: the script hands you a
function (pin the handle so it survives across frames), and your C
trampoline calls it with scua_pcall when the library
fires:
static scua_Handle g_log_cb; // pinned with scua_pin_global at registration
static void on_library_log(int level, const char *msg) {
scua_Handle args[] = { scua_int(p, level), scua_string(p, msg, strlen(msg)) };
scua_pcall(p, g_log_cb, args, 2, NULL, NULL, 0); // re-enter the script
}
This works even mid-turn — i.e. when the library
fires the callback from inside another native that the script called
during its frame. The nested scua_pcall runs above the
in-flight turn, which is preserved; it shares the turn's CPU/memory
budget and faults on its own without disturbing the outer call. Nesting
callbacks too deep (8 levels) returns SCUA_ERR_REENTRANT
instead of overflowing. Two rules: a callback may not suspend (no
wait/ask inside one — that's a script fault),
and don't hold a pinned byte pointer or a raw
scua_arg_bytes/scua_arg_str pointer across a
re-entrant scua_pcall (the arena can move under it).
A partition belongs to one thread. It has a moving
collector and no locks, so it is only ever safe to touch from the thread
that drives it. The first script-driving call binds that owner thread
(or set it explicitly with scua_set_owner_thread); a call
from any other thread returns SCUA_ERR_THREAD. This matters
for off-thread callbacks — an audio library that calls
back on its own audio thread, for instance. Don't call
scua_pcall there. Instead, have the audio thread write into
a lock-free queue your owner thread drains on its next turn, and run the
script callback from the owner thread. The audio thread never touches
the partition.
#Populate a data table from the host
Hand the script a frame populated from
your own data — a query result, a CSV you parsed, an Arrow column batch.
Build each column with the array APIs (a packed
scua_new_buffer for numbers, or scua_new_array
+ scua_array_push for strings/decimals/mixed), then
assemble them with scua_new_frame. Money stays exact: build
a decimal cell with scua_decimal(p, coeff, scale)
(scua_decimal(p, 1050, 2) is 10.50), never a
float.
static void host_make_sales(scua_ctx *ctx) {
scua_partition *p = scua_ctx_partition(ctx);
scua_Handle region = scua_new_array(p, 3); /* a string column */
scua_array_push(p, region, scua_string(p, "EU", 2));
scua_array_push(p, region, scua_string(p, "US", 2));
scua_array_push(p, region, scua_string(p, "EU", 2));
scua_Handle amount = scua_new_array(p, 3); /* an EXACT decimal money column */
scua_array_push(p, amount, scua_decimal(p, 1050, 2)); /* 10.50 */
scua_array_push(p, amount, scua_decimal(p, 2000, 2)); /* 20.00 */
scua_array_push(p, amount, scua_decimal(p, 525, 2)); /* 5.25 */
const char *names[2] = { "region", "amount" };
scua_Handle columns[2] = { region, amount };
scua_return_value(ctx, scua_new_frame(p, names, columns, 2)); /* a frame the script can query */
}
The script queries it like any frame — and the totals are exact:
let sales = make_sales()
print(sales.group_sum("region", "amount")) -- EU 15.75, US 20.00 (exact)
This is the materialize path: typed columns copied into an owned in-arena frame (the column data is referenced, so build it in the same partition). It's Arrow-shaped on purpose — a third party can map an Arrow column batch onto these same calls, so they can back a frame with SQLite, Parquet, or DuckDB without SCUA depending on Arrow or any database.
#One call from an Arrow batch
If your data already comes as an Arrow C
Data Interface batch — what most database and Parquet readers hand
you — skip the per-column glue and pass the
ArrowSchema/ArrowArray pair straight to
scua_frame_from_arrow. The columns are a top-level struct
("+s"); each child column is read by its format string:
"i"/"l" (int32/int64 → int),
"f"/"g" (float32/float64 → float),
"u"/"U" (utf8/large-utf8 → string), and
"d:precision,scale" or
"d:precision,scale,bitwidth" (decimal128 → exact
decimal, the coefficient must fit an int64). Null entries
(per the validity bitmap) become nil.
/* `schema` describes the columns, `array` carries the data (length = row count, one child per column). */
scua_Handle f = scua_frame_from_arrow(p, schema, array);
SCUA copies the data into the frame, so the result
is independent — you keep ownership of the
schema/array pair and release them yourself
per the Arrow contract (SCUA does not call their release callbacks). The
struct definitions live in scua.h behind the standard
ARROW_C_DATA_INTERFACE guard, so they coexist with a real
Arrow header if you already include one. The embed.c harness builds a
small batch this way and group-sums it exactly.
#Where to go next
- Embed SCUA in a host program — the
basics this guide builds on: the partition, data by path, save/load, the
worldmodel, registering host functions (including under a namespace), and opaque host-resource handles. - Save, load, and migrate game state — the persistence story from the script side.