Embedding Citril
Use Citril as the scripting layer inside your own C++ program — for example, a game engine.
Citril is built as a C++ library (citril_core). Your application
owns the loop and the data; Citril runs the scripts, and the two talk to each
other through plain function calls. A typical game setup looks like this:
- The engine exposes functions to scripts (spawn an entity, play a sound, read input).
- A script defines behaviour (
on_start,on_update) and calls those engine functions. - Each frame, the engine calls the script's
on_update.
1. Link the library
Add the Citril source tree to your CMake project and link the core library:
add_subdirectory(third_party/citril)
target_link_libraries(my_engine PRIVATE citril_core)
2. Run a script
Lex, parse, and run a source file with an Interpreter. Wrap it in a
try/catch so a script error can't take down your engine.
#include "citril/lexer.hpp"
#include "citril/parser.hpp"
#include "citril/interpreter.hpp"
#include "citril/errors.hpp"
citril::Interpreter interp;
register_engine_api(interp); // step 3, below
try {
auto program = citril::parse(citril::tokenize(source));
interp.run(program); // runs top-level, defines on_update, ...
} catch (const citril::CitrilError& e) {
log_error(e.render(source, "level.citril"));
}
3. Expose engine functions to scripts
A "native" is a C++ function callable from Citril. Register one by defining a
name in the interpreter's global scope. Here spawn(x, y) becomes
available to every script:
#include "citril/value.hpp"
#include "citril/environment.hpp"
using citril::Value;
static Value make_native(std::string name, int lo, int hi,
std::function<Value(citril::Caller&, std::vector<Value>&)> fn) {
auto n = std::make_shared<citril::NativeObj>();
n->name = std::move(name);
n->arity_min = lo; n->arity_max = hi;
n->fn = std::move(fn);
return Value(n);
}
void register_engine_api(citril::Interpreter& interp) {
interp.globals()->define("spawn", make_native("spawn", 2, 2,
[](citril::Caller&, std::vector<Value>& args) -> Value {
double x = args[0].as_number();
double y = args[1].as_number();
Engine::instance().spawn_entity(x, y);
return Value(); // nil
}));
}
Now a script can call it directly:
function on_start()
spawn(100, 40)
spawn(220, 40)
end
4. Call script functions each frame
Look a function up in the script's globals and call it from your loop. The
built-in call works for any Citril function:
// once, after running the script:
Value* on_update = interp.globals()->lookup("on_update");
// every frame:
if (on_update) {
interp.call(*on_update, { Value(delta_time) });
}
On speed. The examples above use the tree-walking interpreter, which has the simplest embedding surface. Citril also has a bytecode VM that runs 10–24× faster; a first-class embedding API for the VM (registering natives + reusing a compiled script) is on the roadmap. For hot paths today, keep per-frame script work small and do the heavy lifting in engine natives.
Working with values
Citril values map onto simple C++ types. Common helpers on Value:
is_number(),is_string(),is_list(),is_map()— type checks.as_number(),as_string(),as_list()— read the payload.Value(3.0),Value("hi"),Value(true)— construct values to return.Value()— nil.
Passing lists and maps across the boundary
Lists and maps are reference types held behind a shared_ptr, so you
build them in C++ and hand the Value straight to a script. Build a
list of positions to pass in:
auto positions = std::make_shared<std::vector<Value>>();
positions->push_back(Value(100.0));
positions->push_back(Value(240.0));
interp.call(*interp.globals()->lookup("place_all"), { Value(positions) });
A map is a set of key/value pairs. Use MapData::set with
Value keys:
auto entity = std::make_shared<citril::MapData>();
entity->set(Value("x"), Value(12.0));
entity->set(Value("y"), Value(-4.0));
entity->set(Value("hp"), Value(100));
interp.call(*interp.globals()->lookup("on_spawn"), { Value(entity) });
Reading a script's return value
interp.call(...) returns the Value the script function
produced. Check its type before reading it — a script may return anything:
Value result = interp.call(*interp.globals()->lookup("decide_move"), { Value(entity) });
if (result.is_map()) {
citril::Value* dx = result.as_map()->find(Value("dx"));
citril::Value* dy = result.as_map()->find(Value("dy"));
if (dx && dx->is_number() && dy && dy->is_number()) {
move_entity(dx->as_number(), dy->as_number());
}
}
Isolating script errors
A misbehaving script should never crash the host. Two things can be thrown out
of Citril: a CitrilError (a syntax or runtime error, with source
position) and a ThrownValue (a value passed to the script's
error(...)). Catch both around every entry point:
#include "citril/errors.hpp"
bool call_safely(citril::Interpreter& interp, const Value& fn,
std::vector<Value> args) {
try {
interp.call(fn, std::move(args));
return true;
} catch (const citril::CitrilError& e) {
log_error(e.render(current_source, "script.citril"));
} catch (const citril::ThrownValue& t) {
log_error("script error: " + citril::to_display(t.value));
}
return false; // disable this script, keep the engine running
}
Threading and lifetimes
An Interpreter (and the VM) is single-threaded: don't call into the
same instance from two threads at once. Two common patterns work well:
- One interpreter for the whole game, called only from the main loop — simplest, and what most engines want.
- One interpreter per worker thread if you script jobs on a pool — each thread owns its own instance and globals.
Values that come out of a script (lists, maps, functions) share ownership via
shared_ptr, so they stay valid after the call returns — but keep the
Interpreter that produced a function alive for as long as you intend
to call that function, since its captured globals live there.
A complete frame loop
Putting it together: load once, then drive the script from your loop.
citril::Interpreter interp;
register_engine_api(interp); // expose spawn(), play_sound(), ...
// Load the script once. Its top level defines on_start / on_update.
try {
interp.run(citril::parse(citril::tokenize(source)));
} catch (const citril::CitrilError& e) {
log_error(e.render(source, "level.citril"));
return;
}
// Look up the hooks once; they may be absent.
Value* on_start = interp.globals()->lookup("on_start");
Value* on_update = interp.globals()->lookup("on_update");
if (on_start) call_safely(interp, *on_start, {});
while (engine_running()) {
double dt = frame_delta();
if (on_update) call_safely(interp, *on_update, { Value(dt) });
render();
}
Questions about integrating Citril into a specific engine or project? Get in touch.