Skip to content

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 (timed_out set). Defaults to None (wait forever).

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 signal_once_reset can find it.

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 True, run only on the server (shared signal). Defaults to False.

False
task Task

The task to attach the handler to. Defaults to the current FrameContext.task.

None
loc int

Sub-label index to run. Defaults to 0.

0
is_jump bool

If True, jump to the label in the current task rather than spawning a new one. Defaults to True.

True
is_temporary bool

If True, attach the handler to a transient idle task that is cleaned up on the next GUI load. Defaults to False.

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).