The signal system
Emit named events and register handlers that fire when those events occur.
Overview
Signals are the primary decoupled communication mechanism in Cosmos missions. Any code can emit a named signal with signal_emit; any label can listen for it with the //signal/<name> route. Signals are processed synchronously within the same tick — all registered listeners are called immediately when signal_emit runs.
Data passed to signal_emit becomes task variables in the handler. Use snake_case for script-defined signal data keys (e.g. ship_id, target_id). System-emitted signals use CAPS keys (e.g. DESTROYED_ID, LIFE_FORM_NAME).
signal_emit is safe to call even when no MAST runtime is active — it returns early with no side effects.
Quick example
== patrol ==
signal_emit("enemy_spotted", {"enemy_id": enemy_id, "ship_id": ship_id})
->END
//signal/enemy_spotted
log(f"Enemy spotted!")
target(ship_id, enemy_id)
from sbs_utils.procedural.signal import signal_emit, signal_connect, signal_disconnect
signal_emit("enemy_spotted", {"enemy_id": enemy_id, "ship_id": ship_id})
Shared signals
A //shared/signal/<name> route runs on the server only, once. A plain
//signal/<name> route runs once per connected console, plus the server — so with
five consoles its body runs six times.
That is the single most common multiplayer bug in mission code. Ask: "with five consoles,
do I want this five times?" Anything that spawns, saves, rewards, counts, ends the game or
rolls random belongs in //shared/signal; only per-console display stays in //signal.
//shared/signal/quest_completed
log("Mission complete! Well done, crew.")
Running setup only once
//shared/signal fixes where a route runs. It does not limit how many times the
signal is emitted — emit setup twice and its body runs twice.
Where what you create has a natural name, prefer making the work idempotent
(player_ensure, side_ensure, AMD records keyed by their own (key)). Where it does
not, mark the route once:
//shared/signal/give_starting_cash once
| Function | Does |
|---|---|
signal_once_reset(name) |
Re-arm the once routes for a signal, so they can run again |
signal_once_reset() |
Re-arm every once route |
Starting a new mission re-arms everything, so signal_once_reset is only for re-running
setup within a mission — resetting scenario conditions without reloading.
Full guidance, and the routes you must not mark once, are in
Signal routes.
Awaiting a signal
A //signal/<name> route reacts every time a signal fires. Sometimes instead a
task just needs to pause until the next one. signal_next(name) is a one-shot
await of the next signal_emit(name); it resolves with the emitted data.
== wait_for_dock ==
result = await signal_next("docked")
log("Docked - continuing the mission.")
->END
from sbs_utils.procedural.signal import signal_next
result = yield AWAIT(signal_next("docked"))
Loop it to react repeatedly, or use a //signal/<name> route for persistent
reaction. It composes with promise_any (signal-or-timeout, button-or-signal)
and accepts a timeout:
# whichever happens first
await promise_any(signal_next("docked"), delay_sim(30))
# give up after 10 seconds
result = await signal_next("scan_done", timeout=timeout(10))
| Use | When |
|---|---|
await signal_next(name) |
a task should pause here until the next emit (one-shot) |
//signal/<name> route |
react every time the signal fires, from anywhere |
signal_next is safe to call when no MAST runtime is active.
Built-in signals
Many modules emit signals automatically. Common ones:
| Signal | Data keys | Emitted by |
|---|---|---|
quest_activated |
AGENT_ID, QUEST_ID, QUEST |
quest_set_state |
quest_completed |
AGENT_ID, QUEST_ID, QUEST |
quest_set_state |
upgrade_activated |
UPGRADE_AGENT, UPGRADE_AGENT_ID, UPGRADE |
upgrade_add |
player_ship_destroyed |
DESTROYED_ID |
explode_player_ship |
life_form_died |
SHIP_ID, LIFE_FORM_NAME |
internal damage |
docked |
ORIGIN_ID, SELECTED_ID |
docking system |
API
SignalPromise
Bases: Promise
Resolves the next time name is emitted (one-shot).
result() is the emitted data dict (which may be None). With a
timeout (application seconds, so it advances even while the sim is
paused) it resolves with None and sets timed_out = True if the
signal does not arrive in time.
signal_emit(name, data=None)
Emit a named signal, running all registered //signal/<name> routes.
Safe to call when no MAST context is active — returns immediately with no side effects.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The signal name. |
required |
data
|
dict
|
Arbitrary data passed to each signal handler. Defaults to None. |
None
|
signal_next(name, timeout=None)
Suspend the current task until the next signal_emit(name).
Resolves with that emit's data (may be None). One-shot - loop it to
react to each occurrence; for persistent reaction use a //signal/<name>
route. Composes with promise_any for event-or-timeout, or pass
timeout (application seconds) to resolve with None on expiry.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Signal name to wait for. |
required |
timeout
|
float
|
Seconds to wait before resolving with
|
None
|
Returns:
| Name | Type | Description |
|---|---|---|
SignalPromise |
SignalPromise
|
Await it; the value is the emitted data. |
Example
data = await signal_next("wave_cleared") result = await promise_any(signal_next("docked"), delay_sim(30))
signal_observe(fn)
Call fn(name, data) on every emit. Returns fn, so it can be used as a
decorator. Registering the same callable twice registers it once.
signal_observers_clear()
Drop every observer (mission reset).
signal_once_enter(label, path=None)
Test-and-set the one-shot flag for a once route body.
Compiled into the route by SignalRouteDecoratorLabel - scripts do not call
this directly. Keyed on the generated LABEL name, not the signal path, so two
routes handling the same signal each get their own shot.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
label
|
str
|
The route's generated label name. |
required |
path
|
str
|
The signal name, so |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
bool |
True the first time (run the body), False afterwards. |
signal_once_reset(name=None)
Re-arm once routes so they will run again.
The explicit path for an INTENTIONAL re-initialization - resetting scenario
conditions without reloading the mission. A mission reload needs no call: the
flags live in Agent.SHARED and reset_mission_state clears it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Signal name to re-arm. Defaults to all of them. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
int |
How many routes were re-armed. |
signal_register(name, label, server=False, task=None, loc=0, is_jump=True, is_temporary=False)
Register a label as a handler for a named signal.
When signal_emit(name) is called, each handler registered under that
name will run. Temporary handlers are attached to a short-lived idle task
and are cleaned up when a new GUI is loaded.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The signal name to listen for. |
required |
label
|
str | Label
|
The label to execute when the signal fires. |
required |
server
|
bool
|
If |
False
|
task
|
Task
|
The task to attach the handler to. Defaults to
the current |
None
|
loc
|
int
|
Sub-label index to run. Defaults to 0. |
0
|
is_jump
|
bool
|
If |
True
|
is_temporary
|
bool
|
If |
False
|
signal_unobserve(fn)
Stop calling fn. Safe when it was never registered.
signal_waiters_clear()
Drop all pending signal_next waiters (call on mission reset).