Embedding Quickstart
This page takes a C++ program from nothing to a scripted, hot-reloading game loop in six short steps: add JaiScript to the build, run a script, bind a class, call a script function, reload an edit while the loop runs, and handle errors. Every sample on it is compiled and run against the library, and the output under each one is what it prints.
JaiScript compiles into your program as plain C++ with no dependencies, and an engine is an ordinary object. There is no global interpreter state: two engines are fully isolated, so two threads can each run their own without locks.
1. Add JaiScript to your build
Every route needs the same three things: JaiScript's sources compiled into your program, its include folder on the include path, and C++20. Pick the route that matches your project; Building JaiScript into a host has each one in full, with Debug and Release notes and an installed-package route.
CMake. Add the JaiScript folder as a subdirectory (or fetch it with FetchContent) and link the target. It carries the include path, the standard and the flags:
cmake_minimum_required(VERSION 3.20)
project(my_game CXX)
add_subdirectory(JaiScript)
add_executable(my_game main.cpp)
target_link_libraries(my_game PRIVATE JaiScript::jaiscript)Visual Studio (MSBuild). Add JaiScript\source\jaiscript.cpp to the project; it is the whole library in one file. In the project properties, under C/C++: add JaiScript\include to Additional Include Directories, set C++ Language Standard to ISO C++20, and add /bigobj to Additional Options. The defaults for exceptions (/EHsc) and run-time type information stay as they are.
Xcode. Add JaiScript/source/jaiscript.cpp to your target. In Build Settings: Header Search Paths gets $(SRCROOT)/JaiScript/include, C++ Language Dialect is C++20, and Other C++ Flags gets -ffp-contract=off, which keeps floating-point results identical across platforms.
2. Run a script and read the result
jai::engine::make() returns a std::shared_ptr<jai::engine>. execute() runs source text and returns the value of its last expression as a jai::script_value, which as_int(), as_float(), as_string(), as_bool() or as<T>() converts:
#include <jaiscript/core/engine.hpp>
#include <jaiscript/core/dynamic_binder.hpp> // section 3
#include <jaiscript/stdlib/stdlib.hpp>
#include <filesystem> // section 5
#include <fstream>
#include <iostream>
int main() {
auto eng = jai::engine::make(); // an engine is an object: make as many as you like
jai::stdlib::register_all(eng); // formatted print, to_string, math, containers, JSON
eng->execute(R"(print("hello from {}", "JaiScript");)");
jai::script_value r = eng->execute("6 * 7"); // the value of the last expression
std::cout << "6 * 7 = " << r.as_int() << "\n";
std::cout << eng->execute(R"("jai" + "script")").as<std::string>() << "\n";
std::cout << std::boolalpha << eng->execute("2.5 * 2 > 4").as<bool>() << "\n";
return 0;
}hello from JaiScript
6 * 7 = 42
jaiscript
trueThe standard library is opt-in. A bare engine runs the whole language with a concatenating print, template-string formatting (${hp:.1f}), the container and string methods, math::, regex and binary::; jai::stdlib::register_all adds formatted printing, to_string, type_of, JSON, random, time::, crypto:: and reflect::. The reference lists both sets in full. Output goes to std::cout unless you hand the engine another stream with set_output_stream.
A result is a jai::held_value: keep it as long as you like while the engine lives. It never keeps the engine alive; once the engine is destroyed, reading it throws Value outlived its engine. A plain script_value your C++ receives is valid for the call; eng->hold(v) keeps one. Values pass between engines by copy (Host API).
3. Bind a class and a function
A C++ class needs no macros. jai::dynamic_binder names the constructors, members and methods scripts may use, and add_function registers any C++ callable. The samples from here on run inside main after the engine is made:
struct Player {
std::string name;
int64_t hp = 100;
explicit Player(std::string a_name) : name(std::move(a_name)) {}
void hit(int64_t damage) { hp -= damage; }
};
// in main, after the engine is made:
jai::dynamic_binder<Player>(*eng, "Player")
.constructor<std::string>()
.property("name", &Player::name)
.property("hp", &Player::hp)
.method("hit", &Player::hit)
.build();
eng->add_function("roll", [](int64_t sides) { return sides / 2 + 1; }); // any C++ callable
auto hero = std::make_shared<Player>("Ada"); // a live C++ object the script shares
eng->add_global("hero", eng->make_object(hero));
eng->execute(R"(
hero.hit(roll(20));
Player rat = Player("rat"); // scripts construct the class too
rat.hit(30);
print(hero.name, " ", hero.hp, ", ", rat.name, " ", rat.hp);
)");
std::cout << "C++ sees hp " << hero->hp << "\n";Ada 89, rat 70
C++ sees hp 89make_object hands the script a handle to your live object, so its writes land in C++. A script that constructs the class (Player("rat")) gets its own object. dynamic_binder covers properties with getters and setters, overloads, inheritance and the rest.
4. Call a script function
A script function is a value. Look it up with get_variable and call it with a vector of arguments. The call returns a checked_result: test it, then read value(). convert_script_function wraps the same function in a typed std::function instead:
eng->execute("int damage_for(int level) { return level * 3; }");
jai::script_value fn = eng->get_variable("damage_for");
jai::checked_result<jai::script_value> r = fn.as_function()({eng->make_value(int64_t{4})});
if (r) { std::cout << "damage_for(4) = " << r.value().as_int() << "\n"; }
auto damage = jai::convert_script_function<int64_t(int64_t)>(fn); // or a typed std::function
std::cout << "damage(10) = " << damage(10) << "\n";damage_for(4) = 12
damage(10) = 305. A hot-reload frame loop
Put the rules in a module the game imports, and call a script function every frame that reaches the import. When the module's file changes, the next frame re-runs it, and the new functions apply from then on. The sample writes its two files, then saves an edit to rules.jai between frames 2 and 3, as you would in an editor:
namespace fs = std::filesystem;
const fs::path dir = fs::temp_directory_path() / "jai_quickstart";
fs::create_directories(dir);
auto save = [&](const char* name, const char* text) { std::ofstream(dir / name) << text; };
save("rules.jai", "int damage_for(int frame) { return frame * 2; }");
save("game.jai", "import \"rules.jai\";\n"
"int tick(int frame) { import(\"rules.jai\"); return damage_for(frame); }");
eng->import_recheck_interval(0); // see a save at once (the default looks every 0.2 s)
eng->execute_file((dir / "game.jai").string());
jai::script_value tick = eng->get_variable("tick");
for (int64_t frame = 1; frame <= 4; ++frame) {
if (frame == 3) { save("rules.jai", "int damage_for(int frame) { return frame * 100; }"); } // an edit lands
auto r = tick.as_function()({eng->make_value(frame)});
if (!r) { std::cout << "frame " << frame << ": " << eng->format_error(r) << "\n"; continue; }
std::cout << "frame " << frame << ": " << r.value().as_int() << "\n";
}frame 1: 2
frame 2: 4
frame 3: 300
frame 4: 400A broken edit never stops the game. The changed module is parsed and checked before any of it runs; if it has a mistake, that frame's call reports the listing in its result, the program keeps the code it had, and the next save tries again. Hot Reload explains which state survives a reload and how live objects migrate when a class changes.
6. Handle errors
Two rules cover every case. execute() throws, and every exception it throws derives from std::exception. A call through as_function() never throws for what the script did: the failure comes back in its result. Either way the engine stays ready for the next call:
try {
eng->execute("undefined_thing + 1"); // rejected before anything runs
} catch (const std::exception& e) {
std::cout << e.what() << "\n";
}
try {
eng->execute(R"(throw "out of mana";)"); // a throw nothing caught
} catch (const std::exception& e) {
std::cout << "script threw: " << e.what() << "\n";
}
eng->execute("int risky(int x) { return 10 / x; }");
auto r = eng->get_variable("risky").as_function()({eng->make_value(int64_t{0})});
if (!r) { std::cout << "risky(0) failed: " << eng->format_error(r) << "\n"; } // a call reports, never throws
std::cout << "the engine carries on: " << eng->execute("40 + 2").as_int() << "\n";static check failed:
<script>:1:1: error: use of undeclared identifier 'undefined_thing'
undefined_thing + 1
^
script threw: out of mana
risky(0) failed: Division by zero
the engine carries on: 42Checking is strict, so a mistake the checker can see is reported before a single statement runs, with every error in the file listed. Error surfaces lists the exception types and which calls throw, and Static Checking covers names your host injects at run time.
Where to go next
- Host API in depth: functions and direct entries, live bindings to C++ variables and their write-through rules, reading script state back, per-execution variables and the time source.
- dynamic_binder: binding classes in full.
- Safety Rails: execution budgets, memory caps and what an untrusted script can reach.
- Hot Reload and Serialization: keeping state across edits and saves.
examples/embed_quickstart/in the repository: a complete host (a bound class, a signal, saves, hot reload, rails and the debugger).