Signals are Perro's decoupled event bus. A script emits a named signal and any
connected handler runs, without the emitter knowing or caring who is listening.
This keeps gameplay systems independent: the boss does not call the music system
directly, it just emits boss_defeated and whoever cares reacts. Handlers are
ordinary script methods (func! / method!) connected by name, and emitted
Variant params flow through to them. Handler dispatch runs through the same
generated glue as call_method!, so every connected handler must be a pub fn
in a methods! block β see method visibility.
| Situation |
Choice |
Why |
Tradeoff |
| Boss announces phase change to arena, music, and UI |
signal |
Boss does not own or enumerate listeners |
No direct reply; payload/handler contract is dynamic |
| Switch commands one known door and needs success |
method, not signal |
One receiver and return value are part of the request |
Caller depends on the door method contract |
| UI has one-to-one button/handler pairs |
signal_connect_pairs! |
Each signal maps only to its corresponding handler |
Pair order must stay aligned |
| One event fans out to several handlers |
connect_many or explicit connections |
Every listener reacts independently |
connect_many creates a cartesian product, not positional pairs |
| Timer finishes delayed work |
timer_finished!(name) signal |
Timer owns delay; handler owns reaction |
One active timer slot exists per name, so concurrent work needs distinct names |
| Listener lifetime ends before emitter |
disconnect during owner cleanup |
Removes stale routing explicitly |
Cleanup must tolerate targets already absent |
- Script context path:
ctx.run
- Module access:
ctx.run.Signals()
- Lifecycle examples stay inside
lifecycle! because script hooks get API from the macro expansion.
A boss script emits a phase-change signal once its health drops past a
threshold. Connect the handler once in on_all_init, then emit from update
logic; the emitter never needs a reference to the arena systems that react.
#[State]
struct BossState {
#[default = 100.0]
pub health: f32,
#[default = false]
pub phase_two: bool,
}
lifecycle!({
fn on_all_init(&self, ctx: &mut ScriptContext<'_, API>) {
signal_connect!(ctx.run, ctx.id, signal!("boss_phase_two"), func!("on_phase_two"));
}
fn on_update(&self, ctx: &mut ScriptContext<'_, API>) {
let emit = with_state_mut!(ctx.run, BossState, ctx.id, |state| {
let emit = !state.phase_two && state.health <= 50.0;
state.phase_two |= emit;
emit
}).unwrap_or(false);
if emit {
signal_emit!(ctx.run, signal!("boss_phase_two"), params![]);
}
}
});
methods!({
// pub because signal dispatch uses the same glue as call_method!.
pub fn on_phase_two(&self, ctx: &mut ScriptContext<'_, API>) {
// Enrage. Re-broadcast so the music and arena hazards react too.
signal_emit!(ctx.run, signal!("music_set_intensity"), params![1.0_f32]);
}
});
| Field |
Detail |
| Access |
ctx.run.Signals() |
| Signature |
pub fn connect( &mut self, script_id: NodeID, signal: SignalID, function: ScriptMemberID, params: &[Variant], ) -> bool |
| Params |
&mut self, script_id: NodeID, signal: SignalID, function: ScriptMemberID, params: &[Variant], |
| Returns |
bool |
| Use when |
Use connect to connect in event routing; connection ownership and dynamic payload shape remain caller contracts. |
| Fails when / edge behavior |
Has no separate failure value in this wrapper; connect keeps the backing API behavior. |
| Field |
Detail |
| Access |
ctx.run.Signals() |
| Signature |
pub fn disconnect( &mut self, script_id: NodeID, signal: SignalID, function: ScriptMemberID, ) -> bool |
| Params |
&mut self, script_id: NodeID, signal: SignalID, function: ScriptMemberID, |
| Returns |
bool |
| Use when |
Use disconnect to disconnect in event routing; connection ownership and dynamic payload shape remain caller contracts. |
| Fails when / edge behavior |
Has no separate failure value in this wrapper; disconnect keeps the backing API behavior. |
| Field |
Detail |
| Access |
ctx.run.Signals() |
| Signature |
pub fn connect_many(...) -> usize |
| Params |
ctx, script, signals, functions, params |
| Returns |
usize new connection count |
| Use when |
Connect many signals to one function, or one signal to many functions. |
| Fails when / edge behavior |
Duplicate signal/script/function pairs do not add new connections. |
Example:
lifecycle!({
fn on_all_init(&self, ctx: &mut ScriptContext<'_, API>) {
let _ = signal_connect_many!(
ctx.run,
ctx.id,
[signal!("open"), signal!("close")],
[func!("on_window_signal")]
);
let _ = signal_connect_many!(
ctx.run,
ctx.id,
[signal!("changed")],
[func!("refresh_ui"), func!("mark_dirty")]
);
let _ = signal_connect_many!(
ctx.run,
ctx.id,
[signal!("hover"), signal!("click")],
[func!("play_sound"), func!("track_input")]
);
}
});
| Field |
Detail |
| Access |
ctx.run.Signals() |
| Signature |
pub fn disconnect_many(...) -> usize |
| Params |
ctx, script, signals, functions |
| Returns |
usize removed connection count |
| Use when |
Remove many signal/function links at once. |
| Fails when / edge behavior |
Missing signal/script/function pairs do not count as removed. |
Example:
let _ = signal_disconnect_many!(
ctx.run,
ctx.id,
[signal!("hover"), signal!("click")],
[func!("play_sound"), func!("track_input")]
);
| Field |
Detail |
| Access |
ctx.run.Signals() |
| Signature |
pub fn emit(&mut self, signal: SignalID, params: &[Variant]) -> usize |
| Params |
&mut self, signal: SignalID, params: &[Variant] |
| Returns |
usize |
| Use when |
Use emit to emit in event routing; connection ownership and dynamic payload shape remain caller contracts. |
| Fails when / edge behavior |
Has no separate failure value in this wrapper; emit keeps the backing API behavior. |
| Field |
Detail |
| Access |
ctx.run.Signals() |
| Signature |
signal_connect!(ctx.run, script, signal, function, params) |
| Params |
ctx, script, signal, function, params |
| Returns |
same as backing method |
| Use when |
Use signal_connect! to signal connect! in event routing; connection ownership and dynamic payload shape remain caller contracts. |
| Fails when / edge behavior |
Has no separate failure value in this wrapper; signal_connect! keeps the backing API behavior. |
| Field |
Detail |
| Access |
ctx.run.Signals() |
| Signature |
signal_connect_pairs!(ctx.run, script, [(signal, function), ...][, params]) -> usize |
| Params |
ctx, script, [(signal_name, function_name), ...], [params] |
| Returns |
usize (count of new connections) |
| Use when |
Use to wire many signals to their 1:1 same-purpose handlers at once. Unlike connect_many (cartesian product), each signal connects only to its paired function. Pair elements are name strings. |
| Fails when / edge behavior |
Each pair that fails to connect simply does not increment the returned count. |
| Field |
Detail |
| Access |
ctx.run.Signals() |
| Signature |
signal_disconnect!(ctx.run, script, signal, function) |
| Params |
ctx, script, signal, function |
| Returns |
same as backing method |
| Use when |
Use signal_disconnect! to signal disconnect! in event routing; connection ownership and dynamic payload shape remain caller contracts. |
| Fails when / edge behavior |
Has no separate failure value in this wrapper; signal_disconnect! keeps the backing API behavior. |
| Field |
Detail |
| Access |
ctx.run.Signals() |
| Signature |
signal_disconnect_many!(ctx.run, script, signals, functions) -> usize |
| Params |
ctx, script, signals, functions |
| Returns |
usize removed connection count |
| Use when |
Remove many signal/function links with one call. |
| Fails when / edge behavior |
Missing signal/script/function pairs do not increment the returned count. |
| Field |
Detail |
| Access |
ctx.run.Signals() |
| Signature |
signal_emit!(ctx.run, signal, params) |
| Params |
ctx, signal, params |
| Returns |
same as backing method |
| Use when |
Use signal_emit! to signal emit! in event routing; connection ownership and dynamic payload shape remain caller contracts. |
| Fails when / edge behavior |
Has no separate failure value in this wrapper; signal_emit! keeps the backing API behavior. |