SCUA

News

SCUA 0.38.0: games run scripts on their own clock

October 22, 2026

SCUA 0.38.0 is mostly about sharing: a game engine sharing its frame with a script, programs sharing files, and a project sharing its machine with the commands it runs. A host with a frame loop can now give the script a time budget each frame and its own clock, so a wait(500ms) lasts half a second of game time and pausing the game pauses the script. The same host can spread the script's actors across several cores, with each engine function saying how it may be called. Programs that share files get a lock the operating system releases however they end, a per-user folder several windows can use at once, and exclusive writes that never show a half-written file. A project can list the commands it may run in scua.toml, and saves get a written promise that they keep loading in later releases.

#Drive a script from a game loop

A game's frame has a budget, and an actor that ticks forever never finishes, so a host now decides how much script work each frame gets:

scua_set_drain_mode(p, SCUA_DRAIN_EXPLICIT);   // a host call runs only the call
scua_set_clock(p, 0);                          // the script's time is the game's
scua_set_fault_hook(p, on_fault, host);        // each fault, once
scua_post_open(p, 1024);                       // any thread may now post messages
scua_load_script(p, ...);

for (;;) {
    scua_advance_clock(p, dt_ms);              // paused: don't; slow motion: a smaller dt
    scua_pcall(p, update, ...);                // runs update alone
    scua_turn_budget b = { sizeof b, 0, 2000000 };   // 2 ms of actor work
    scua_run_turns(p, &b, &report, err, sizeof err);
    simulate(); render();
}

scua_run_turns runs the script's tasks and actors until the budget is spent, and whatever doesn't fit carries over to the next frame. Waits and timers come due when the game's clock reaches them, and now() reads that clock. A loader thread or a job system hands results to an actor with scua_post_text, the one call that works from any thread; the message arrives at the start of the next frame's run. Engine functions an actor calls can create and read the host's own objects, such as textures or bodies, just as the main script's can. examples/embed_frames.c is the whole loop, and the embedding guide walks through it.

#Actors on several cores

Call scua_set_workers(p, 4) before the load, and each scua_run_turns runs the script's actors on the game's thread and three workers. A frame then has two phases. In the parallel phase the actors run. In the serial phase the game applies their engine writes, steps physics and renders, and callbacks such as a physics contact can call into the script as they always have.

Each engine function says how it may run:

scua_register_module_mode(p, "world", "raycast", world_raycast, host, SCUA_PARALLEL_READ);
scua_register_module_mode(p, "world", "spawn",   world_spawn,   host, SCUA_DEFERRED);
scua_register_module_mode(p, "fx",    "lerp",    fx_lerp,       NULL, SCUA_ANY_THREAD);
// anything left unclassified runs on the game's thread

A parallel-read function runs straight away on the worker, against engine state that holds still for the phase. A deferred one is queued and run by scua_apply_deferred, so the engine sees the writes in the same order on any number of workers. A function nobody classified runs on the game's thread while the actor that called it waits. That means a host can turn workers on before it has classified anything and still be correct, then classify the busy functions to go faster. The scripts don't change: the messages the actors send are the same on one thread or eight. examples/embed_multicore.c runs a frame of six agents on four threads. Workers run on macOS and Windows.

#Several programs, one set of files

fs.lock takes a lock that every other running program respects. The operating system releases it when the program ends, however it ends, so a crash never leaves a file locked. Here four copies of a program each add 50 to a shared total:

import fs

fn read_total()
  match fs.read_text("total.txt")
    Ok(text) -> return tonumber(text)
    Error(_) -> return 0
  end
end

for i in 1:51 do
  let lock = fs.lock("total.lock", { timeout = 5000 })?
  fs.write("total.txt", `{read_total() + 1}`)?
  fs.close(lock)
end
$ for i in 1 2 3 4; do scua --allow-fs=data bump.scua & done; wait; cat data/total.txt
200

Take the lock out and the same four runs ended at 56, 52 and 52, because each overwrote the others' updates. In a web server, a handler waiting for a lock holds only its own request, and the server keeps answering everyone else.

fs.write(path, data, { exclusive = true }) writes the file under a hidden name and renames it into place only if the name is still free, so nobody reads it half-written. When it loses, its Error has the kind exists; fs.write and fs.lock errors both carry a kind now, so a program can retry only what might pass. With { sync = true } on the write and on the rename, a save is on the drive before the call returns, and a power cut can't undo it. --user-shared lets several windows of one app use the per-user folder at the same time.

#The commands a project may run

A project can now list the commands it runs in its scua.toml, and --allow-exec lets the program run those and nothing else:

[app]
id = "dev.example.build"

[exec]
commands = [
  ["git", "status", "--short"],
  ["git", "log", "--oneline", "-n", "{int}"],
]
import exec

match exec.run(["git", "log", "--oneline", "-n", "3"])
  Ok(r) -> print("git log ran")
  Error(e) -> print(e.message)
end
match exec.run(["git", "push", "--force"])
  Ok(r) -> print("pushed?!")
  Error(e) -> print(e.kind, e.message)
end
$ scua --allow-exec build.scua
git log ran
not_declared this command is not declared; the closest declaration is `git status --short`

The list travels with the program, so whoever installs it can read what it may run. A program can't change its own list either: if the folder it may write to holds that scua.toml, the launch stops and suggests a folder of the program's own, such as --allow-fs=./data. A dependency can't add to the list, because only the project's own scua.toml is read. --allow-exec-any still lets a program run anything, and now covers exec.run as well.

#Saves that keep loading

The persistence guide now makes a promise: a blob that durable.encode wrote in SCUA 0.26 or later loads in every later release. If the format ever changes, later releases keep reading this one. durable.format reads a blob's format without decoding it, so a game can turn away a save from a newer version by name:

import durable

let blob = durable.encode({ level = "keep", gold = 30 }, 1)
print(durable.format(blob), durable.current_format())

fn load(bytes)
  match durable.format(bytes)
    Ok(f) -> if f > durable.current_format() then return Error("saved by a newer version of the game") end
    Error(e) -> return Error(`not a save: {e}`)
  end
  return durable.decode(bytes)
end

print(load(blob))
print(load(b""))
$ scua load.scua
Ok(2) 2
Ok({gold = 30, level = keep})
Error(not a save: not a durable blob (empty))

#Upgrading

  • fs.write's Error is now { kind, message }. Show e.message where you printed the error before.
  • The folder --allow-fs names must exist. A missing one is refused before the program runs, with a message saying to create it.
  • A package's capability list names the grants its functions need as well as the modules it imports, for example serve for http.serve and tty for sys.readline. A budget that allowed the module alone needs the grant name added.
  • A wait in an actor's message handler faults on the line where it's written, so a pcall around it now catches it.
  • A launch refused because another run holds the per-user folder exits with status 9, so a launcher can retry.

The changelog lists everything else in 0.38.0, including a server 500 that carries a reference matching its log line, time.wall() and time.mono_ms() for timing real work, json.encode(v, { canonical = true }), and --max-total-ops to bound a whole run.