Signals & Observable Properties​

Gameplay is event-shaped: a door opens, a wave spawns, three unrelated systems need to hear about it without the door knowing any of them exist.

JaiScript ships two small C++ frameworks for exactly that. jai::signal is a typed multicast event whose receivers can be C++ lambdas or script; jai::observable_property<T> is a value that announces its own changes. Together they are how a host engine wires gameplay events to script.

This chapter is your tour: firing and receiving signals from C++, observable values, and the three routes a signal takes to become script-connectable. How signal members get onto bound classes in the first place (the one-declaration JAI_SIGNAL_PROPERTY route and the machinery beneath it) is the binder chapter's deep dive. Headers: include/jaiscript/signals/, include/jaiscript/properties/.

jai::signal: private emitter, public view​

The founding idiom, and the one worth copying: the class owns a private emitter and exposes a public signal view. Outsiders can connect, only the owner can fire.

C++
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 remains

Output:

C++ receiver: opened to 42
script receiver: opened to 42
C++ receiver: opened to 7

API essentials (signals/signal_decl.hpp)​

  • Connection lifetime = receiver lifetime. connect(...) is [[nodiscard]] and returns signal<T>::shared_receiver_type (alias jai::receiver<T>::shared_type); hold it in a member, drop it to disconnect. Alternatively use owned named connections (connect("id", callback) / disconnect("id")), where the emitter owns the receiver, or connect_oneshot(...) for fire-once.
  • Script receivers come in two forms: a source snippet (fired with parameter_names bound, as above) or a script function value (connect(id, script_value fn)). A failing receiver, C++ or script of either form, throws out of the emit, and the receivers after it in that pass do not run (When a receiver fails).
  • emit_while(...) treats receivers as predicates (stop propagation on false); receivers can block()/unblock() themselves.
  • An emit runs the receivers connected when it started. A receiver may connect, disconnect or re-emit the signal while it fires. One it connects runs from the next emit on (an emit started after the connect, a re-emit say, is a new emit and runs it), so a receiver that connects another each time it fires cannot keep an emit going. One that leaves before the emit reaches it does not run, whether by disconnect(c), disconnect("id"), clear(), its last handle dropped, or a connect of the same receiver again, which moves it to the end of the list as a new connection. connect_immediate(...) takes every argument form connect takes, and its receiver also joins every emit already running, the one in progress and each emit it is nested in.
  • Threading is chosen per signal at compile time. signal_emitter<Sig> takes no lock; signal_emitter<Sig, jai::signal_synchronized> locks for use from several threads (Threads).
  • Header hygiene: a header that only declares signal members includes signals/signal_decl.hpp. A translation unit that emits includes signals/signal.hpp (the umbrella) or signal_impl.hpp: every emit instantiates the script-receiver path, whose definitions live there with the engine glue, so emitting with only signal_decl.hpp in scope leaves unresolved symbols at link time.

All of it (C++ and script receivers, oneshot, named connections, serialization of signal-bearing types) is covered by a dedicated test suite.

observable_property​

C++
#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";   // 50

Output: hp: 100 -> 75, hp: 75 -> 85, final hp: 50.

Semantics from the header (properties/observable_property.hpp): assignment emits only on actual change (old != new); on_change is a public signal<void(const T&, const T&)> over a private emitter (same idiom); ++/--/compound ops all emit; set_silent mutates without notifying. Properties register themselves with their property_manager (that's what powers schema-driven auto_bind and automatic serialization of JAI_PROPERTY fields). Hosts coming from Qt's Q_PROPERTY, RTTR or refl-cpp have a mapping in From Qt Properties, RTTR and refl-cpp.

One linking gotcha: a standalone TU that instantiates property<T> needs properties/property_serialization.hpp (or anything that pulls serialization/archive_impl.hpp). The property's virtual serialize methods are defined there. dynamic_binder.hpp includes it already, so binder-using TUs never notice.

Connecting from script​

Script sees every signal, however its class was registered, as the same three-verb surface: connect(name, handler), disconnect(name), connected(name). Handlers come in the same two receiver forms C++ uses: a function (script lambda: parameters bind positionally from the emit arguments, no naming required) or a script-text snippet (runs with the emitter's parameter_names in scope). C++ emits fire both. There are three routes a signal takes to reach script, from most to least automatic.

1. Auto-bound: the one-declaration route​

For a property_owner class registered the one-declaration way from the binder chapter, declaring JAI_SIGNAL_PROPERTY(void(int64_t), onWave) was the entire script registration. auto_bind surfaces the member, and script connects immediately. The Spawner from that chapter again:

jaiscript · needs the Spawner registration from the binder chapter
auto s = Spawner();
s.onWave.connect("hud", [=](int wave) -> void {
    print("wave {} incoming", wave);
});
s.spawn_wave();
s.spawn_wave();
output
wave 1 incoming
wave 2 incoming

This is the default route for engine classes. Reach for it first. Script-text receivers connect through the same verb (s.onWave.connect("logger", "print(\"wave logged\");")) and are the receivers that can ride save files, named or unnamed; the binder chapter's serialization section shows one surviving a JSON round-trip and firing after load.

2. Any signal member, one binder line​

A class that isn't a property_owner exposes a plain jai::signal<Sig> or signal_emitter<Sig> member (any signature) with one .property line. Here the Door from above:

C++
jai::dynamic_binder<Door>(*eng, "Door")
    .constructor<>()
    .method("open", &Door::open)
    .property("onOpen", &Door::onOpen)     // the whole script exposure
    .build();
jaiscript · needs the host registration above
var 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"));
output
opened to 42
silent after disconnect: true

Both routes hand script one generic engine-registered Signal view over a type-erased operations table. One class serves every signature. The mechanism, and the two honest edges worth knowing before shipping (a view is a member read of a live bound object and dangles past its owner; text receivers connected through the view see whatever parameter_names the owner set in C++), are covered in the binder chapter's signals section.

3. bind_signal_type: the explicit typed form​

When you want a named script-visible type for one signature, jai::bind_signal_type<Sig>(engine, "Name") (signals/signal_binding.hpp) registers signal<Sig> under "Name" and signal_emitter<Sig> under "NameEmitter", each with the same three verbs:

C++
jai::bind_signal_type<void(int)>(*eng, "IntSignal");   // "IntSignal" + "IntSignalEmitter"

Connecting never requires it. Routes 1 and 2 cover every signature generically. Reach for the typed form when:

  • You want the signature to have a name. A registered type name makes signals of that shape first-class citizens of the registration surface, visible to registration-time type checks and nameable in configure lambdas (binder.bound_engine() is where registrars call it).
  • You want to pre-register the surface independent of any owning class. The registration is idempotent (is_type_registered guards it), so shared signal signatures can be bound once at engine setup.
  • Precedence. A typed registration wins: .property member reads of that signature hand out the typed object instead of the generic view. Behavior is identical (same verbs, same receivers), so this is an opt-in nicety, never a requirement.

Notes that apply to all three routes​

  • What the checker sees. A bound class is a declared name, so auto s = Spawner(); passes strict; the signal member itself is a binder property, which the checker leaves untyped. connect / disconnect / connected and their arguments are checked at run time, never at load. A handler lambda's body is checked like any other lambda. (Rules in Static Checking.)
  • observable_property<T> members registered via the property schema (auto_bind on a property_owner) surface to script as transparent wrappers: player.score + 5 forwards to the underlying int, while player.score.on_change resolves on the wrapper, identically on both backends.
  • A connect from script uses the default: a receiver script connects while the signal is emitting runs from the next emit on, as a C++ connect does. connect_immediate is C++ only.
  • When C++ invokes a script function directly outside a normal call (converted callbacks, signal receivers), wrap the invocation in jai::engine::external_call_guard. Without it, reference parameters would bind against whatever script call happens to be in flight (engine.hpp; the signal glue does this for you).

Receivers in detail​

When a receiver fails​

A failing receiver throws out of the emit, whatever its kind: a C++ callback's exception, a script-text receiver's error and a script function receiver's error alike. The receivers after it in that pass do not run. The emitter stays consistent as the exception unwinds (entries disconnected during the emit, fired one-shots and dead entries are still cleaned up), so the emit site can catch the exception and fire 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 (dropping the last reference to the object that owns it, say): the emit stops there, the receivers after it do not run, and the emit returns normally. A one-shot receiver 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.

Threads​

An emitter's threading policy is its second template argument, chosen per signal at compile time. signal_emitter<Sig> (jai::signal_unsynchronized, the default) takes no lock and holds no mutex: one thread at a time uses the emitter and its facade, for every call. A receiver's own block(), unblock() and connected() are safe from any thread under either policy; block() stops the notifications that start after it, while one already running on another thread finishes. Dropping a C++ receiver's handle is safe from any thread too. A script receiver holds script values, so its last handle drops on its engine's thread, or while that engine is idle.

signal_emitter<Sig, jai::signal_synchronized> owns one recursive mutex, held by every call on the emitter (connect, connect_immediate, disconnect, connected, observer_count, clear, block, unblock, set_blocked_callback, script_engine, parameter_names, a save or a load) and by the whole of an emit, receivers included. Its facade is jai::signal<Sig, jai::signal_synchronized>, JAI_SYNCHRONIZED_SIGNAL_PROPERTY(Sig, name) declares the pair on a property_owner, and bind_signal_type<Sig, jai::signal_synchronized> registers it for script. That is the guarantee, and it has three consequences:

  • Emits of one signal from different threads run one after another, and a connect or disconnect from another thread waits for an emit in progress to finish.
  • A receiver may still connect, disconnect or re-emit the same signal while it fires (the mutex is recursive).
  • Because the lock is held while receivers run, a receiver must not wait on another thread that is connecting to or emitting the same signal.

An emitter never moves under either policy, because its facade refers to it, and under the synchronized policy it outlives every thread that uses it: its destructor takes no lock. Receivers run on the emitting thread, and a script receiver runs on its engine there, so a signal with script receivers is emitted on the thread that is using that engine (one engine serves one thread at a time). There is no queued delivery; a host that emits from a worker and wants receivers on its main thread hands the emit to that thread itself.

Destroy order​

The engine and the emitters its script connected to may be destroyed in either order. Once an engine is destroyed, its script receivers (source snippets and script function values, named or not, and the on_change callbacks script set on an observable property) are skipped: an emit passes over them as if they were blocked, so emit_while continues past them, while C++ receivers on the same signal keep firing. A text receiver connected after the engine is gone is skipped the same way. Dropping a receiver, an emitter or the object that owns it after the engine is gone releases what the script receivers captured without running any script: no receiver body and no script destructor. A std::function the host converted from a script function with convert_script_function and kept past its engine throws Cannot call a script function after its engine was destroyed when called. Script values held directly by the host (a script_value member, say) should still be dropped before the engine: releasing one afterwards runs no script, but nothing else may be done with it.

Script receivers and hot reload​

A receiver is a C++ callable, a script-text receiver or a script function receiver, and both script forms connect from either side. From C++, connect("text") returns a handle like any other, while connect("id", "text") and connect("id", fn) (a function script_value) are named connections. From script, obj.sig.connect("name", handler) takes a function or lambda, or a string of source. A function receiver receives the emit's arguments positionally; a text receiver runs its source with the arguments bound as locals under the emitter's parameter_names (arg_0, arg_1, ... when none are set), on the engine the emitter was given with script_engine(eng).

Under hot reload, connections are C++ state on the emitter, so a reload neither drops nor duplicates them, and re-running the code that connects under a name replaces the receiver of that name. What a receiver runs after the reload depends on its form:

  • A text receiver runs its source each time the signal fires, so the names in it resolve to whatever is defined at that moment, reloaded definitions included.
  • A lambda resolves the program-scope names it calls when it runs, so [=](int w) -> void { on_wave(w); } reaches a reloaded on_wave.
  • A named function passed directly (connect("hud", on_wave)) is the function value it was when connected and keeps that body; wrap it in a lambda, or re-run the connect, to follow reloads.

Connections that save​

JAI_SIGNAL_PROPERTY gives the signal a hidden property that serializes the emitter with the owner's other properties, in JSON or binary. It writes the signal's parameter_names and each script-text receiver with its source and parameter names, a named one by id; loading rebuilds those receivers on the archive's engine and reconnects them, a named one under its id and an unnamed one held by the emitter, ready to fire. C++ callbacks and script function receivers are runtime-only: a function value is not text, so it is not written, and the host reconnects them after a load.

A load replaces the signal's script-text receivers with the saved ones and leaves its C++ callbacks and function receivers connected, so loading into an object that already holds receivers never doubles them. An unnamed text receiver that C++ held by its handle before the save comes back held by the emitter: it runs until disconnect, clear() or the emitter's death, and nothing outside needs to hold it.

A saved script-text receiver is script source, and it runs on the next emit after the load. Load an object with signal properties only from a trusted source, or on an engine under the safety rails.

Coming from another signal library​

Code written against Boost.Signals2, Qt or a sigslot library moves over with From Boost.Signals2, which maps every type and call, converts a class, and covers what behaves differently: who can emit, connection lifetime, emits during emits, the per-signal lock, and return values.