Script-Extensible Objects: extend
A script class can derive a bound C++ class, and the script decides which object becomes which class.
The host constructs and owns its objects; a script hands one of them a class with
extend(object, Class), and from that moment the object is that class everywhere (typed
fields, overrides, methods), no matter which road brings it back into script. This chapter covers the script
surface, the one-line host surface, who owns what, and how it serializes. The host-side examples below are
taken from source/tests/serialization/polymorphic_serialization_tests.cpp and run on
both backends.
One object, one identity
A base pointer to a derived object is the derived object, as in C++: once extended, the object reaches script as its script class through every shared_ptr<Base>, never as a sliced base.
The shape this exists for is a game whose C++ spawns creatures and whose scripts define what each kind of creature does. C++ never names a script class; it hands the creature over and the script extends it:
extern self; // the host provides `self` (its callback slots) before the script runs
class Slime : Creature { // Creature is the host's bound C++ class
int size = 3;
double hopCooldown = 0.0;
void update(double dt) override {
hopCooldown = hopCooldown - dt;
if (hopCooldown <= 0.0) { hop(); hopCooldown = 1.5; } // hop() is a Creature method
}
void death() override { // a big slime splits into two smaller ones
if (size <= 1) { return; }
for (int i = 0; i < 2; ++i) {
var child = spawn_creature(position); // the host spawns a plain C++ creature
extend(child, Slime).size = size - 1; // ...and the script says it is a smaller Slime
}
}
}
// the three callbacks this host happens to expose (spawn, update, death) - your host will
// have its own. It passes the C++ creature; the script says what it becomes.
self.spawn = [=](var& creature) -> void { extend(creature, Slime).spawn(); };
self.update = [=](var& creature, double dt) -> void { creature.update(dt); }; // runs Slime.update
self.death = [=](var& creature) -> void { creature.death(); }; // runs Slime.deathAfter extend, every road back into script (a bound method returning
shared_ptr<Creature>, a signal argument, a container element, a serialized
shared_ptr<Base>, the host's next update call) hands script the
Slime, not a sliced view of its base. That is C++'s own rule, "a base pointer to a derived object is
the derived object", carried across the boundary. The object is a hybrid: one script instance
that owns or references the C++ object (internal storage no script name reaches; the bound properties and methods
reach it) and carries the script layer (fields, overrides) on top. Overrides dispatch wherever script calls the method; a call C++ makes on the
object (creature->update(dt), or a bound C++ method that calls a virtual) runs the C++ body.
extend(object, Class)
extend is an engine-core builtin (every engine has it, sandboxes included). Class is
the bare class name (its constructor value carries the definition) or a string. It works in
place: the instance the handle already holds is re-classed where it stands, so nothing needs
rebinding and every other handle to the same object follows.
extern raw; // `raw` arrives from the host, so declare it
class Goblin : Creature { // Creature is a bound C++ class
int bite = 3;
string speak() override { return "voidkin"; }
}
class GoblinChief : Goblin {
int fangs = 1;
string speak() override { return "voidspawn"; }
}
extend(raw, Goblin); // raw arrived as a plain C++ handle; now it IS a Goblin
raw.bite = 17;
print(extend(raw, Goblin).bite); // idempotent: the same class returns the live layer
print(extend(raw, "Goblin").bite); // a string names the class too
extend(raw, GoblinChief); // re-derive in place
print(raw.bite, " ", raw.fangs, " ", raw.speak());17
17
17 1 voidspawnThe rules:
- The class's C++ base must be the object's C++ class or one of its bases (through any chain of script classes). The object keeps its own C++ type, so objects of different C++ types under one base share one script class. Anything else is refused, naming the class and its C++ base.
- Same class: no-op. The current layer is returned.
- Another class: re-derive. Every field both classes declare keeps its value
(
biteabove), the new class's own fields take their initializers (fangs), the new overrides dispatch, and fields only the old class declared stay reachable. No constructor runs. Field initializers do, in declaration order, base first, so a class meant for extension needs no constructor. - Every handle follows. A handle taken before the extend resolves to the layer on its next member access: reads, writes, getters, setters and bound methods all reach it.
- Pure script objects re-derive too.
extend(p, Pt3)on a script-only instance re-classes it in place under the same rule (Pt3must derivep's class). - Refusals are catchable in script (
try { extend(x, Wrong); } catch (e) { ... }) and thrown from the C++ entry point.
Write it as a statement (extend(kin, Slime); kin.bites = 1;) or chain it (extend(kin, Slime).spawn();). The object kin holds is the
re-classed one afterwards, so keep using kin rather than binding the result to a second name.
Try it without a host
Nothing above needs C++. Script classes re-derive under the same rule, so you can watch
extend work in the standalone runner:
class Pt { int x = 1; string what() { return "Pt"; } }
class Pt2 : Pt { int y = 2; string what() override { return "Pt2"; } }
class Pt3 : Pt2 { int z = 3; string what() override { return "Pt3"; } }
var p = Pt2();
p.x = 17; // a field Pt3 also declares
extend(p, Pt3); // re-derive in place
print(p.what() + " x=" + to_string(p.x) + " y=" + to_string(p.y) + " z=" + to_string(p.z));
try { extend(p, Pt); } catch (e) { print("refused: " + to_string(e)); }Pt3 x=17 y=2 z=3
refused: extend: 'Pt' does not derive 'Pt3'x survives because both classes declare it; z takes Pt3's
initializer; and Pt is refused because it does not derive p's class. Those are
the same three rules the C++ case obeys.
What the checker sees
Extension is decided at run time, so the checker does not
gate on it. Any name passed to extend(...) anywhere in a unit is open
for that whole unit (flow-insensitively, by name), and a bound C++ class stays open once any
script layer has been extended over it. A member the checker cannot find on such a receiver is not an
error; the ones it can find are still typed and still checked.
What it does still gate is the name itself. Under the strict default a handle the host injects is an
undeclared identifier, so scripts like the two above declare it: extern self;,
extern raw;. The declaration defines nothing at run time. It only tells the checker the name
arrives from outside the program. The typed form, extern shared_ptr<Creature> raw;, buys
member checking against Creature for everything the checker can still see; a name passed to
extend stays open either way.
Host side: script_extensible and engine::extend
Every property_owner is script-extensible for free: it derives the jai::script_extensible
mixin, a 32-byte slot that lets the object carry its own layer back to script (a type with nothing to reflect
can derive the mixin directly; a type that can derive nothing still works through an engine-owned lookup).
Under multiple inheritance the first extensible base's slot is the one used, re-exported as
jai_script_slot(). The host surface is small:
auto creature = std::make_shared<Creature>(); // C++ constructs and owns, as always
jai::script_value layer = eng->extend(creature, "Goblin"); // the same thing the builtin does
eng->add_global("hero", creature); // the Goblin: every entry hands script the object's layer, in any order
eng->execute("hero.bite = 17;");
std::shared_ptr<jai::class_instance> live = eng->script_layer(*creature); // the live layer, or null
std::string state = creature->script_layer_to_json(); // {"$script":{"bite":17,...},"$type":"Goblin"}
creature->script_layer_from_json(state); // hydrate in place (re-derives on another $type)
std::string fields = eng->script_layer_detach(creature); // the fields as JSON; the object carries no layer nowNothing is registered, cast, keyed, or looked up by hand, and C++ never bakes a class name into a binding:
engine::extend exists for hosts that want it, but the idiomatic shape keeps the choice in script.
Ownership
Ownership follows creation, and neither direction cycles:
- A hybrid the script constructed (
Goblin()) owns its C++ object; the object's slot only points back. - An object that was extended owns its layer for its lifetime. The layer references the
object without owning it (a raw pointer for member access, a weak pointer for
shared_ptrextraction), so the host stays free to destroy the object whenever it likes. If a script still holds the layer when the object dies, the layer is retired: its own script fields stay readable, but any C++ member access reports instead of dangling. - An object that only the script owns at the moment of
extend(one it made withnew, or one the host has let go of) stays the script's: its layer owns it, as a hybrid the script constructed does. - A type without the slot cannot own anything, so its extended layers are engine-owned (and keep
the object alive) until
script_layer_detach.
Copies follow the C++ base. Assignment of a hybrid (var b = a;) deep-copies (a copyable base
copies its C++ object too, giving two identities), while a hybrid over a base without a copy function is
nocopy automatically, exactly as a derived class of a nocopy script class is.
By-value capture ([=], [this]) shares a hybrid: the lambda pins that exact object, where a plain script value is copied into the lambda.
Serialization
A hybrid serializes as itself. Through any C++ archive path (a shared_ptr<Base>
field, a container, a save file) the writer emits $type = the script class name,
$script = the script-level fields, and $val = the C++ base's ordinary payload; the
reader follows the language's load order (Loading saved objects). A script class with a zero-argument constructor is constructed, its super(...) building the C++ object, and the C++ base's saved state then loads into that object through the base's own load. A script class without one is built over a C++ object made the way the archive makes any host type: the base's load_and_construct when it declares one, else its default constructor whatever its access (a private one through friend jai::access;). The script fields load next, and post_load runs if the class defines one (void post_load(int version): define it on the script class to fix up fields after a load; version is the archive's version stamp) once the outermost read is done, after a C++ root's own completion step. JSON and binary, back-references
included, and the script-level to_json(v) / from_json(s) do the same for a script
value. The script-level load follows the same order: the value it returns owns its C++ object as a hybrid the script constructed does, and a class without a zero-argument constructor over a base its registry entry cannot build, but whose binder registered a default constructor, is built through that. A base with neither a load_and_construct nor a default constructor is refused on every path: Cannot load hybrid script class 'Goblin': its C++ base 'Creature' is not default constructible and has no load_and_construct. Because extension runs field initializers before hydration, a field that was never written during play
still loads with its declared default. The archive framework itself (dispatch order, the binder chain as the
polymorphism declaration, the security posture) is the Serialization
chapter.
std::shared_ptr<Creature> original = eng->execute("v").as<std::shared_ptr<Creature>>();
auto loaded = jai::from_binary<std::shared_ptr<Creature>>(*eng, jai::to_binary(*eng, original)); // a fresh object...
eng->script_layer(*loaded) != nullptr; // ...carrying its Goblin layerMoving a layer between engines
A layer belongs to exactly one engine (its symbols, its class definition). Extending an object in engine
B while engine A holds its layer moves it: the fields travel by name, the new
layer is hydrated in B, and A's layer is retired. For slot-carrying types that is one
call: b->extend(obj, "Goblin"); a type without the slot needs the explicit pair
b->extend(obj, "Goblin", a->script_layer_detach(obj)), since no engine can see into
another's lookup. Layers are movable, never shared between threads: the same rule every script value follows.
Errors and cost
Inside the engine every refusal is an error code: extend_rejected (the class's C++ base is not
the object's C++ class or one of its bases, the argument is not a class, arity) and script_layer_retired (a detached,
moved, or outlived layer reached for its object), so script catches them like any runtime error; the C++
engine::extend throws at its boundary.
The lookup that hands script the layer is a pointer read on the object's slot (no allocation per crossing). In a creature-simulation workload the class-based scripts measured a few percent faster than the same logic kept in host-side maps, with byte-identical outcomes.