dynamic_binder: Binding C++ Classes​

jai::dynamic_binder<T> makes a C++ class usable from script. The rules that govern this page:

  • Scripts reach only what you bind. A binder chain names the constructors, members and methods of one class for one engine.
  • Objects cross the boundary by identity, never by copy. Once in script, a bound object follows the language's value rules: a value copies through the C++ copy constructor, a handle (shared_ptr) or a reference shares.
  • One declaration can do three jobs. A field declared with JAI_PROPERTY on a jai::property_owner is reflected (C++ enumerates it by name), script-visible, and serialized to JSON and binary, so the three never drift apart.
  • A jai::registrar makes a class known to every engine that calls bind_registrar. Registration order never matters.
  • An engine and its objects belong to one thread at a time.

Every C++ sample here is compiled against the library and run on both backends.

Bind a plain C++ class​

A class you already have needs no macros:

C++
#include <jaiscript/core/engine.hpp>
#include <jaiscript/core/dynamic_binder.hpp>
#include <jaiscript/stdlib/stdlib.hpp>
#include <iostream>

struct Turret {                                   // 1. an ordinary class
    std::string name = "turret";
    int64_t ammo = 10;
    Turret() = default;
    Turret(std::string a_name, int64_t a_ammo) : name(std::move(a_name)), ammo(a_ammo) {}
    bool fire() { if (ammo == 0) { return false; } --ammo; return true; }
    double range() const { return 12.5; }
};

int64_t total_ammo(const Turret& a, const Turret& b) { return a.ammo + b.ammo; }

int main() {
    auto eng = jai::engine::make();
    jai::stdlib::register_all(eng);

    jai::dynamic_binder<Turret>(*eng, "Turret")   // 2. bind it under a script name
        .constructor<>()
        .constructor<std::string, int64_t>()
        .property("name", &Turret::name)
        .property("ammo", &Turret::ammo)
        .method("fire", &Turret::fire)
        .method("range", &Turret::range);
    eng->add_function("total_ammo", &total_ammo);  // 3. functions that take it

    eng->execute(R"(                                // 4. scripts construct and call it
        Turret t = Turret("north", 2);
        t.fire();
        t.fire();
        print(t.name, " ammo=", t.ammo, " fired=", t.fire(), " range=", t.range());
    )");

    auto east = std::make_shared<Turret>("east", 5);  // 5. hand a live C++ object to script
    eng->add_global("east", eng->make_object(east));
    eng->execute("east.fire(); east.ammo += 10;");
    std::cout << "C++ sees east.ammo = " << east->ammo << "\n";

    eng->execute(R"(var west = new Turret("west", 3); print(total_ammo(east, west));)");
    auto west = eng->get_variable("west").as<std::shared_ptr<Turret>>();   // 6. and take one back
    west->ammo = 99;
    eng->execute("print(west.ammo);");
    return 0;
}
output
north ammo=0 fired=false range=12.5
C++ sees east.ammo = 14
17
99
  • Constructors are listed by parameter types; a script call picks one by argument count and types. Turret(...) makes a value and new Turret(...) a shared_ptr handle.
  • Properties read and write the C++ member directly.
  • Identity. make_object(shared_ptr) and add_global(name, shared_ptr) wrap your pointer without a copy, so script writes land in C++. as<std::shared_ptr<T>>() hands the script's object back, shared.
  • Lifetime follows the shared_ptr: the object lives while either side holds it. add_global_ref binds an object you keep alive yourself, and eng->make_object<T>(args...) constructs a value in place.
  • Null and weak handles. A std::shared_ptr<T> or std::weak_ptr<T> parameter (by value or const&) takes a script null as an empty handle, so pick(null) resolves to the handle overload. A weak_ptr<T> parameter also takes a handle or a script weak_ptr<T>; an expired one arrives empty. Returning a std::weak_ptr<T> to script is not supported: return a shared_ptr<T> and let script take its own weak_ptr.
  • Exceptions. A C++ exception from a bound method reaches script as a catchable error carrying its what() text (Error surfaces).

One declaration: JAI_PROPERTY​

An engine usually grows reflection for its editor, script binding for its designers and serialization for its saves as three separate registrations of the same fields, and they drift: a field saves but does not show in the editor, or renames in one system only. Here the declaration is the registration. A spawner for a tower-defense game:

C++
#include <jaiscript/core/registrar.hpp>
#include <jaiscript/properties/macros.hpp>
#include <jaiscript/properties/property_serialization.hpp>
#include <jaiscript/signals/signal_property.hpp>
#include <jaiscript/serialization/convenience.hpp>   // to_json / from_json / to_binary below

class Spawner : public jai::property_owner<Spawner> {
public:
    JAI_PROPERTY((std::string), id, "north-gate");
    JAI_PROPERTY((int64_t), level, 1);
    JAI_PROPERTY((double), interval, 2.5);
    JAI_OBSERVABLE_PROPERTY((int64_t), threat, 0);     // emits on_change(old, new)
    JAI_SIGNAL_PROPERTY(void(int64_t), onWave);        // script-connectable, serializable

    void spawn_wave() { total_waves_ += 1; onWaveSignal(total_waves_); }
    int64_t total_waves() const { return total_waves_; }

private:
    int64_t total_waves_ = 0;   // not a property: private, unreflected, unsaved
};

static jai::registrar<Spawner, void> _regSpawner("Spawner",
    [](jai::dynamic_binder<Spawner>& b) {
        b.method("spawn_wave", &Spawner::spawn_wave);
        b.method("total_waves", &Spawner::total_waves);
    });
  • property_owner<Spawner> gives the class a property_mgr and registers its serialization identity (the $type name and the polymorphic load factory) at program start.
  • JAI_PROPERTY((type), name, default) declares a jai::property<T> member that behaves like a T in C++. The type is parenthesized so template commas survive the macro.
  • JAI_OBSERVABLE_PROPERTY adds an on_change(old, new) signal that fires on a write that changes the value, from either side.
  • JAI_SIGNAL_PROPERTY(signature, name) declares a private emitter you fire (name + Signal, here onWaveSignal(n)), a public jai::signal view others connect to, and a hidden property that saves it. JAI_SIGNAL_PROPERTY_AS(signature, name, emitter) names the emitter yourself.
  • jai::registrar is a self-registering static. Its configure function names only what the macros cannot know, the methods; it calls auto_bind() for the rest.

The host replays every registrar in the binary into an engine with one call:

C++
auto eng = jai::engine::make();
jai::stdlib::register_all(eng);   // formatted print, containers, math, json
jai::bind_registrar(*eng);         // every jai::registrar in the binary, one call

A host whose bindings need its own services gives the registrar a context type (jai::registrar<Monster, HostContext>); each configure function then receives the context that jai::bind_registrar<HostContext>(*eng, ctx) passes.

What makes a type visible, and when​

StepEffect
Declaring (property_owner and the macros)Identity, inheritance, properties and serialization. Not script-visible.
jai::registrar<T>Every engine handed to bind_registrar knows the type. An engine that never makes that call knows none of them.
dynamic_binder<T>(eng, "Name")One engine knows the type, under the name and with the methods you choose.

Registration decides which host types and functions a script can name; it is not a sandbox. An engine with nothing registered still runs the whole language, reads files through include and import, and has no memory cap. Safety Rails lists what a default engine can reach and gives the recipe for untrusted script.

  • One name everywhere. A type is named by its JAI_PROPERTY_OWNER(Name) or JAI_PROPERTY_OWNER_AS(Class, "Name") pin when it declares one, else by its bare class name, and that is also its serialization $type.
  • Static phase. Declarations, schemas, serialization identities and the registrar queue set themselves up at program start, in any file order, touching no engine.
  • Runtime phase. The host calls engine::make(), jai::stdlib::register_all, jai::bind_registrar once per engine, then any explicit binder chains, then scripts. Registrations may come in any order: a derived class registered before its property_owner base registers that base under its own name, and a later explicit registration of the base adopts the same definition and gives it the explicit name.
  • A bound member may name a class that registers later, its own class included. The engine's first load, check or host call into script raises one error listing every type still unregistered; register them and load again.
  • Registering after scripts have run is supported. Re-registering an explicitly bound type replaces its binding for objects constructed from then on; existing objects keep their shape.
  • The one enforced rule: an explicit registration cannot adopt an automatically registered base while script instances of it are alive, because that would change their type identity. The binder throws. Finish registering before scripts construct objects, and pin a name on any class scripts construct directly.
error
Bindings are incomplete:
  'Hangar.plane' uses the C++ type 'Plane', which is not registered. Register it: jai::dynamic_binder<Plane>(engine, "Plane");

Scripted​

No glue was written for id, level or onWave:

jaiscript (with the host above)
auto s = Spawner();
s.level = 3;
s.onWave.connect("hud", [=](int wave) -> void {
    print("wave {} incoming", wave);
});
s.spawn_wave();
s.spawn_wave();
print("{} at level {}: {} waves so far", s.id, s.level, s.total_waves());
output
wave 1 incoming
wave 2 incoming
north-gate at level 3: 2 waves so far

A binding has a default constructor for any default-constructible, non-abstract type, preferring a T(engine*) constructor when one exists. A signal connection takes a name ("hud"): the signal owns a named connection, which lasts as long as the object and is replaced or removed by name.

Live objects, both directions​

Script writes land in the C++ member, and C++ emits reach script receivers:

C++
auto gate = std::make_shared<Spawner>();
eng->add_global("gate", eng->make_object(gate));
eng->execute("gate.id = \"east-gate\"; gate.level = 7;");
// gate->id.get() == "east-gate", gate->level.get() == 7

eng->execute(R"JAI(
    gate.onWave.connect("hud", [=](int wave) -> void {
        print("gate wave {}", wave);
    });
)JAI");
gate->spawn_wave();   // C++ emit -> the script receiver runs
output
gate wave 1

An observable property carries change notifications the other way. A script observes:

jaiscript (with the host above)
gate.threat.on_change([=](int old_value, int new_value) -> void {
    print("threat {} -> {}", old_value, new_value);
});

and a write from either side fires it:

C++
gate->threat = 5;                                    // C++ write
eng->execute("gate.threat = gate.threat + 2;");      // script write; reads unwrap in expressions
output
threat 0 -> 5
threat 5 -> 7

One engine serves one thread at a time, and everything registered into it or made by it belongs to that thread. Another thread may use or drop those objects only while the engine is idle, with the hand-off synchronized. Two engines on two threads need no locks, because nothing is global; share a value between them with an atomic.

When a bound object is copied, and when it is shared​

The boundary never copies: make_object(shared_ptr), a T& or std::shared_ptr<T> result, and a member whose type is a registered class all arrive as the C++ object. From there the object behaves as a script class instance does. The guide's Bound C++ objects walks each case with one class.

Every case, one per row: when a bound object is copied or shared. The three met daily:

ScriptResult
A value into a variable, field, element, by-value parameter or by-value captureA separate C++ object made by the copy constructor.
A reference: var& g = gate;, a & parameterThe same object.
A handle: new Turret(...), a std::shared_ptr<Turret> the host returnsEvery copy of the handle shares the one object.

The bound = is the "=" method: .method("=", &C::operator=), or a lambda taking (C&, const C&); overloads from other types resolve like any overload set. auto_bind binds it only for a trivially copy-assignable class. Assigning through a reference to a class that binds none raises Cannot assign to a reference to 'C': its binding has no operator=. Binding "=" on a class whose assignment cannot compile fails at the binding, as in C++.

Reflected​

The same declaration is what an editor inspector or debug panel walks. Writing through the property keeps change signals firing:

C++
gate->visit_reflected_properties([](const std::string& name, jai::property_base* p) {
    std::cout << "prop: " << name << "\n";
});
if (auto* lvl = gate->property_value<int64_t>("level")) {
    std::cout << "level via reflection: " << *lvl << "\n";
}
gate->set_property_value<int64_t>("threat", int64_t{9});   // on_change fires
output
prop: id
prop: interval
prop: level
prop: onWave
prop: threat
level via reflection: 7
threat 7 -> 9

The walk is virtual: a base pointer to a derived object shows the whole chain. Each property carries value_type_id() (its C++ type) and allow_save(), and Spawner::schema() and Spawner::all_property_names() answer without an instance.

Serialized​

No save or load method was written; one call walks the same property manager. The outer level is the archive's root record, whose value0 holds the pointer you passed with its $id; the object is the $val. The signal's named script-text receiver is saved with it:

C++
eng->execute(R"JAI(
    gate.onWave.connect("logger", "print(\"wave logged\");");   // named script-text receiver
)JAI");
auto json = jai::to_json(*eng, gate);
std::cout << json << "\n";
output
{
  "value0": {
    "$id": 1,
    "$val": {
      "id": "east-gate",
      "interval": 2.5,
      "level": 7,
      "onWave": {
        "parameter_names": [],
        "observers": [],
        "owned_observers": {
          "logger": {
            "parameter_names": [],
            "script": "print(\"wave logged\");"
          }
        }
      },
      "threat": 9
    }
  }
}

Loading rebuilds the object and reconnects the receiver. Pass the engine as the load context so the receiver has an engine to run on:

C++
auto loaded = jai::from_json<std::shared_ptr<Spawner>>(*eng, json, *eng);
// loaded->id.get() == "east-gate", level 7, threat 9
loaded->spawn_wave();   // the receiver came back from the save file
output
wave logged

Binary is the same declaration through another archive:

C++
auto bytes  = jai::to_binary(*eng, gate);                                   // std::vector<uint8_t>
auto again  = jai::from_binary<std::shared_ptr<Spawner>>(*eng, bytes, *eng);
again->spawn_wave();   // receivers persist in binary too
output
wave logged
  • Which receivers save. Script-text receivers persist. Function receivers (script lambdas and C++ callbacks) are runtime-only and are skipped without error.
  • By value or by pointer. A property_owner copies and moves, so jai::from_json<Spawner>(*eng, jai::to_json(*eng, *gate)) returns one by value: its properties sit flat at the root, under its static type. A shared_ptr keeps identity and a derived object's $type.
  • Across releases. JSON is the readable form for debugging and diffs, binary the compact one. Fields are found by name, so adding or removing one needs no migration, but neither layout is promised to load across 0.x releases (Stability).

A class that is not a property_owner and has no serialize, save or load serializes through its binder chain: the .property names are its record's keys, and .base_class<B>() adds the base's properties and the $type a base pointer loads by:

C++
struct Actor {
    virtual ~Actor() = default;
    std::string name;
};
struct Archer : Actor {
    int arrows = 12;
};

jai::dynamic_binder<Actor>(*eng, "Actor").property("name", &Actor::name);
jai::dynamic_binder<Archer>(*eng, "Archer").base_class<Actor>().property("arrows", &Archer::arrows);

std::shared_ptr<Actor> hero = std::make_shared<Archer>();
hero->name = "robin";
std::string json = jai::to_json(*eng, hero, 0);
auto back = jai::from_json<std::shared_ptr<Actor>>(*eng, json);
std::cout << json << "\n";
std::cout << back->name << " has " << std::dynamic_pointer_cast<Archer>(back)->arrows << " arrows\n";
output
{"value0": {"$id": 1,"$type": "Archer","$val": {"name": "robin","arrows": 12}}}
robin has 12 arrows

The record, its loads and the property a binding keeps out of it: a class you cannot edit. Four ways a class serializes sets the intrusive and non-intrusive routes side by side, and Serialization covers versioning, hooks, $ref aliasing and custom archives.

Hot reload with state intact​

A script class can derive a bound C++ class with no extra registration; super(...) calls the bound constructor. The C++ half of such an object is the same live object across reloads:

jaiscript (with the host above)
class SmartSpawner : Spawner {
    int aggression = 1;
    SmartSpawner() : super() {}
}
auto smart = SmartSpawner();
smart.level = 5;
smart.aggression = 2;
smart.spawn_wave();

Later in the session, another execute() redefines the class while smart is alive:

jaiscript (with the host above)
class SmartSpawner : Spawner {
    int aggression = 1;
    int burst = 4;                  // new field takes its initializer
    SmartSpawner() : super() {}
}

print("reloaded: level={} aggression={} burst={} waves={}",
    smart.level, smart.aggression, smart.burst, smart.total_waves());
output
reloaded: level=5 aggression=2 burst=4 waves=1

Script fields kept their values, the new field took its initializer, and the C++ base, private total_waves_ included, was neither destroyed nor rebuilt. Hot Reload has the migration rules.

The binder chain, verb by verb​

A registrar drives a dynamic_binder<T>; drive one directly for a type that is not a property_owner:

C++
struct Vec2 {
    double x = 0, y = 0;
    Vec2() = default;
    Vec2(double x, double y) : x(x), y(y) {}
    double length() const { return std::sqrt(x * x + y * y); }
    Vec2& scale(double k) { x *= k; y *= k; return *this; }
    Vec2& translate(double dx, double dy) { x += dx; y += dy; return *this; }
    Vec2 operator+(const Vec2& o) const { return {x + o.x, y + o.y}; }
    bool operator==(const Vec2& o) const { return x == o.x && y == o.y; }
};

jai::dynamic_binder<Vec2>(*eng, "Vec2")
    .constructor<>()
    .constructor<double, double>()             // overloaded ctors resolve by arity + type
    .method("length", &Vec2::length)
    .method("scale", &Vec2::scale)             // Vec2& scale(double) -> chainable
    .method("translate", &Vec2::translate)
    .method("+", &Vec2::operator+)             // operators are methods named by token
    .method("==", &Vec2::operator==)
    .static_method("axes", []() { return 2; })
    .static_method("dot", [](const Vec2& a, const Vec2& b) { return a.x * b.x + a.y * b.y; })
    .property("x", &Vec2::x)
    .property("y", &Vec2::y);
jaiscript (with the host above)
var v = Vec2(3.0, 4.0);
print("length {}", v.length());
var a = Vec2(1.0, 2.0) + Vec2(3.0, 4.0);
print("sum ({}, {})", a.x, a.y);
print("chain {}", Vec2(1.0, 1.0).scale(3.0).translate(1.0, 0.0).x);
v.x = 30.0;                                    // property writes hit the C++ member
print("after write {}", v.length());
print("eq {}", Vec2(1.0, 2.0) == Vec2(1.0, 2.0));
print("neq {}", Vec2(1.0, 2.0) != Vec2(9.0, 2.0));   // != falls back to negated ==
print("axes {}", Vec2::axes());
print("dot {}", Vec2::dot(Vec2(1.0, 2.0), Vec2(3.0, 4.0)));
output
length 5.0
sum (4.0, 6.0)
chain 4.0
after write 30.265491900843113
eq true
neq true
axes 2
dot 11.0

A method that returns T& hands script the object it refers to, and the receiver is kept alive across a chain, so Vec2(1.0, 1.0).scale(3.0).translate(1.0, 0.0).x is safe on temporaries. != worked without being bound because == supplies it.

Every verb with its rules: binder verbs. The ones most chains use:

VerbWhat it binds
.constructor<Args...>()A constructor overload, resolved by arity and C++ parameter types.
.method(name, &T::fn), .method(name, lambda)A member function, or a lambda whose first parameter is T& or const T&.
.method(name, fn, option), .static_method(name, fn, option)The trailing option: a privilege the call requires, jai::nodiscard (a result the script must use) and jai::serial (kept out of parallel bodies), combined with |: jai::nodiscard | jai::serial | jai::filesystem. add_function and add_variadic_function take the same slot.
.property(name, &T::member)Field access, and a key of the class's archive record when it has no route of its own.
.transient_property(name, &T::member)Field access its archive record leaves out.
.base_class<Base>()An inheritance edge.
.build()Optional: commits the binding at that point and throws what fails. A chain commits when its statement ends, and a binder still in scope when the first script loads is committed then; a failure in either is raised by the next load.

Parameters. These hold for methods, static methods, constructors and add_function alike:

  • A non-const T& parameter writes the callee's final value back into the script argument (Host API): gate.add(total, 5) with void add(int64_t& target, int64_t by) leaves the sum in total.
  • A char parameter (by value or const char&), and a store to a char property, take a char or a one-character string. Any other length raises Cannot convert 'string' to 'char': a char takes a string of exactly one character.

Operators are methods named by token, following the script rules of the operators chapter. A type can start as a script class and move to C++ without its call sites changing.

The tokens, the operators one binding supplies, ++ and --, compound and reflected forms, overload choice and the no-match error: bound operators.

Serial-only constructors​

A parallel_for body may construct bound classes and script classes over them (parallel loops); the constructor and destructor then run on a worker thread. That is safe for a constructor that only builds its own members. One that touches state other objects share (a registry, a cache, a service lookup, a signal hub) is marked .serial_only_constructor(). A body that constructs the class, or a script class over it, is refused when the statement is reached, before any element runs.

C++
struct Sprite {
    static std::vector<Sprite*>& registry() { static std::vector<Sprite*> all; return all; }
    double alpha = 1.0;
    Sprite() { registry().push_back(this); }        // every sprite joins the renderer's list
    ~Sprite() { std::erase(registry(), this); }
};

jai::dynamic_binder<Sprite>(*eng, "Sprite")
    .constructor<>()
    .serial_only_constructor()
    .property("alpha", &Sprite::alpha);
jaiscript (with the host above)
class Glowing : Sprite { float glow = 0.5; Glowing() : super() {} }
array<int> lights = [1, 2, 3];
try { parallel_for (auto& n : lights) { Sprite s; n = 0; } } catch (e) { print(e); }
try { parallel_for (auto& n : lights) { Glowing g; n = 0; } } catch (e) { print(e); }
Glowing one;                              // a serial construction is unaffected
print("{} {}", one.alpha, one.glow);
output
parallel_for: C++ class 'Sprite' is marked serial_only_constructor() and cannot be constructed in a parallel body at 3:48
parallel_for: class 'Glowing' has a C++ base 'Sprite' marked serial_only_constructor() and cannot be constructed in a parallel body at 4:49
1.0 0.5

The mark covers the destructor too. An object of a marked class that loses its last reference on a worker is released on the calling thread once the workers are done, in the order of the elements that dropped it.

Calls from parallel bodies​

Bound functions, static methods and methods run on the worker thread unless registered with the jai::serial option. Leaving a call unmarked declares it safe to call from any worker at once, so mark every call that touches shared state (a manager, a registry, the scene graph beyond the object, a connection, a log):

RegistrationCoversRefused
.method("name", f, jai::serial)On a non-const overload, the method's non-const overloads; a const getter of the same name stays parallel. On a const overload (a const member function, a const T&-self lambda), every overload of the name.When called, since the receiver's class is known only then.
.static_method("name", f, jai::serial)A static method.When the statement is reached.
add_function("name", f, jai::serial), add_variadic_functionA free function, under the name it registers ("game::save").

The receiver follows the C++ signature. A non-const member function, a T&-self lambda or a property store runs only on an object the worker owns: the element it is running, a parallel_reduce accumulator, or an object it constructed or copied. A const member function also runs on an object the worker did not make, such as one a C++ function returns or one captured from the enclosing scope. A non-const T& parameter cannot take enclosing state. The library's own builtins follow the same rule; the guide lists which are serial.

C++
struct Meter {
    double level = 0.0;
    void add(double amount) { level += amount; }      // non-const: only an object the worker made
    double reading() const { return level; }          // const: any object
    void publish() const { audio().play("tick"); }    // the audio manager: serial
};

jai::dynamic_binder<Meter>(*eng, "Meter")
    .constructor<>()
    .method("add", &Meter::add)
    .method("reading", &Meter::reading)
    .method("publish", &Meter::publish, jai::serial);  // const, but it reaches a shared manager
eng->add_function("master", []() { return master_meter; });        // the program's one Meter (level 100)
eng->add_function("log_line", [](const std::string& line) { logger().write(line); }, jai::serial);
jaiscript (with the host above)
array<float> xs = [1.0, 2.0, 3.0];
parallel_for (auto& x : xs) {
    Meter m;
    m.add(x * 10.0);                       // the worker's own object
    x = m.reading() + master().reading();  // a const method on the shared one
}
print("{}", to_json(xs));
try { parallel_for (auto& x : xs) { var shared = master(); shared.add(1.0); } } catch (e) { print(e); }
try { parallel_for (auto& x : xs) { Meter m; m.publish(); } } catch (e) { print(e); }
try { parallel_for (auto& x : xs) { log_line("x"); } } catch (e) { print(e); }
output
[110.0,120.0,130.0]
C++ method 'add' of 'Meter' changes an object this parallel body did not make (only its const methods may run on it)
C++ method 'publish' of 'Meter' is marked jai::serial and cannot be called in a parallel body
parallel_for: host function 'log_line' is marked jai::serial and cannot be called in a parallel body at 10:37

Signals: one view, every signature​

A JAI_SIGNAL_PROPERTY is exposed by auto_bind; a plain jai::signal<Sig> or signal_emitter<Sig> member by one .property("name", &T::member) line. Script sees the same three verbs either way:

jaiscript (with the host above)
s.onWave.connect("hud", [=](int wave) -> void { print("wave {}", wave); });   // named function receiver
s.onWave.connect("logger", "print(\"wave logged\");");     // named script-text receiver
s.onWave.connected("hud");                                 // true
s.onWave.disconnect("hud");
  • One generic Signal class serves every signature. Reading a signal member hands script a view over three type-erased operations (connect, disconnect, connected) built where the emitter's type is known.
  • The lookup walks the inheritance chain, so a signal declared on a base connects through a derived registration, in either registration order.
  • Script-text receivers work however the object was made (in script, in C++, or by a load): connecting gives the emitter the binding engine.
  • A view does not own the signal. It is a member read of a live object and dangles past its owner.
  • A view does not set parameter_names. A text receiver sees the names the owner set in C++ (none by default). A function receiver takes the emit arguments positionally and needs no names.
  • jai::bind_signal_type<Sig> registers a named class for one signature, which takes precedence over the generic view (when to use it).

Firing signals from C++, receiver lifetime and observable properties are in Signals & Observable Properties.

Inheritance and polymorphism​

Declare C++ inheritance once, as property_owner<Derived, Base>, and the same edge serves script dispatch, upcasts and polymorphic saves. A class that is not a property_owner declares the edge with .base_class<Base>().

C++
class Pickup : public jai::property_owner<Pickup> {
public:
    JAI_PROPERTY((std::string), name, "pickup");
};

class HealthPickup : public jai::property_owner<HealthPickup, Pickup> {
public:
    JAI_PROPERTY((int64_t), healing, 25);
};

// Registration order does not matter — either class may register first, or the base
// not at all: a derived registration materializes an unregistered property_owner base.
jai::dynamic_binder<Pickup>(*eng, "Pickup")
    .auto_bind()
    .method("kind", [](Pickup& p) { return std::string("pickup:") + p.name.get(); });
jai::dynamic_binder<HealthPickup>(*eng, "HealthPickup")
    .auto_bind();     // derives the Pickup base edge: healing and the inherited name both bind

A save through the base pointer records the concrete $type, and a load through the base pointer returns the derived object, with no factory map and no slicing:

C++
std::shared_ptr<Pickup> loot = std::make_shared<HealthPickup>();
loot->name = std::string("elixir");
std::static_pointer_cast<HealthPickup>(loot)->healing = 40;

auto lootJson = jai::to_json(*eng, loot);          // saved through the BASE pointer
std::cout << lootJson << "\n";
auto back = jai::from_json<std::shared_ptr<Pickup>>(*eng, lootJson);
auto* hp = dynamic_cast<HealthPickup*>(back.get());   // != nullptr, healing == 40
output
{
  "value0": {
    "$id": 1,
    "$type": "HealthPickup",
    "$val": {
      "name": "elixir",
      "healing": 40
    }
  }
}

In script, a C++ function taking shared_ptr<Pickup> accepts a HealthPickup, and base methods are callable on derived instances:

C++
eng->add_function("describe", [](std::shared_ptr<Pickup> p) { return p->name.get(); });
jaiscript (with the host above)
var h = HealthPickup();
h.name = "elixir";
h.healing = 40;
print("upcast: {} heals {}", describe(h), h.healing);
print("inherited method: {}", h.kind());
output
upcast: elixir heals 40
inherited method: pickup:elixir
  • Methods, JAI_PROPERTY fields and signals of a base resolve through a derived registration without being bound again, as serialization saves and loads every level of the chain.
  • A derived registration registers an unregistered property_owner base on the spot (its jai_auto_bind methods, schema properties and signals, up the whole chain); the adoption rule above applies. A base whose name is already taken by another type waits for its own registration.

Plain structs and templates: static binders​

A plain struct or a class template can carry its binding as a declaration beside the type, at global scope: JAI_STATIC_BINDER(Type, "Name", ...) for a type, JAI_TEMPLATE_BINDER(Template, "Name", ...) for every instantiation of a one-parameter template, and JAI_TEMPLATE_BINDER_2 for two. The chain takes .property and .method, written against T in a template binder, and the type gets a default constructor. Nothing reaches an engine until the host names the instantiations it wants. For constructor overloads, operators, base classes or signals use dynamic_binder.

C++
#include <jaiscript/core/static_binder.hpp>
#include <jaiscript/core/static_binder_impl.hpp>
#include <jaiscript/core/template_binder.hpp>

struct Point { double x = 0, y = 0; double sum() const { return x + y; } };
JAI_STATIC_BINDER(Point, "Point",
    .property("x", &Point::x)
    .property("y", &Point::y)
    .method("sum", &Point::sum)
);

template <typename T> struct Box { T value{}; T get() const { return value; } };
JAI_TEMPLATE_BINDER(Box, "Box",
    .property("value", &Box<T>::value)
    .method("get", &Box<T>::get)
);

eng->bind_static_types<Point, Box<int64_t>, Box<double>>();   // or bind_static_type<T>() one at a time
jaiscript (with the host above)
Point p;
p.x = 1.5;
p.y = 2.0;
print("sum {}", p.sum());
Box<int> b;
b.value = 4;
Box<float> f;
f.value = 2.5;
Box<int32> w;            // Box<int32_t>, bound with Box<int64_t>
print("{} {} {}", b.get(), f.get(), w.get());
print(type_of(b), " ", type_of(f), " ", type_of(w));
output
sum 3.5
4 2.5 0
Box<int> Box<float> Box<int32>

An instantiation is named the way a script spells it:

int64_t is int and std::vector<E> is array<E>; every spelling: template instantiation names.

  • Binding one width of a numeric argument binds its 32/64-bit sibling too: Box<int32> with Box<int>, Box<float32> with Box<float>.
  • Two C++ types the script spells alike (int and long on MSVC) cannot both bind: the second bind_static_type throws.
  • A jai::registrar for a template instantiation needs an explicit name; without one, jai::registrar<Point<int>, Ctx> is a compile error that points to the named constructor. A template-syntax name such as "Point<int>" registers the template name with the parser. Point stays an ordinary identifier: only Point< followed by a type reads as the template.

Atomics: one value that every engine shares​

jai::stdlib::register_atomics(eng) (<jaiscript/stdlib/atomic.hpp>; register_all leaves it out) binds std::atomic<int64_t>, std::atomic<double> and std::atomic<bool> as atomic<int>, atomic<float> and atomic<bool>. The host owns the atomic and binds it into each engine with add_global_ref. Every engine then reads and writes the one C++ object from its own thread, each operation one atomic step: a counter, flag or gauge shared with no lock and no copy. An atomic is the one bound type built to be shared across threads; no binder flag makes another object so.

C++
std::atomic<int64_t> page_views{0};                // the host's: it outlives both engines

auto front = jai::engine::make();
auto back = jai::engine::make();
for (jai::engine* eng : {front.get(), back.get()}) {
    jai::stdlib::register_all(eng);
    jai::stdlib::register_atomics(*eng);
    eng->add_global_ref("page_views", page_views);    // a reference: no engine copies it
}
front->execute("page_views += 1; ++page_views;");
back->execute(R"(
    int before = page_views.fetch_add(10);
    print("back saw {} and left {}", before, page_views.load());
)");
std::cout << "the host reads " << page_views.load() << "\n";
output
back saw 2 and left 12
the host reads 12

Every call and operator, the types it takes and what it yields: atomics.

  • a = a + 1 is a load and then a store, two steps another engine can come between; a += 1, ++a and a.fetch_add(1) are one. Two atomics written one after the other are two separate facts: state that must change together belongs in a transaction.
  • An int step keeps the language's overflow rule, where a C++ std::atomic wraps: at the largest int, a += 1 raises Integer overflow in '+=' ('++', and '+' for fetch_add) and leaves the value as it was.
  • atomic<float> has no ++ or -- and atomic<bool> no arithmetic: load_avg++ raises Cannot apply '++' to atomic<float>.
  • An atomic does not copy: auto c = page_views;, var v = page_views; and a by-value parameter raise Cannot copy C++ object of type 'atomic<int>'. atomic<int>& views = page_views; and an atomic<int>& parameter name it.
  • A host may bind one atomic under different names in different engines, and a script may construct a local one (atomic<int> local(5);) that nothing else shares.

Limits​

  • auto_bind exposes primitive, string and signal properties. JAI_PROPERTY fields of the int family, float/double, bool and std::string are script-visible automatically. Container- and class-typed properties still reflect and serialize, but script access needs an explicit .property() or .method() in the configure function.
  • Reading an observable property yields its wrapper. It unwraps in expressions and in print; assign it to a typed local for the value itself.
  • A static_method cannot return a bound class, and a std::weak_ptr<T> cannot be returned to script (both above).
  • No bound object is shared across threads except an atomic. Use one engine per thread and pass values by copy.