Skip to main content

Embedding in C++

Scrii is designed to be hosted inside C++ applications. The entire public surface is a single header:

#include "scrii.hpp" // engine (scrii), exec/submit, value API, make_ref

Link against the static library Scrii (C++23). You do not need plugin.hpp for embedding — that header is only for writing unloadable native plugins, which is a separate feature.

See Getting Started — Using Scrii as a dependency for CMake and Meson project setup.

Running scripts

The engine object owns the shared variable table (the state) and runs scripts on its own worker pool:

#include "scrii.hpp"

int main() {
scrii::scrii engine; // owns state + worker pool

scrii::result r = engine.exec("print(\"hello from scrii\")");
if (!r.ok())
std::cerr << r.status_name() << ": " << r.msg() << "\n";
}

result never throws: check ok() and read status() / msg(). Common statuses: FILE_NOT_FOUND, UNKNOWN_VARIABLE, TYPE_ERROR, STACK_OVERFLOW, UNSUPPORTED.

exec vs exec_file vs submit

These three entry points differ in where the script comes from and in whether they block:

CallSourceBlocks?Use it for
exec(script)inline stringYes — returns when doneone-shot scripts, short snippets, driving a script you control
exec_file(path)a file on diskYes — returns when donerunning a script file; sets import-relative resolution to the file's directory
submit(script)inline stringNo — returns immediatelylaunching background work you'll join later, or several concurrent scripts

exec and exec_file are convenience wrappers: they submit a fiber and block until that fiber finishes, returning the result directly.

// inline
scrii::result r1 = engine.exec("var x = 1 + 2");

// from a file — relative import() paths resolve against the file's dir
scrii::result r2 = engine.exec_file("scripts/boot.scr");

submit is asynchronous. You get a FiberPtr back immediately; the script is queued on the pool and runs cooperatively. Block for a specific fiber with join, then read its outcome with result_of:

auto job = engine.submit("compute_heavy()");

// ...do other host work while the script runs...
engine.join(job); // wait for THIS fiber
scrii::result r = engine.result_of(job); // its result
if (!r.ok()) std::cerr << r.status_name() << "\n";

Concurrency notes:

  • All fibers — whether from exec, exec_file, or submit — share the same state table and the same worker pool. submit several scripts and they run concurrently, interleaving at cooperative yield points.
  • A script can also submit itself (std.async.spawn), so background work can be created from within a script too.
  • Everything funnels through one recursive mutex, so concurrent access is safe.
  • If you want isolated state for a task (its own globals, no interference), create a separate scrii engine instance for it rather than sharing one.

Exchanging values

The host reads and writes script variables through a mutex-guarded API that is safe to call from any thread, including while a script runs.

engine.insert("player_name", scrii::Var("Cory"));
engine.insert("max_hp", scrii::Var(100, scrii::var_type::INT));

// an optional description string is attached to the value (visible via the
// script's `->` operator, info(), and help())
engine.insert("welcome", scrii::Var("hello!"), "a greeting message");
engine.insert("level_cap", scrii::Var(99, scrii::var_type::INT),
"maximum player level");

engine.exec("player_name = player_name + \"!\"");
// script.scr
info(level_cap) // type + "maximum player level"
welcome -> "updated greeting" // -> replaces the description

exec runs the script synchronously and blocks until it finishes; exec_file does the same from a file on disk.

if (engine.contains("max_hp")) {
auto hp = engine.at("max_hp"); // returns Var (copy)
int v = hp.get<int>();
}

switch (engine.type("player_name")) {
case scrii::var_type::STRING: /* ... */ break;
default: break;
}

scrii::Var is a variant: object, array, string, char, int, float, double, long long, bool, function (std::function<Var(std::vector<Var>)>), reference, or nil. Use get<T>(), type_of(), to_string(), to_bool().

Nested values can be inserted or read at a path of object keys:

engine.insert({"player", "stats", "hp"}, scrii::Var(80));
auto hp = engine.at({"player", "stats", "hp"}); // 80

insert_path does the same but takes an explicit path vector and works on the stored table directly (existing siblings are preserved, no copies are made):

engine.insert_path({"config", "retries"}, scrii::Var(3));
engine.remove("player_name"); // top-level
engine.remove_path({"config", "retries"}); // nested

remove / remove_path erase a variable; removing a missing name is a no-op.

Restricted and const values

By default an inserted value is normal script data: the script can reassign or modify it like any var. Mark a value restricted (read-only — callable but never reassigned or modified) or const (deeply immutable) with Var::set_restricted() / Var::set_constant() before inserting:

scrii::Var api_key = scrii::Var("secret");
api_key.set_restricted(); // script can read it, never write it
engine.insert("api_key", api_key);

scrii::Var schema = scrii::Var(42, scrii::var_type::INT);
schema.set_constant(); // deeply immutable
engine.insert("answer", schema);
// script.scr
print(api_key) // "secret" — reading is fine
api_key = "overwritten" // error: REASSIGN_RESTRICTED

Script-side consequences are described in Syntax — Restricted names and const. set_restricted() is what builtins and native plugins use to register their read-only globals.

ref and shared storage

A ref declared in a script points at the same slot as another binding, so writes through the alias reach the original (see ref in the language reference). The host-side equivalent is make_ref: it hands the script a Var that is a reference to a box the host still owns. See make_ref below.

Calling host functions from scripts

Any scrii::function inserted into the state is callable by the script:

engine.insert("log_line", scrii::Var(
[](std::vector<scrii::Var> args) -> scrii::Var {
for (auto &a : args) std::cout << a.to_string();
std::cout << "\n";
return {}; // nil
},
scrii::var_type::FUNCTION));
// script.scr
log_line("score:", 42)

Arguments are passed by value. Exceptions thrown from the callback are caught by the interpreter and surface as script errors — prefer throwing scrii::ScriptError{status::..., msg} so scripts get a clean message.

But see Trust & safety — this is not a sandbox.

Concurrency model

  • exec() / exec_file() run the whole script on a fiber over the internal worker pool and return when it finishes.
  • submit() returns immediately; the script runs cooperatively on the pool and is awaited via join().
  • Long-running scripts call tick() every few statements which cooperatively yields when other fibers are waiting.
  • Blocking std-library calls (sleep, timers, network) release the state mutex while they wait, so detached callbacks keep running.

make_ref — handing the script shared storage

Normally insert copies a value into the state table, so the script gets its own value. make_ref is the opposite: it hands the script shared storage that the host still controls, so both sides read and write the same box.

// a box the host owns
auto box = std::make_shared<scrii::Var>(scrii::Var(10, scrii::var_type::INT));

// two names that alias the SAME box
engine.insert("x", scrii::make_ref(box));
engine.insert("y", scrii::make_ref(box));
// script.scr
print(x) // 10
x = 99 // writes through to the box the host holds
print(y) // 99 — same storage
  • make_ref(box) wraps an existing shared_ptr<Var> you own.
  • make_ref(value) boxes a fresh copy of value and returns it.

The engine dereferences a reference on every read/write, so a script write through any alias lands in the boxed storage (this is the same mechanism behind the script-side ref name = <lvalue> keyword — a ref binding holds a reference to a box). Callbacks you install into std.async/timers/network that close over such values keep seeing the live storage.

make_ref gotchas

  • Lifetime is the host's job. A make_ref Var keeps its box alive only while the Var is reachable from the state. If you drop your only shared_ptr to box and the script variable is removed, the box is freed and any remaining aliases go stale. Keep the box (or the engine) alive for as long as scripts may touch it.
  • Writes go both ways. Making x and y alias one box means a script can mutate host-owned data and vice versa. Use this deliberately; do not share host-owned boxes with untrusted scripts you don't want mutating host state.

:::danger Host-side threading is not synchronized by the engine Engine API calls (at, insert, merge, …) are mutex-guarded, but that guard covers only the state table, not the boxed storage behind a make_ref. If you read or write the same shared_ptr<Var> box from a non-engine thread — including your own std::async C++ threads or callback fibers — while a script may also touch it, you must guard the box yourself (mutex, atomic, or hand-off between phases). The engine will not serialize accesses to host-owned storage. :::

  • Recommendation: make host-supplied refs restricted. If the host exposes a make_ref box that scripts should read but never reassign or modify, mark it set_restricted() before inserting:

    auto box = std::make_shared<scrii::Var>(scrii::Var(0, scrii::var_type::INT));
    scrii::Var ref = scrii::make_ref(box);
    ref.set_restricted(); // scripts may read it but never write it
    engine.insert("counter", ref);

    A restricted ref keeps the shared-box footguns narrow: the script can read the live host value but cannot reassign the name or mutate through it, so the only writer left is you. Writes go both ways above still applies to any box you leave unrestricted.

  • Don't use it for plain copies. If the script should get its own value, insert the Var directly (pass by value) instead of make_ref.

Merging engines

scrii::scrii config;
config.exec("var theme = \"dark\"");
game.merge(config); // copies every global across, restricted included

Import search paths

Scripts use the import() builtin to load other scripts. Where an import resolves from depends on how the importing script was launched:

  • exec_file(path) — the script's directory becomes the base. Relative imports resolve against the directory of the file that called them, so a script at scripts/boot.scr can import("libs/helper") and it finds scripts/libs/helper.scr.
  • exec / submit (inline strings) — there is no source file, so imports resolve against the process working directory.

On top of either base, the engine searches extra import paths in order. The host can add and remove search directories at any time:

engine.add_import_path("/srv/mods");
engine.exec("import(\"helpers\")"); // looks in /srv/mods
engine.remove_import_path("/srv/mods");

File lookup is straightforward: each import path is probed in order, and the first existing file wins (an optional .scr extension is appended). When an imported file itself imports, it resolves from its own directory — the same rule that applied to the original script. This keeps module trees portable: a file that imports its neighbors works wherever it is copied, regardless of how the host launched it.

Note that import() merges the imported file's globals into the root scope and returns them as an object — see import in the language reference.

Trust & safety: calling C++ is not guarded

Scrii is not a sandbox. When a script calls a host function (via engine.insert with a FUNCTION value), the interpreter invokes that C++ lambda directly on the engine's worker thread, with whatever arguments the script passed and with full host privileges. There is:

  • no capability isolation or per-call checking,
  • no memory limit on the C++ side (the element_budget used by insert only caps script container growth; the recursion limit only caps call depth),
  • no separate process, VM, or syscall filtering.

The callback must validate its own arguments — check sizes, types, and ranges before dereferencing (a[0].get<T>() on a missing/typed-wrong argument throws TYPE_ERROR, not a security stop). The interpreter's exception handling only converts a thrown C++ exception into a script error; it is a reporting mechanism, not a safety boundary.

The practical consequence: run only scripts you trust, or build your own sandbox around the engine. Anything you expose becomes a capability the script can call with arbitrary inputs. In particular, be very cautious about exposing (or letting scripts reach) std.system, std.file, networking, or any host function that takes a path or command string — an untrusted script could invoke them to touch the host's files, processes, and network with the embedding process's privileges.

Do not confuse this with the plugin "guard". A function registered through a native plugin is wrapped so that calls fail cleanly after the plugin is unloaded — that guard is purely about safe unloading, and it does not add any sandboxing to the calls themselves.

Notes & gotchas

  • No lexical closures: functions access globals only, not enclosing locals. Callbacks installed into async/timers/network objects must reach their data through globals.
  • One engine, many threads: all state access funnels through one recursive mutex. Host threads may call at()/insert() while scripts run.
  • Recursion limit: 256 nested script calls (clean STACK_OVERFLOW error).
  • Scripts are not sandboxed — see Trust & safety.
  • make_ref lifetime — keep boxed storage alive as long as scripts may touch it (see make_ref).
  • Host-side threads are not synchronized against boxed storage — see the danger note under make_ref gotchas and prefer set_restricted() on host-supplied refs.
  • import() (loading another script) is a language feature — see import in the language reference. Loading native plugins is covered separately in Native Plugins.
  • The scheduler spins up hardware-concurrency workers on first use and joins them in the destructor; keep the scrii::scrii object alive as long as any fibers, futures, or function values are reachable.