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:

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:

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:

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.