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:
#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;
}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, andnew Turret(...)makes ashared_ptrhandle. 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 (
nullptrsetter) rejects stores withCannot assign to 'x': it is read-only. - Objects cross by identity.
make_object(shared_ptr)wraps the pointer without a copy as a handle, likeadd_global(name, shared_ptr), so script writes land in C++ (east.ammois 14) andas<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_refinstead binds a C++ object or variable you keep alive yourself. - Handles and null. A
std::shared_ptr<T>orstd::weak_ptr<T>parameter (by value orconst&) takes a scriptnullas an empty handle, as C++ takesnullptr, sopick(null)resolves to the handle overload of an overload set. Aweak_ptr<T>parameter also takes a handle or a scriptweak_ptr<T>, watching the same object; an expired one arrives empty. Returning astd::weak_ptr<T>to script is not supported (a scriptweak_ptrwatches a script handle, and a host one has none): returnshared_ptr<T>, and script takes its ownweak_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 asjammed); 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.levelto 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:
#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 aproperty_mgrand registers its serialization identity ($typename, polymorphic load factory) at program start. No separate serialization registration exists to forget.JAI_PROPERTY((type), name, default)declares ajai::property<T>member. The type goes in parentheses so templates with commas survive the macro. The member behaves like aTin 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 privatesignal_emitteryou fire (onWaveSignal(n)) and a publicjai::signalview scripts and other systems connect to, plus a hidden property that rides save/load. Declaring it is also the entire script registration:auto_bindsurfaces the signal as a connectable member (how, below). The emitter isname+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 aswave_emitter(n), connects and serializes ason_wave).jai::registraris a self-registering static: any binary that links this file knows how to bindSpawnerinto any engine. The configure lambda only names what the macros can't know: the methods. EverythingJAI_PROPERTYandJAI_SIGNAL_PROPERTYdeclared is bound automatically (registrarcallsauto_bind()for you).
Host setup is three lines; bind_registrar replays every registrar in the binary into the
engine you hand it:
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 callA 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 tobind_registrarknows 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:
engine::make()jai::stdlib::register_alljai::bind_registrar, once per engine- any explicit
dynamic_binderchains - 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:
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());wave 1 incoming
wave 2 incoming
north-gate at level 3: 2 waves so farThe 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:
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 runsgate wave 1The 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:
gate.threat.on_change([=](int old_value, int new_value) -> void {
print("threat {} -> {}", old_value, new_value);
});gate->threat = 5; // C++ write
eng->execute("gate.threat = gate.threat + 2;"); // script write; reads unwrap in expressionsthreat 0 -> 5
threat 5 -> 7Lifetime 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++autodrops the reference:var c = owner.child();,auto candTurret care each a newTurret. A script function the host calls receives aT&argument the same way: avar porTurret pparameter is a copy, avar& pparameter names the host's object. - A reference (
var& g = gate;,auto& c = owner.child();, a¶meter) 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(...), ashared_ptr<Turret>variable, and astd::shared_ptr<Turret>that a host function, method or property returns, that a host passes to a script callback, or thatadd_globalormake_objecttakes:var g = gate;names the host'sgate.
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:
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 firesprop: id
prop: interval
prop: level
prop: onWave
prop: threat
level via reflection: 7
threat 7 -> 9The 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.
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";{
"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:
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 filewave loggedBinary 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:
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 toowave loggedShip 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:
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:
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());reloaded: level=5 aggression=2 burst=4 waves=1Existing 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:
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();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)));length 5.0
sum (4.0, 6.0)
chain 4.0
after write 30.265491900843113
eq true
neq true
axes 2
dot 11.0Two 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:cis a new object, and member writes through it leave the owner's alone.var& c = owner.child();(orauto&, orC& c) binds a reference, as C++ binds one. Member writes land in the object the same way, andc = 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 callsVec2(3.0, 4.0)and overloads resolve by arity and C++ parameter types..constructor<>()prefers aT(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 isT&orconst T&becomes a method (that parameter isself); any other lambda is a method that ignores its receiver (.method("twice", [](int64_t k) { return k * 2; })answersobj.twice(4)). Same-arity overloads of one name resolve by C++ parameter signature: registeringset(double, double)afterset(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¶meter of a method, a static method or a constructor writes the callee's final value back into the script argument, exactly as foradd_function(see the quickstart):gate.add(total, 5)withvoid add(int64_t& target, int64_t by)leaves the sum intotal. .method("+", ...): operator overloading by token, the same set a script class declares:+ - * / % & | ^ << >> == != < <= > >= <=>, unary- + ~(a lambda taking only the object),[],()and=, withto_boolfor!vandif (v). The bound operators follow the script rules of the operators chapter:<=>supplies< <= > >=and==,==supplies!=, and5 == masksm's==taking an int. A bound compound operator runs in place:.method("+=", [](Money& self, const Money& o) -> Money& { ... })answersm += ofor a variable, a field, an element, a map value or a reference, and the expression ism; with none bound,m += oism = m + o. A lambda whose first parameter is another type and whose second is the class binds the reflected form, C++'s freeoperator-(int64_t, const Money&):.method("-", [](int64_t l, const Money& r) { ... })answers5 - mwhere 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: withoperator<(const Money&)andoperator<=>(int64_t)bound,m < 5runs the<=>, and with onlyoperator+(const Money&),m + "!"is the builtin concatenation ofm'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 asVec2::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. Astd::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 plainjai::signal<Sig>orsignal_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; passnullptras 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; }bindsnextbeforeNodeitself is registered..static_property(name, &var)/.static_property(name, getter, setter): class-level values. The pointer form binds the live variable:Class::namereads it andClass::name = vwrites 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 aBaseslot may hold this type.static_assert-guarded; diamond inheritance throws..post_load_hook(fn): the class'spost_loadmethod,[](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_base64write it beside each bound object a script value holds, andfrom_binary/from_base64pass it to the post-load hook when the class also has a.deserialization_factory(otherwise the hook receives 1).from_jsonpasses 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 howgate.threat + 2worked: the observable wrapper unwraps to itsintfor arithmetic while keeping.on_changeon the wrapper..auto_bind(mode)binds everything the declarations already know:property_ownerschemas (JAI_PROPERTYvalues,JAI_OBSERVABLE_PROPERTYwrappers,JAI_SIGNAL_PROPERTYconnect surfaces), base classes from the inheritance declaration, and common methods by concept (to_string(),size(),empty(),==/!=fromoperator==). Astatic 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 forbind_signal_typeand globals..serial_only_constructor(): the class's constructor or destructor touches state other objects share, so aparallel_forbody may not construct it (next section)..serial_method(name)/.serial_static_method(name): the method touches state other objects share, so aparallel_forbody 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.
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();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);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.5Calls 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.
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");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); }[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:37Signals: 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:
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:
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:
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{
"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:
eng->add_function("describe", [](std::shared_ptr<Pickup> p) { return p->name.get(); });var h = HealthPickup();
h.name = "elixir";
h.healing = 40;
print("upcast: {} heals {}", describe(h), h.healing);
print("inherited method: {}", h.kind());upcast: elixir heals 40
inherited method: pickup:elixirThere 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:
#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 timePoint 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));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_PROPERTYfields ofint-family,float/double,bool, andstd::stringbecome script-visible automatically, andJAI_SIGNAL_PROPERTYmembers 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 astd::vectorgets 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-declaredJAI_PROPERTYorJAI_SIGNAL_PROPERTYreads, 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_methodcan't return a bound class. ReturningVec2from astatic_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_ownertypes round-trip asshared_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. Usefrom_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 + 2works, andprint(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 calledPoint, and onlyPoint<followed by a type reads as the template. JAI_TEMPLATE_BINDERnames an instantiation the way a script spells it. Each argument takes its script type's name: scriptintandfloatare 64-bit, soint64_tspellsintanddoublespellsfloat(a scriptdoubleis the same type), while narrower widths carry their size:int32_tisint32,uint8_tisuint8, C++floatisfloat32.std::vector<E>isarray<E>,std::map<K, V>ismap<K,V>,std::shared_ptr<T>isshared_ptr<T>, and a class argument takes itsJAI_STATIC_BINDERor template-binder name. SoPoint<double>registers"Point<float>", and a script'sPoint<double>andPoint<float>both reach it;Point<float32>reaches the C++floatone.bind_static_typebinds a 32/64-bit sibling with either width (Point<int32>withPoint<int>,Point<float32>withPoint<float>). An argument with no script spelling is a compile error: specializejai::type_name_helper<T>with the name scripts write for it. Two C++ types the script spells alike (intandlongon MSVC) cannot both bind; the secondbind_static_typethrows.