SCUA

How-to

Drive a script from your engine (the callback model)

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_eval is rejected (the two models don't mix on one partition). scua_persist still 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.
  • Pinnedscua_pin_global promotes a handle to outlive the scope (a cached player, a callback target). Release it with scua_unpin_global. The update/draw handles 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_persistscua_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 vectorsvec2/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