dynamic_binder: Binding C++ Classes​

One declaration, three jobs. Declare a field once with JAI_PROPERTY and it is simultaneously reflected (C++ code can enumerate and edit it by name), script-visible (obj.field just works), and serialized (JSON and binary, from the same declaration, including polymorphic pointers and signal connections). This chapter walks one game class through all three, then documents the fluent binder chain that makes it go. Every C++ signature below is taken from include/jaiscript/core/dynamic_binder.hpp and friends; every snippet was compiled against the real library and run on both backends, with byte-identical output.

Step by step: bind a plain C++ class​

A class you already have needs no macros. Name the constructors, members and methods scripts may use, then use the type from either side:

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)
        .build();
    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; scripts pick one by argument count and types. Turret(...) in script makes a value, and new Turret(...) makes a shared_ptr handle. A value copies like a script class's, through the C++ copy constructor; copies of a handle share one object (copies and handles, below).
  • Properties read and write the C++ member directly; a getter-only property (nullptr setter) rejects stores with Cannot assign to 'x': it is read-only.
  • Objects cross by identity. make_object(shared_ptr) wraps the pointer without a copy as a handle, like add_global(name, shared_ptr), so script writes land in C++ (east.ammo is 14) and as<std::shared_ptr<T>>() returns the script's object to C++ shared, not copied.
  • Lifetime follows the shared_ptr: the object lives while either side holds it. add_global_ref instead binds a C++ object or variable you keep alive yourself.
  • Handles and null. A std::shared_ptr<T> or std::weak_ptr<T> parameter (by value or const&) takes a script null as an empty handle, as C++ takes nullptr, so pick(null) resolves to the handle overload of an overload set. A weak_ptr<T> parameter also takes a handle or a script weak_ptr<T>, watching the same object; an expired one arrives empty. Returning a std::weak_ptr<T> to script is not supported (a script weak_ptr watches a script handle, and a host one has none): return shared_ptr<T>, and script takes its own weak_ptr<T> of it.
  • Errors. A C++ exception thrown by a bound method reaches the script as a catchable error carrying its what() text (throw std::runtime_error("jammed") is caught as jammed); Error surfaces lists how each call reports failures.

The rest of this chapter covers what a plain binding leaves out: reflection, save files, signals and inheritance from one declaration (JAI_PROPERTY), and every verb of the fluent chain.

Three systems that drift​

Every engine eventually grows the same three subsystems, usually years apart, usually by different people:

  • Reflection: an editor or debug inspector needs to enumerate an object's fields by name.
  • Script binding: designers need tower.level to work from a script.
  • Serialization: save files need to write those same fields out and read them back.

Three systems, three registration sites, one class. Every new field is a three-stop tour. Miss a stop and you get the classic bugs: the field that saves but doesn't show in the editor, the script-visible field that silently never persists, the renamed member that still loads under its old name in one system and not the other. The systems don't fail loudly; they drift.

JaiScript's answer is to make the declaration itself the registration. A field declared through JAI_PROPERTY lives in a per-instance property_manager and a per-type schema; reflection walks the manager, the binder reads the schema, and the archives serialize the manager. There is nothing to keep in sync because there is only one thing.

The declaration​

A spawner building for a tower-defense game: three plain properties, one observable property, one signal, and two ordinary methods. This is the entire integration surface. Everything in the rest of this chapter falls out of these 25 lines:

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);
    });

Reading it back:

  • property_owner<Spawner> gives the class a property_mgr and registers its serialization identity ($type name, polymorphic load factory) at program start. No separate serialization registration exists to forget.
  • JAI_PROPERTY((type), name, default) declares a jai::property<T> member. The type goes in parentheses so templates with commas survive the macro. The member behaves like a T in C++ (level += 1, comparisons, streaming all forward), and it self-registers into the manager and the type schema.
  • JAI_SIGNAL_PROPERTY(signature, name) expands to a pair: a private signal_emitter you fire (onWaveSignal(n)) and a public jai::signal view scripts and other systems connect to, plus a hidden property that rides save/load. Declaring it is also the entire script registration: auto_bind surfaces the signal as a connectable member (how, below). The emitter is name + Signal; JAI_SIGNAL_PROPERTY_AS(signature, name, emitter) names it for a codebase with another convention (JAI_SIGNAL_PROPERTY_AS(void(int64_t), on_wave, wave_emitter) fires as wave_emitter(n), connects and serializes as on_wave).
  • jai::registrar is a self-registering static: any binary that links this file knows how to bind Spawner into any engine. The configure lambda only names what the macros can't know: the methods. Everything JAI_PROPERTY and JAI_SIGNAL_PROPERTY declared is bound automatically (registrar calls auto_bind() for you).

Host setup is three lines; bind_registrar replays every registrar in the binary into the engine you hand it:

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 passes a context type as the second type argument (jai::registrar<Monster, HostContext>); each configure function then receives the context jai::bind_registrar<HostContext>(*eng, ctx) hands it.

Three doors, each an explicit opt-in​

The pieces above are a deliberate layering. A type's visibility widens only when you ask:

  • Declaring (property_owner + the macros) gives a type its identity, inheritance, properties, and serialization. A declaration alone is never script-visible.
  • jai::registrar<T> opts the type in: one static line, and every engine the host hands to bind_registrar knows it. Engines that never make that call (sandboxes, workers, network peers) know nothing.
  • dynamic_binder<T>(eng, "Name") opts one engine in, with a curated name and engine-specific methods.

Whichever door a type enters, its property_owner bases follow it inside that engine (inheritance, below), and it carries one name everywhere: the in-class pin when declared, else the bare class name, always matching the serialization $type. Registration is the reachability boundary: engine::make() binds nothing on its own, which is exactly what makes an empty engine a sandbox (Safety Rails).

Timing. Registration happens in two phases.

The static phase runs at program start. Declarations, schema entries, serialization identities and the registrar queue all set themselves up there. It touches no engine, and the order in which files run their statics does not matter.

The runtime phase runs in the order the host calls things:

  1. engine::make()
  2. jai::stdlib::register_all
  3. jai::bind_registrar, once per engine
  4. any explicit dynamic_binder chains
  5. scripts

Within steps 3 and 4 the order of registrations does not matter. Registrars replay in any order, and a derived class may be registered before its base. When it is, the derived registration auto-materializes the base: it registers the base's property_owner definition under the base's own name, without being asked. A later explicit registration of that base adopts the same definition and gives it the explicit name.

Registering more types after scripts have run is supported. New types appear. Re-registering a type that was bound explicitly replaces its binding for objects constructed from then on; objects that already exist keep the shape they were built with.

One rule is enforced: an explicit registration cannot adopt an auto-materialized base while script instances of it are alive. Adopting it would change their type identity, so the binder throws at the registration instead. In practice, finish registering before scripts construct objects, and treat the automatic names as internal. Give any class scripts construct directly a fixed script name with JAI_PROPERTY_OWNER.

Scripted​

Job one: scripted. No glue was written for id, level, interval, or onWave, yet property access, method calls, and signal connection all read like the class was written in script:

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

The constructor came free too: build() auto-registers a default constructor for any default-constructible, non-abstract type (and prefers a T(engine*) constructor when one exists). Signal connections take a name ("hud"): named connections are owned by the signal, so they live as long as the object and can be replaced or removed by name (disconnect("hud")).

Live objects, both directions​

Crossing the boundary never copies an object. make_object(shared_ptr) wraps your pointer (no copy, shared identity), so 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

The observable property completes the loop in the other direction: script observes, C++ mutates, and the change notification crosses the boundary. Assignments from either side fire it; unchanged writes don't:

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

Lifetime notes: eng->make_object<T>(args...) constructs a value in place (its copies copy); add_global_ref on a class instance wraps a non-owning alias (you guarantee the object outlives the engine). One engine serves one thread at a time. Two engines on two threads need no locks, because nothing is global.

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

The boundary hands script the C++ object itself: make_object(shared_ptr), a T& or std::shared_ptr<T> result, and a member whose type is a registered class all arrive without a copy. Once the object is in script, it follows the language's value rules, exactly as a script class instance does:

  • A copy of a value from a variable, a field or an element (Turret b = a;, var c = b;), a by-value parameter or return, a container literal's element ([a] as an array) and a by-value lambda capture ([a]() { ... }, [=]) make a separate C++ object through the class's copy constructor. Writes to the copy do not reach the original.
  • A variable, a by-value parameter or a by-value return that takes a call's T& result copies it, as C++ auto drops the reference: var c = owner.child();, auto c and Turret c are each a new Turret. A script function the host calls receives a T& argument the same way: a var p or Turret p parameter is a copy, a var& p parameter names the host's object.
  • A reference (var& g = gate;, auto& c = owner.child();, a & parameter) names the same object.
  • A handle shares the object with every copy of the handle, in a variable, a parameter or a lambda capture: new Turret(...), a shared_ptr<Turret> variable, and a std::shared_ptr<Turret> that a host function, method or property returns, that a host passes to a script callback, or that add_global or make_object takes: var g = gate; names the host's gate.

So to keep working on the host's object, use the global itself, a reference or a & parameter. The guide's Bound C++ objects walks through each case with one class.

Passing a bound property by reference follows the spelling: gate.level or, inside a method of a script class that derives Spawner, this.level handed to a & parameter binds the property itself, so the callee's writes land in the C++ member. Inside such a method a bare level is the same property: it reads and stores through the accessors like this.level, and f(level) into a & parameter writes back into it. A [=] lambda copies its value at creation; a [&] lambda reads the live property.

Reflected​

Job two: reflected. The same declaration is a runtime reflection surface. This is what editor inspectors and debug panels are built on. Walk every property by name, read typed values, and write through the property so observable change signals still fire:

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: hold a base pointer to a derived object and you still see the full most-derived chain. Per-property metadata rides along (value_type_id() for the C++ type, allow_save() for serialization eligibility), and the static side is queryable without an instance via Spawner::schema() / Spawner::all_property_names().

Serialized​

Job three: serialized. No save/load methods were written. One call walks the same property manager the other two jobs use. And notice what's inside the payload: the signal's named script receiver serialized with the object. The outer level is the archive's root record, whose value0 slot holds the pointer you passed, with its $id; your object is the $val.

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 reconstructs the object and re-arms the receiver. Pass the engine as the load context so persisted script receivers attach to it and execute on the next emit:

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 a different archive. No second format to maintain, and this object's payload is 374 bytes instead of 492 characters of JSON:

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

Ship saves in binary, debug them in JSON, diff them in code review. One declaration serves all of it. The receiver rule is deliberate: named script-text receivers persist; function-object receivers (script lambdas and C++ callbacks) are runtime-only and are skipped, never errored on. Serialization depth (versioning, migration hooks, $ref aliasing, custom archives) lives in Serialization.

Hot reload with state intact​

Script classes can extend bound C++ classes. Because the C++ half of such an object is the same live object across reloads, iterating on the script half never resets engine state. Redefine the class with a new field mid-session:

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, same session: a fresh execute() redefines the class with a new field, 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

Existing script fields kept their values (aggression=2), the new field took its initializer (burst=4), and the C++ base (its properties and its private total_waves_ counter) was neither destroyed nor rebuilt (waves=1). The migration rules, the migrate hook, and the it-always-succeeds contract are in Hot Reload.

The fluent chain, verb by verb​

Under the registrar sits jai::dynamic_binder<T> (include/jaiscript/core/dynamic_binder.hpp), the fluent builder you can also drive directly for types that aren't property_owners. The classic value-type example:

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)
    .build();
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

Two behaviors in that transcript deserve a sentence each. The chain Vec2(1,1).scale(3).translate(1,0).x runs entirely on C++ temporaries: a method returning T& into its receiver stays valid because the bound receiver is kept alive across the chain. The idiom is safe by construction, not by luck. And != worked without being registered: when only == exists, script != is its negation.

A method that returns T& hands script the object it refers to, without a copy, and the declaration decides what script does with it:

  • var c = owner.child(); copies it, as copies and handles describes: c is a new object, and member writes through it leave the owner's alone.
  • var& c = owner.child(); (or auto&, or C& c) binds a reference, as C++ binds one. Member writes land in the object the same way, and c = other; runs the class's bound operator=.

The bound operator= is the "=" method: .method("=", &C::operator=), or a lambda taking (C&, const C&). Further "=" overloads from other types resolve like any overload set. auto_bind binds "=" for a trivially copy-assignable class. A class that binds no "=" refuses the assignment with Cannot assign to a reference to 'C': its binding has no operator=, as C++ refuses a class without one. Binding "=" on a class whose assignment cannot compile (one copying a container of a move-only type) fails at the binding, as C++ does.

A script variable, field or element declared C holds its own object, and assigning into it runs the bound operator= on that object: a = other;, and r = other; after var& r = a;. Without a bound "=", the assignment replaces the held object with one the copy constructor builds from other; script owns that storage. A var variable is dynamic and takes a copy of other in place of what it held.

The verbs:

  • .constructor<Args...>(): typed constructor overload; script calls Vec2(3.0, 4.0) and overloads resolve by arity and C++ parameter types. .constructor<>() prefers a T(engine*) constructor when the type has one. If you register none, build() adds a default constructor for default-constructible types.
  • .method(name, &T::fn): member function pointer, const or not.
  • .method(name, lambda): a lambda whose first parameter is T& or const T& becomes a method (that parameter is self); any other lambda is a method that ignores its receiver (.method("twice", [](int64_t k) { return k * 2; }) answers obj.twice(4)). Same-arity overloads of one name resolve by C++ parameter signature: registering set(double, double) after set(int64_t, int64_t) adds an overload rather than clobbering it. Passing a member data pointer here is a compile error that tells you to use .property(). The ordinary call and the VM's direct lane invoke one copy of the lambda, so a stateful functor keeps one state.
  • A non-const T& parameter of a method, a static method or a constructor writes the callee's final value back into the script argument, exactly as for add_function (see the quickstart): gate.add(total, 5) with void add(int64_t& target, int64_t by) leaves the sum in total.
  • .method("+", ...): operator overloading by token, the same set a script class declares: + - * / % & | ^ << >> == != < <= > >= <=>, unary - + ~ (a lambda taking only the object), [], () and =, with to_bool for !v and if (v). The bound operators follow the script rules of the operators chapter: <=> supplies < <= > >= and ==, == supplies !=, and 5 == m asks m's == taking an int. A bound compound operator runs in place: .method("+=", [](Money& self, const Money& o) -> Money& { ... }) answers m += o for a variable, a field, an element, a map value or a reference, and the expression is m; with none bound, m += o is m = m + o. A lambda whose first parameter is another type and whose second is the class binds the reflected form, C++'s free operator-(int64_t, const Money&): .method("-", [](int64_t l, const Money& r) { ... }) answers 5 - m where 5 has no - for a Money. Arithmetic never reverses on its own. An operator counts for an operand only when one of its overloads takes it: with operator<(const Money&) and operator<=>(int64_t) bound, m < 5 runs the <=>, and with only operator+(const Money&), m + "!" is the builtin concatenation of m's text. When no bound overload takes the operands, the error names the binding to add: No bound 'Money * int': bind operator "*" taking int on Money, or convert the operand, and the checker warns where it sees a number left of a bound class's operator that has no reflected form. Script classes can overload operators from the other side, so a type can start in script and graduate to C++ without call sites changing.
  • .static_method(name, fn): free function or lambda (stateful lambdas included), called as Vec2::axes(). Bound-class arguments work (Vec2::dot(a, b) above); returning a bound class doesn't compile in either form. See the last section.
  • .property(name, &T::member): read/write field access. Setters are only generated for copy-assignable member types. A std::vector<E> member binds as a zero-copy live view with array-like methods (size/push/pop/at/[]). Members whose type is a registered class bind by reference, so mutation acts on the live member. A plain jai::signal<Sig> or signal_emitter<Sig> member binds as a script connect surface with this same one line. See the signals section.
  • .property(name, getter, setter): computed properties from lambdas or member function pairs; pass nullptr as the setter for read-only.
  • .property(name, &T::member, jai::skip_type_check): a property whose type isn't registered yet throws at registration time with a fix-it message; this tag acknowledges a deliberate circular dependency and skips the check. The classic case is self-reference: class Node { std::shared_ptr<Node> next; } binds next before Node itself is registered.
  • .static_property(name, &var) / .static_property(name, getter, setter): class-level values. The pointer form binds the live variable: Class::name reads it and Class::name = v writes it, so a change on either side is the other side's value. The lambda form reads through the getter and stores through the setter, which takes the one new value.
  • .base_class<Base>(): inheritance edge. Methods and properties of the base become callable on the derived, virtual dispatch preserved, and the polymorphic-load type check learns that a Base slot may hold this type. static_assert-guarded; diamond inheritance throws.
  • .post_load_hook(fn): the class's post_load method, [](T& self) or [](T& self, int version), which a script-side load (from_json, from_binary, from_base64) runs after it has written the object's fields.
  • .version(n): the number (1 by default) the engine's serialization registry keeps for the class. Only the binary writer of script values reads it: to_binary / to_base64 write it beside each bound object a script value holds, and from_binary / from_base64 pass it to the post-load hook when the class also has a .deserialization_factory (otherwise the hook receives 1). from_json passes the object's "$version" key, or 1. The C++ archives never read it, and Serialization recommends a format property instead.
  • .deserialization_factory(fn): construct non-default-constructible types on load; archive-only, context-only, and context+archive signatures are auto-detected.
  • .transparent_wrapper(unwrap): operations not found on the wrapper retry on the unwrapped value. This is how gate.threat + 2 worked: the observable wrapper unwraps to its int for arithmetic while keeping .on_change on the wrapper.
  • .auto_bind(mode) binds everything the declarations already know: property_owner schemas (JAI_PROPERTY values, JAI_OBSERVABLE_PROPERTY wrappers, JAI_SIGNAL_PROPERTY connect surfaces), base classes from the inheritance declaration, and common methods by concept (to_string(), size(), empty(), ==/!= from operator==). A static T::jai_auto_bind(dynamic_binder<T>&) hook lets a class bind its own private members. Modes: all, properties, hierarchy. It binds "=" only for a trivially copy-assignable class.
  • .bound_engine(): the engine this binder targets; configure lambdas use it for bind_signal_type and globals.
  • .serial_only_constructor(): the class's constructor or destructor touches state other objects share, so a parallel_for body may not construct it (next section).
  • .serial_method(name) / .serial_static_method(name): the method touches state other objects share, so a parallel_for body may not call it (calls from parallel bodies). A method's mark covers its non-const overloads; .serial_method(name, jai::serial_overloads::every) covers its const ones too.
  • .build(): finalizes registration. The destructor calls it if you forget; the binder is movable, non-copyable.

Serial-only constructors​

A parallel_for body may construct bound classes, and script classes over them (see parallel loops). The constructor and the destructor then run on a worker thread, and the binder takes them to be safe there: a constructor that only builds its own members is. One that touches state other objects share is not: it adds the object to a registry, takes a handle from a cache, looks up a service, connects to a signal hub, or attaches itself to another object. Mark such a class with .serial_only_constructor(), and a body that constructs it, or a script class over it, is refused when the statement is reached, before any element runs. One mark covers the constructor and the destructor alike: the object a worker makes is the one that dies there. An object of a marked class that loses its last reference on a worker anyway is not destroyed there: the statement releases it when its workers are done, on the calling thread, in the order of the elements that dropped it, then the order they dropped. Construction outside a parallel body is unaffected.

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)
    .build();
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

Calls from parallel bodies​

A parallel_for body, and the class code it reaches, calls bound C++ on its worker thread: free functions, static methods and methods are all parallel unless registered serial. Mark a call serial when it touches state other objects share (an audio or texture manager, a registry, a service lookup, the scene graph beyond the object itself, a network connection, a log): .serial_method("name") on the binder for a method's non-const overloads (a const getter of the same name stays parallel; .serial_method("name", jai::serial_overloads::every) takes the const ones too, for a const overload that still touches shared state), .serial_static_method("name") for a static method, and engine::serial_function("name") after registering a free function. A body that names a serial free function or static method is refused when the statement is reached; a serial method is refused when it is called, since the receiver's class is known only then. The library's own builtins are serial (print, I/O, JSON, reflection, the clock) apart from pure math, to_string, the web:: text functions and the crypto:: functions other than crypto::sha256_file. Leaving a method unmarked declares it safe to call from any worker at once, so a binding whose method changes state that other objects or threads share must register it serial: that is the whole contract a parallel body checks.

The receiver follows the signature. A non-const member function (a T& self in a lambda, a property store) runs only on an object the calling worker owns: the element it is running (a range or zipped element, or a parallel_reduce accumulator, which the statement proves the only element of its object), or one it constructed or copied. A const member function also runs on an object the worker did not make, such as one a C++ function hands back or one the body reads from the enclosing scope (a captured object: every worker reaches the same C++ object through a view of its own), because C++ constness says it does not change the object. A non-const call on such an object is refused when it runs. Arguments follow the rules a script call follows: a non-const T& parameter, which writes back into its argument, cannot take enclosing state.

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)
    .serial_method("publish", jai::serial_overloads::every)   // const, but it reaches a shared manager
    .build();
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); });
eng->serial_function("log_line");
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 serial_method() and cannot be called in a parallel body
parallel_for: host function 'log_line' is marked serial_function() and cannot be called in a parallel body at 10:37

Signals: one view, every signature​

Signal members reach script with no per-signature ceremony. A JAI_SIGNAL_PROPERTY on a property_owner is exposed by auto_bind; a plain jai::signal<Sig> or signal_emitter<Sig> member is exposed by one .property("name", &T::member) line. Either way, script sees the same three-verb surface:

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");

Script sees one generic Signal view for every signature (one class, not one per signature), and the lookup walks the whole inheritance chain, so a signal declared on a base class connects through a derived registration in either registration order: a Pickup base declares a signal, HealthPickup registers with base_class + auto_bind, and script connects on the derived object. Script-text receivers work the same way whether the object was constructed in script, made in C++, or loaded from a save.

How the view is built

Reading a signal member hands script an engine-wide Signal view over a type-erased operations table (signal_script_ops, include/jaiscript/signals/signal_view.hpp): three function pointers (connect, disconnect, connected) whose thunks instantiate at the binding site, where the emitter's concrete type is known. On the auto_bind path, JAI_SIGNAL_PROPERTY stamps its schema entry is_signal (properties/property_schema.hpp), and the binder turns that entry into a read-only property whose getter asks the live signal property for its erased table (property_base::signal_ops). That lookup is find_reflected_property: virtual, whole inheritance chain. Connecting text routes the binding engine into the emitter before the connect, so C++-side emits fire text receivers regardless of how the object came to exist.

A typed registration can sit on top of this: jai::bind_signal_type<Sig> registers a named class for one signature and takes precedence over the generic view at member reads. When to reach for it (named signal types, pre-registration, precedence) is covered in Signals & Observable Properties.

What the view doesn't do (and what to use instead):

  • It doesn't own the signal. A view is a member read of a live bound object, the same lifetime class as any C++-bound member reference. A view held past its owner dangles; keep the owner alive for as long as script holds the signal.
  • It doesn't set parameter_names. Script-text receivers connected through the view see whatever parameter names the emitter's owner set in C++ (none, by default). When a handler needs the emit arguments, connect a function receiver. Its parameters bind positionally, no naming required.

That is the binder's half of the signal story. Your half as the host (firing signals from C++ with the owner/view idiom, receiver lifetime and emit_while, the two receiver forms, and observable properties) is Signals & Observable Properties.

Inheritance and polymorphism​

Declare the C++ inheritance once (property_owner<Derived, Base>), and the same edge serves script dispatch, C++ upcasts, and polymorphic saves:

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(); })
    .build();
jai::dynamic_binder<HealthPickup>(*eng, "HealthPickup")
    .auto_bind()      // derives the Pickup base edge from property_owner<HealthPickup, Pickup>
    .build();         // healing AND the inherited name both bind — no re-exposure needed

(auto_bind reads the inheritance edge straight from the property_owner declaration. Classes that don't use property_owner declare the same edge explicitly with .base_class<Base>().)

Polymorphic serialization needs no extra registration at all. property_owner already recorded the $type identity and the derived→base edge. Save through the base pointer, load through the base pointer, get the derived object back:

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
    }
  }
}

The $type discriminator picked the concrete factory on load: no switch statement, no factory map, no slicing. On the script side, the same edge gives upcasts and inherited dispatch: a C++ function taking shared_ptr<Pickup> accepts a script-made 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

There is no registration-order rule. The property_owner declaration is the inheritance truth, and registration projects it completely: a derived registration materializes an unregistered property_owner base on the spot (methods from its jai_auto_bind, schema properties, signal views, all recursively up the chain), and an explicit registration arriving later adopts that same definition and gives it its curated script name. Existing bindings and derived instances follow, because it is the same definition. Methods and JAI_PROPERTY fields both inherit through the chain, and base-level declarations need no re-exposure on the derived binder. Auto-materialized bases are named by the shared ladder: a JAI_PROPERTY_OWNER(Name) / JAI_PROPERTY_OWNER_AS(Class, "Name") pin when the class declares one, else the bare C++ class name, the same name serialization uses for $type, so a type carries one identity everywhere. (Plain non-property_owner bases keep the explicit .base_class<B>() spelling, and a base whose name is already taken by a different type simply waits for its own registration.)

Script classes extending C++ classes (class SmartSpawner : Spawner, with super(...) calling the bound constructor) were demonstrated in the hot-reload section. That direction works with zero extra registration.

Plain structs and templates: static binders​

A plain struct or a class template can carry its binding as a compile-time declaration beside the type, at namespace scope outside any namespace: JAI_STATIC_BINDER(Type, "Name", ...) for a type, JAI_TEMPLATE_BINDER(Template, "Name", ...) for every instantiation of a one-parameter template (JAI_TEMPLATE_BINDER_2 for two). The chain inside takes .property and .method, written against T in a template binder. Nothing reaches an engine until the host asks for the instantiations it wants:

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, so Box<int64_t> is Box<int> and Box<double> is Box<float>; binding one width of a numeric argument binds its 32/64-bit sibling as well (Box<int32> above). The naming rules are the last two items of the next section. The type gets a default constructor; for constructor overloads, operators, base classes or signals use dynamic_binder.

What this doesn't do (and what to use instead)​

  • Auto-binding covers primitive, string, and signal properties. JAI_PROPERTY fields of int-family, float/double, bool, and std::string become script-visible automatically, and JAI_SIGNAL_PROPERTY members surface as connectable signal views. Container- and class-typed properties still reflect and serialize from the one declaration, but script access needs an explicit .property()/.method() in the configure lambda. A member-pointer .property() on a std::vector gets you the zero-copy live view.
  • Inherited properties resolve through derived registrations. Every schema-generated accessor (value, observable, and signal alike) looks the property up over the whole inheritance chain (the virtual find_reflected_property), so a base-declared JAI_PROPERTY or JAI_SIGNAL_PROPERTY reads, writes, and connects through a derived registration in either registration order. This matches serialization, which saves and loads every level's properties the same chain-walking way.
  • Function-object signal receivers don't serialize. Lambdas connected from script and C++ callbacks fire live but are skipped on save, by design. For handlers that should be part of an object's persistent state, connect named script-text receivers (connect("logger", "print(\"...\");")) and pass the engine as load context (from_json(eng, json, eng)) so they re-arm.
  • A static_method can't return a bound class. Returning Vec2 from a static_method, function pointer or lambda, is a compile error in the return-conversion path. Return primitives or strings from static methods; for factories, register a constructor overload or an engine-level function instead.
  • property_owner types round-trip as shared_ptr, not by value. Their property managers hold pointers into the object, so the types are deliberately move-pinned: from_json<Spawner> by value won't compile. Use from_json<std::shared_ptr<Spawner>>, as every example here does.
  • Reading an observable property yields a wrapper, not the raw value. In expressions it unwraps transparently (gate.threat + 2 works, and print(gate.threat) shows the value). Assign it to a typed local when you want the value itself.
  • No cross-thread object sharing. An engine, the objects registered into it and every value it makes belong to one thread at a time: another thread uses or drops them only while the engine is idle, with the hand-off synchronized. The zero-global-state design means the alternative is cheap: one engine per thread, values copy cleanly, and nothing needs a lock. There is no binder flag that makes a single bound object thread-shared.
  • Auto-named registrars can't name template instantiations. jai::registrar<Point<int>, Ctx> without an explicit name is a compile error pointing you at the named constructor. Pass "Pointi" (or any script-legal name) yourself. Template-syntax names like "Point<int>" register the base template name with the parser automatically; the base name stays an ordinary identifier, so a variable or script class may still be called Point, and only Point< followed by a type reads as the template.
  • JAI_TEMPLATE_BINDER names an instantiation the way a script spells it. Each argument takes its script type's name: script int and float are 64-bit, so int64_t spells int and double spells float (a script double is the same type), while narrower widths carry their size: int32_t is int32, uint8_t is uint8, C++ float is float32. std::vector<E> is array<E>, std::map<K, V> is map<K,V>, std::shared_ptr<T> is shared_ptr<T>, and a class argument takes its JAI_STATIC_BINDER or template-binder name. So Point<double> registers "Point<float>", and a script's Point<double> and Point<float> both reach it; Point<float32> reaches the C++ float one. bind_static_type binds a 32/64-bit sibling with either width (Point<int32> with Point<int>, Point<float32> with Point<float>). An argument with no script spelling is a compile error: specialize jai::type_name_helper<T> with the name scripts write for it. Two C++ types the script spells alike (int and long on MSVC) cannot both bind; the second bind_static_type throws.