Signals & Observable Properties
A door opens, a wave spawns, and three unrelated systems need to hear about it without the door knowing they exist. jai::signal is a typed multicast event whose receivers are C++ callables or script. jai::observable_property<T> is a value that announces its own changes. The model:
- Private emitter, public view. A class owns a
jai::signal_emitter<Sig>it fires and exposes ajai::signal<Sig>others connect to. - A connection lives as long as its receiver.
connect(...)returns a handle: hold it, and drop it to disconnect. A named connection (connect("id", ...)) is owned by the emitter untildisconnect("id"). - Three receiver kinds: a C++ callable, a script function, or script text. Only script text is saved with the object.
- An emit runs, on the emitting thread, the receivers connected when it started. A receiver that fails throws out of the emit.
- Script sees every signal the same way:
connect(name, handler),disconnect(name),connected(name).
Putting signal members on bound classes with JAI_SIGNAL_PROPERTY is covered in the binder chapter. Code written against Boost.Signals2, Qt or a sigslot library has a mapping in From Boost.Signals2.
jai::signal: private emitter, public view
class Door {
private:
jai::signal_emitter<void(int)> onOpenSignal;
public:
jai::signal<void(int)> onOpen; // public connect surface
Door() : onOpen(onOpenSignal) {}
void configure(jai::engine* eng) {
onOpenSignal.script_engine(eng) // enable script receivers
.parameter_names({"width"}); // names visible to script snippets
}
void open(int width) { onOpenSignal(width); } // only the owner emits
};
Door door;
door.configure(eng.get());
auto cppConn = door.onOpen.connect([](int w) { std::cout << "C++ receiver: opened to " << w << "\n"; });
auto scriptConn = door.onOpen.connect(
"print(\"script receiver: opened to \" + to_string(width));");
door.open(42); // both receivers fire
scriptConn = nullptr; // dropping the receiver disconnects
door.open(7); // only the C++ receiver remainsC++ receiver: opened to 42
script receiver: opened to 42
C++ receiver: opened to 7| Call | What it does |
|---|---|
connect(callable), connect("text") | [[nodiscard]]; returns signal<T>::shared_receiver_type (alias jai::receiver<T>::shared_type). Dropping the last handle disconnects. |
connect("id", callable), connect("id", "text"), connect("id", fn) with a function script_value | A named connection the emitter owns. Connecting under a name again replaces that receiver; disconnect("id") removes it. |
connect_oneshot(...) | Fires once, even when it re-emits its own signal. One that an emit_while stopped short of has not fired and waits for a later emit. |
connect_immediate(...) | Takes every form connect takes; the receiver also joins every emit already running. C++ only. |
emit_while(...) | Treats receivers as predicates and stops at the first false. |
a receiver's block(), unblock(), connected() | Mute, unmute and query one receiver. |
emitter.script_engine(eng) | Enables script receivers and names the engine text receivers run on. |
emitter.parameter_names({...}) | The local names a text receiver sees the emit arguments under (arg_0, arg_1, ... when none are set). A function receiver takes the arguments positionally and needs no names. |
Connecting and disconnecting during an emit. A receiver may connect, disconnect or re-emit the signal while it fires. A receiver connected during an emit runs from the next emit on, and a re-emit is a new emit that includes it. A receiver that leaves before the emit reaches it does not run, whether by disconnect, clear(), its last handle dropped, or a second connect of the same receiver, which moves it to the end of the list as a new connection.
Includes. A header that only declares signal members includes signals/signal_decl.hpp. A translation unit that emits includes signals/signal.hpp (or signal_impl.hpp): an emit instantiates the script-receiver path defined there, and without it the link fails with unresolved symbols.
observable_property
#include <jaiscript/properties/property_manager.hpp>
#include <jaiscript/properties/property_serialization.hpp> // property<T> vtable lives here
#include <jaiscript/properties/observable_property.hpp>
jai::property_manager mgr;
jai::observable_property<int> hp(mgr, "hp", 100);
auto watch = hp.on_change.connect([](const int& oldV, const int& newV) {
std::cout << "hp: " << oldV << " -> " << newV << "\n";
});
hp = 75; // emits (100 -> 75)
hp += 10; // arithmetic ops emit too (75 -> 85)
hp.set_silent(50); // NO signal — the loading/initialization path
std::cout << "final hp: " << hp.get() << "\n"; // 50This prints hp: 100 -> 75, hp: 75 -> 85, final hp: 50.
- Assignment,
++,--and the compound operators emit, and only when the value actually changes.set_silentchanges it without notifying. on_changeis a publicsignal<void(const T&, const T&)>over a private emitter.- A property registers itself with its
property_manager, which is whatauto_bindand the archives read. - A translation unit that instantiates
property<T>includesproperties/property_serialization.hpp, where the property's virtualserializeis defined.dynamic_binder.hppalready includes it.
Hosts coming from Qt's Q_PROPERTY, RTTR or refl-cpp have a mapping in From Qt Properties, RTTR and refl-cpp.
Connecting from script
A script handler is a function (its parameters take the emit arguments positionally) or a string of source (run with the emitter's parameter_names in scope). A C++ emit fires both. A signal reaches script by one of three routes:
1. Auto-bound. On a property_owner class, JAI_SIGNAL_PROPERTY(void(int64_t), onWave) is the whole registration: auto_bind exposes the member. This is the route to reach for first. With the Spawner of the binder chapter:
auto s = Spawner();
s.onWave.connect("hud", [=](int wave) -> void {
print("wave {} incoming", wave);
});
s.spawn_wave();
s.spawn_wave();wave 1 incoming
wave 2 incoming2. One binder line. Any other class exposes a jai::signal<Sig> or signal_emitter<Sig> member of any signature with .property. The Door above:
jai::dynamic_binder<Door>(*eng, "Door")
.constructor<>()
.method("open", &Door::open)
.property("onOpen", &Door::onOpen); // the whole script exposurevar d = Door();
d.onOpen.connect("watch", [=](int w) -> void { print("opened to {}", w); });
d.open(42);
d.onOpen.disconnect("watch");
d.open(7);
print("silent after disconnect: {}", !d.onOpen.connected("watch"));opened to 42
silent after disconnect: trueRoutes 1 and 2 hand script one generic Signal view that serves every signature (how the view works). A view is a member read of a live object and dangles past its owner, so keep the owner alive while script holds the signal.
3. bind_signal_type, a named type. jai::bind_signal_type<Sig>(engine, "Name") (signals/signal_binding.hpp) registers signal<Sig> as "Name" and signal_emitter<Sig> as "NameEmitter", with the same three verbs:
jai::bind_signal_type<void(int)>(*eng, "IntSignal"); // "IntSignal" + "IntSignalEmitter"Connecting never requires it. Use it to give a signature a name that registration-time type checks can see, or to register a shared signature once at engine setup, independent of any owning class; the call is idempotent, and a registrar's configure function reaches the engine through binder.bound_engine(). A typed registration takes precedence: a .property read of that signature hands out the typed object instead of the generic view, with identical behavior.
- The checker leaves a signal member untyped:
connect,disconnect,connectedand their arguments are checked at run time. A handler lambda's body is checked like any lambda (Static Checking). - Observable members exposed by
auto_bindreach script as transparent wrappers:player.score + 5uses the value, andplayer.score.on_changeresolves on the wrapper. - Calling a script function from C++ outside a script call (a converted callback, a hand-written receiver) goes inside a
jai::engine::external_call_guard, so reference parameters do not bind against a script call that happens to be in flight. Signals do this themselves.
Receivers in detail
When a receiver fails
A failing receiver of any kind throws out of the emit, and the receivers after it in that pass do not run. The emitter stays consistent as the exception unwinds (disconnected entries and fired one-shots are still cleaned up), so the emit site can catch and emit again. When script triggered the emit through a bound method, the error is that call's run-time error and a script try/catch around the call catches it.
A receiver may destroy the emitter, for example by dropping the last reference to its owner. The emit stops there and returns normally.
Threads
The threading policy is the emitter's second template argument, chosen per signal at compile time.
The default, signal_emitter<Sig>, takes no lock: one thread at a time uses the emitter and its view. signal_emitter<Sig, jai::signal_synchronized> holds one recursive mutex for the whole of an emit and for every other call on the emitter. Each policy's types, macro and binding, and what the lock implies: signal threading policies.
- Under either policy, a receiver's own
block(),unblock()andconnected()are safe from any thread (block()stops notifications that start after it), and so is dropping a C++ receiver's handle. A script receiver holds script values, so its last handle drops on its engine's thread or while that engine is idle. - An emitter never moves, because its view refers to it. A synchronized emitter must outlive every thread that uses it: its destructor takes no lock.
- There is no queued delivery. Receivers run on the emitting thread, so a signal with script receivers is emitted on the thread using that engine (one engine serves one thread at a time). A host that emits from a worker and wants receivers on its main thread hands the emit over itself.
Destroy order
An engine and the emitters its script connected to may be destroyed in either order. Once the engine is gone:
- Its script receivers are skipped as if blocked (text, function values, and script
on_changecallbacks), soemit_whilecontinues past them and C++ receivers keep firing. A text receiver connected afterwards is skipped too. - Dropping a receiver, an emitter or its owner releases what script receivers captured without running any script: no receiver body, no script destructor.
- A
std::functionmade byconvert_script_functionthrowsCannot call a script function after its engine was destroyedwhen called. - Script values the host holds directly should still be dropped before the engine. Releasing one afterwards runs no script, and nothing else may be done with it.
Script receivers and hot reload
Connections are C++ state on the emitter, so a reload neither drops nor duplicates them, and re-running code that connects under a name replaces that name's receiver. What runs after the reload depends on the receiver:
- Script text is run each time the signal fires, so its names resolve to the current definitions.
- A lambda resolves the program-scope names it calls when it runs:
[=](int w) -> void { on_wave(w); }reaches a reloadedon_wave. - A named function passed directly (
connect("hud", on_wave)) keeps the body it had when connected. Wrap it in a lambda, or re-run the connect, to follow reloads.
Copies and moves
A signal_emitter neither copies nor moves. The emitter JAI_SIGNAL_PROPERTY declares (a jai::member_signal_emitter) and the one inside an observable_property follow the property_owner that holds them:
- A move takes the receivers: they stay connected and run when the new object emits, and the moved-from object emits to nobody. Move assignment lets go of the target's receivers first.
- A copy keeps the script engine and parameter names and starts with no receivers. Copy assignment leaves the target's receivers connected and sends an observable property's receivers no change.
- The receivers an owner holds through
track(...), and the script receivers its property manager keeps, move with it and are not copied. - A receiver that captured the old object's address still holds that address. Reconnect one that reaches back into its owner.
Connections that save
JAI_SIGNAL_PROPERTY gives the signal a hidden property that serializes with the owner's other properties, in JSON or binary.
- Saved: the signal's
parameter_namesand each script-text receiver with its source, a named one under its id. - Not saved: C++ callbacks and script function receivers. The host reconnects them after a load.
- Loading rebuilds the text receivers on the archive's engine, ready to fire, and replaces any text receivers the signal held; C++ and function receivers already connected stay. Loading into a live object therefore never doubles a receiver.
- An unnamed text receiver that C++ held by its handle comes back owned by the emitter and runs until
disconnect,clear()or the emitter's death. - A saved text receiver is code that runs on the next emit after the load. Load objects with signal properties only from a trusted source, or on an engine set up for untrusted script.