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:
| Call | Source | Blocks? | Use it for |
|---|---|---|---|
exec(script) | inline string | Yes — returns when done | one-shot scripts, short snippets, driving a script you control |
exec_file(path) | a file on disk | Yes — returns when done | running a script file; sets import-relative resolution to the file's directory |
submit(script) | inline string | No — returns immediately | launching 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, orsubmit— share the same state table and the same worker pool.submitseveral scripts and they run concurrently, interleaving at cooperative yield points. - A script can also
submititself (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
scriiengine 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 viajoin().- 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 existingshared_ptr<Var>you own.make_ref(value)boxes a fresh copy ofvalueand 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_refVarkeeps its box alive only while theVaris reachable from the state. If you drop your onlyshared_ptrtoboxand the script variable isremoved, 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
xandyalias 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_refbox that scripts should read but never reassign or modify, mark itset_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 itengine.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 waysabove still applies to any box you leave unrestricted. -
Don't use it for plain copies. If the script should get its own value,
inserttheVardirectly (pass by value) instead ofmake_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 atscripts/boot.scrcanimport("libs/helper")and it findsscripts/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_budgetused byinsertonly 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_OVERFLOWerror). - Scripts are not sandboxed — see Trust & safety.
make_reflifetime — 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::scriiobject alive as long as any fibers, futures, or function values are reachable.