Learn Simulant
Everything you need to know to build games with Simulant
Signals and Events
Signals provide a type-safe, flexible mechanism for implementing event-driven programming in Simulant. They allow objects to communicate without being tightly coupled to each other -- a sender (the signal) does not need to know which objects are listening, and listeners (slots) can be attached or detached at any time.
Related documentation:
- Application -- for application lifecycle signals
- Stage Nodes -- for node destruction signals
- Coroutines -- for combining signals with async tasks
Table of Contents
- What Are Signals and Why Use Them?
- Signal/Slot Architecture
- Creating Signals
- Connecting to Signals
- Disconnecting from Signals
- Emitting Signals
- Built-in Signals in Simulant
- One-Shot Connections
- Signal Performance Considerations
- Common Patterns and Best Practices
- Anti-Patterns and Pitfalls
- Complete Examples
1. What Are Signals and Why Use Them?
In traditional procedural code, object A that wants to notify object B must hold a direct pointer to B and call a method on it. This creates tight coupling -- A must know the concrete type of B, and changing the relationship later requires modifying A's code.
Event-driven programming reverses this: A announces that something happened ("an event occurred"), and any interested party can listen for that announcement. This is the role of the signal/slot pattern.
Benefits
- Decoupling: The emitter does not know who is listening.
- Flexibility: Listeners can be added or removed at runtime.
- One-to-many: A single signal can notify arbitrarily many listeners.
- Type safety: The compiler enforces that the callback signature matches the signal's signature.
- Testability: Signals make it easy to verify that events occur in unit tests.
When to Use Signals
- Responding to user interface events (button clicks, keyboard input).
- Reacting to physics events (collision enter/exit).
- Observing lifecycle events (node creation, destruction).
- Hooking into the game loop (frame started, update, shutdown).
- Any situation where multiple unrelated systems need to react to the same event.
2. Signal/Slot Architecture
Simulant's signal system lives in the smlt::sig namespace and is defined in <simulant/signals/signal.h>. The architecture consists of four key components:
| Component | Description |
|---|---|
signal<Signature> |
The signal object. Holds a list of connected callbacks. |
Connection |
A handle representing a single connection. Can be disconnected later. |
ScopedConnection |
A RAII-style connection that automatically disconnects when it goes out of scope. |
DEFINE_SIGNAL macro |
A convenience macro for declaring signals as class members. |
How It Works
- A class declares a
signal<T>member (or usesDEFINE_SIGNAL). - Other code connects a callback (lambda, function pointer, or bound member function) to the signal.
- When the event occurs, the owning class emits the signal by calling
operator(). - All connected callbacks are invoked in the order they were connected.
// Simplified flow:
//
// [Emitter Object]
// |
// |-- signal<void(int)> my_signal
// | |
// | +--- callback 1: [](int x) { ... }
// | +--- callback 2: some_function
// | +--- callback 3: [this](int x) { ... }
// |
// my_signal(42); <-- emits, all three callbacks fire
3. Creating Signals
Basic Signal Declaration
A signal is templated on a function signature. The signature defines what arguments the signal will pass to its listeners.
#include <simulant/signals/signal.h>
using namespace smlt;
// A signal with no arguments
sig::signal<void()> on_started;
// A signal that passes a float (e.g., delta time)
sig::signal<void(float)> on_update;
// A signal that passes two objects
sig::signal<void(StageNode*, StageNodeType)> on_node_created;
// A signal that returns a value (rare; see note below)
sig::signal<bool(const std::string&)> on_text_input;
Using DEFINE_SIGNAL in Classes
Simulant provides the DEFINE_SIGNAL macro to declare a signal as a class member with a getter method. This is the idiomatic approach throughout the engine:
class MyComponent {
// Macro expands to:
// public: UpdatedSignal& signal_update() { return signal_update_; }
// UpdatedSignal& signal_update() const { return signal_update_; }
// private: mutable UpdatedSignal signal_update_;
DEFINE_SIGNAL(UpdatedSignal, signal_update);
};
You then define the signal type alias at namespace scope:
typedef sig::signal<void(float)> UpdatedSignal;
The macro gives you a clean public accessor (signal_update()) while keeping the actual member (signal_update_) private. The member is declared mutable so it can be accessed through const methods.
Naming Convention
Signal accessor methods follow the pattern signal_<past_tense_verb>() or signal_<event_name>():
signal_destroyed()signal_collision_enter()signal_activated()signal_update()
4. Connecting to Signals
Connecting a callback to a signal returns a Connection handle. There are several ways to provide callbacks.
4.1. Lambda Functions
Lambdas are the most common approach. They are concise and can capture local state.
app->signal_update().connect([](float dt) {
// dt is the delta time since the last frame
std::cout << "Frame delta time: " << dt << std::endl;
});
With captures:
int frame_count = 0;
app->signal_frame_started().connect([&frame_count]() {
++frame_count;
if (frame_count % 60 == 0) {
std::cout << "One minute elapsed (at 60fps)" << std::endl;
}
});
4.2. Member Functions
To connect a member function, use std::bind or a capturing lambda:
class Player {
public:
Player(Application* app) {
// Using a capturing lambda (recommended, clearest approach)
app->signal_shutdown().connect([this]() {
this->on_application_shutdown();
});
}
private:
void on_application_shutdown() {
save_game();
}
};
4.3. Free Functions
void handle_shutdown() {
std::cout << "Application is shutting down!" << std::endl;
}
app->signal_shutdown().connect(&handle_shutdown);
4.4. Connections with Return Values
Some signals expect a return value from listeners. The text input signal, for example, returns bool:
// signal_text_input_received has signature:
// sig::signal<bool(const unicode&, TextInputEvent&)>
input->signal_text_input_received().connect(
[](const unicode& c, TextInputEvent& evt) -> bool {
// Return true to accept the character, false to reject it
if (c == '@') {
return false; // Block this character
}
text_buffer += c;
return true;
}
);
Important: When a signal has a return type, the signal's operator() returns the value from the last connected callback. There is no built-in accumulation or combination of return values.
4.5. Storing Connection Handles
Always store the connection if you plan to disconnect it later:
// Connection - manual lifetime management
sig::connection conn = button->signal_clicked().connect([&]() {
on_button_clicked();
});
// Later:
conn.disconnect();
5. Disconnecting from Signals
5.1. Manual Disconnection
Call disconnect() on the Connection handle:
sig::connection conn = app->signal_update().connect(callback);
// When you no longer need the connection:
conn.disconnect();
// Check if still connected:
if (conn.is_connected()) {
std::cout << "Still connected" << std::endl;
}
5.2. Scoped Connections (RAII)
ScopedConnection automatically disconnects when it is destroyed. This is the safest approach when connections should live for the lifetime of a scope or object.
{
sig::scoped_connection conn = button->signal_clicked().connect([&]() {
handle_click();
});
// conn is active here
assert(conn.is_connected());
} // <-- conn goes out of scope, disconnect() called automatically
// conn is now disconnected
Scoped connections are strongly recommended when the lifetime of the connection is tied to a scope or object lifetime. They prevent dangling callbacks that could access destroyed objects.
5.3. Disconnecting Inside a Callback
It is safe to disconnect from within a callback. The signal implementation uses deferred removal -- dead connections are marked during iteration and removed after the signal finishes emitting.
sig::connection conn;
conn = app->signal_update().connect([&](float dt) {
total_dt += dt;
if (total_dt >= 5.0f) {
// Safe: disconnects itself after this call completes
conn.disconnect();
}
});
5.4. Checking Connection Validity
if (conn) {
// Equivalent to conn.is_connected()
}
5.5. Connection Safety
If a signal object is destroyed while connections still point to it, those connections become inert -- calling disconnect() or is_connected() on them will safely return false. This prevents crashes from stale connections.
6. Emitting Signals
Emitting a signal is done by calling the signal's operator():
class MyClass {
DEFINE_SIGNAL(DestroyedSignal, signal_destroyed);
public:
bool destroy() {
if (already_destroyed_) return false;
already_destroyed_ = true;
// Emit the signal -- all connected callbacks fire
signal_destroyed()();
// ... perform destruction ...
return true;
}
};
For signals with arguments, pass the arguments when calling:
// signal_update has signature sig::signal<void(float)>
signal_update_(dt);
// signal_stage_node_created has signature
// sig::signal<void(StageNode*, StageNodeType)>
signal_stage_node_created_(new_node, node_type);
// signal_collision_enter has signature
// sig::signal<void(const Collision&)>
signal_collision_enter_(collision_data);
Emission Order
Callbacks are invoked in the order they were connected. If you need a specific order, connect in that order.
7. Built-in Signals in Simulant
Simulant provides many signals across its core classes. Below is a comprehensive reference.
7.1. Application Signals
Source: Application class (see Application docs)
| Signal | Signature | When It Fires |
|---|---|---|
signal_frame_started() |
void() |
At the very beginning of each frame. |
signal_update(float) |
void(float) |
During the update phase, passing delta time. |
signal_fixed_update(float) |
void(float) |
During fixed-update phase, passing fixed timestep. |
signal_late_update(float) |
void(float) |
After all updates, before coroutines resume. |
signal_post_late_update() |
void() |
After late update completes. |
signal_post_coroutines() |
void() |
After coroutines have been resumed. |
signal_frame_finished() |
void() |
At the end of each frame, before swap. |
signal_pre_swap() |
void() |
Just before the back buffer is swapped. |
signal_shutdown() |
void() |
When the application is shutting down. |
// Example: Frame timing with application signals
class FrameTimer {
public:
FrameTimer(Application* app) {
app->signal_frame_started().connect([this]() {
frame_start = std::chrono::high_resolution_clock::now();
});
app->signal_frame_finished().connect([this]() {
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
end - frame_start).count();
if (ms > 16) {
S_WARN("Frame took {}ms, target is 16ms (60fps)", ms);
}
});
}
private:
std::chrono::high_resolution_clock::time_point frame_start;
};
7.2. Scene Signals
Source: Scene class
| Signal | Signature | When It Fires |
|---|---|---|
signal_activated() |
void() |
When the scene becomes active. |
signal_deactivated() |
void() |
When the scene is deactivated. |
signal_stage_node_created(StageNode*, StageNodeType) |
void(StageNode*, StageNodeType) |
When a new node is added to the scene. |
signal_stage_node_destroyed(StageNode*, StageNodeType) |
void(StageNode*, StageNodeType) |
When a node is destroyed. |
signal_layer_render_started(Camera*, Viewport*, StageNode*) |
void(Camera*, Viewport*, StageNode*) |
Before a layer renders. |
signal_layer_render_finished(Camera*, Viewport*, StageNode*) |
void(Camera*, Viewport*, StageNode*) |
After a layer renders. |
// Example: Log all node destructions for debugging
scene->signal_stage_node_destroyed().connect(
[](StageNode* node, StageNodeType type) {
S_DEBUG("Node destroyed: type={}, name={}",
static_cast<int>(type), node->name());
}
);
7.3. Scene Manager Signals
| Signal | Signature | When It Fires |
|---|---|---|
signal_scene_activated(std::string, Scene*) |
void(std::string, Scene*) |
When a scene is activated (passing route and scene). |
signal_scene_deactivated(std::string, Scene*) |
void(std::string, Scene*) |
When a scene is deactivated. |
7.4. StageNode Signals
Source: StageNode class (see Stage Nodes docs)
| Signal | Signature | When It Fires |
|---|---|---|
signal_destroyed() |
void() |
Immediately when destroy() is called. |
signal_cleaned_up() |
void() |
Just before the node is actually deleted (after late_update). |
signal_bounds_updated(AABB) |
void(AABB) |
When the node's bounding box changes. |
// Understanding destroy vs. clean_up:
auto actor = scene->create_child<smlt::Actor>(mesh);
bool destroyed = false;
bool cleaned_up = false;
actor->signal_destroyed().connect([&]() {
destroyed = true; // Fires immediately on destroy()
});
actor->signal_cleaned_up().connect([&]() {
cleaned_up = true; // Fires later, just before deletion
});
actor->destroy();
// destroyed == true, cleaned_up == false
app->run_frame();
// Now cleaned_up == true (clean-up happens after late_update)
7.5. Widget Signals
Source: Widget class (see UI docs)
| Signal | Signature | When It Fires |
|---|---|---|
signal_pressed() |
void() |
When the widget is pressed/finger-down. |
signal_released() |
void() |
When the finger/mouse is released over the widget. |
signal_clicked() |
void() |
On finger-up (press then release on same widget). |
signal_focused() |
void() |
When the widget gains input focus. |
signal_blurred() |
void() |
When the widget loses input focus. |
7.6. Keyboard Signals
Source: SoftKeyboard class
| Signal | Signature | When It Fires |
|---|---|---|
signal_key_pressed(SoftKeyPressedEvent&) |
void(SoftKeyPressedEvent&) |
When a key is pressed on the on-screen keyboard. |
signal_done(const unicode&) |
void(const unicode&) |
When the user confirms text input. |
signal_cancelled() |
void() |
When the user cancels text input. |
7.7. Physics Signals
Source: PhysicsBody class (see Physics docs)
| Signal | Signature | When It Fires |
|---|---|---|
signal_collision_enter(const Collision&) |
void(const Collision&) |
When this body starts colliding with another. |
signal_collision_exit(const Collision&) |
void(const Collision&) |
When this body stops colliding with another. |
// Example: Track collisions on a physics body
body->signal_collision_enter().connect([&](const Collision& c) {
S_INFO("Collision started with body: {}", c.other->id());
flash_material(c.self);
});
body->signal_collision_exit().connect([&](const Collision& c) {
S_INFO("Collision ended with body: {}", c.other->id());
restore_material(c.self);
});
7.8. Animation Signals
Source: KeyFrameAnimated class (see Animation docs)
| Signal | Signature | When It Fires |
|---|---|---|
signal_animation_added(KeyFrameAnimated*, const std::string&) |
void(KeyFrameAnimated*, const std::string&) |
When an animation sequence is added. |
7.9. Audio Signals
Source: AudioSource class
| Signal | Signature | When It Fires |
|---|---|---|
signal_sound_played(SoundPtr, AudioRepeat, DistanceModel) |
void(SoundPtr, AudioRepeat, DistanceModel) |
When a sound starts playing. |
signal_stream_finished() |
void() |
When an audio stream finishes. |
7.10. Window Signals
| Signal | Signature | When It Fires |
|---|---|---|
signal_screen_added(std::string, Screen*) |
void(std::string, Screen*) |
When a screen is added to the window. |
signal_screen_removed(std::string, Screen*) |
void(std::string, Screen*) |
When a screen is removed. |
signal_focus_lost() |
void() |
When the window loses OS focus. |
signal_focus_gained() |
void() |
When the window gains OS focus. |
7.11. Input Manager Signals
| Signal | Signature | When It Fires |
|---|---|---|
signal_text_input_received(const unicode&, TextInputEvent&) |
bool(const unicode&, TextInputEvent&) |
When text input is received. Return true to accept. |
7.12. Updateable Signals
Source: Updateable mixin (used by StageNode and others)
| Signal | Signature | When It Fires |
|---|---|---|
signal_update(float) |
void(float) |
Every frame during the update phase. |
signal_late_update(float) |
void(float) |
Every frame during the late update phase. |
signal_fixed_update(float) |
void(float) |
Every fixed timestep during the fixed update phase. |
7.13. Other Notable Signals
| Source | Signal | Signature | When It Fires |
|---|---|---|---|
DestroyableObject |
signal_destroyed() |
void() |
When destroy() is called. |
Compositor |
signal_layer_render_started(Layer&) |
void(Layer&) |
Before a compositor layer renders. |
Compositor |
signal_layer_render_finished(Layer&) |
void(Layer&) |
After a compositor layer renders. |
Mesh |
signal_skeleton_added(Skeleton*) |
void(Skeleton*) |
When a skeleton is attached to a mesh. |
Mesh |
signal_submesh_created(AssetID, SubMeshPtr) |
void(AssetID, SubMeshPtr) |
When a submesh is created. |
Mesh |
signal_submesh_destroyed(AssetID, SubMeshPtr) |
void(AssetID, SubMeshPtr) |
When a submesh is destroyed. |
Mesh |
signal_submesh_material_changed(...) |
void(AssetID, SubMeshPtr, MaterialSlot, AssetID, AssetID) |
When a submesh material changes. |
SubMesh |
signal_material_changed(...) |
void(SubMeshPtr, MaterialSlot, AssetID, AssetID) |
When the submesh's material changes. |
Actor |
signal_mesh_changed(StageNodeID) |
void(StageNodeID) |
When the actor's mesh changes. |
ParticleSystem |
signal_material_changed(...) |
void(ParticleSystem*, AssetID, AssetID) |
When the particle system material changes. |
AudioSource |
signal_sound_played(...) |
void(SoundPtr, AudioRepeat, DistanceModel) |
When a sound starts playing. |
AudioSource |
signal_stream_finished() |
void() |
When an audio stream finishes. |
Renderable |
signal_render_priority_changed(old, new) |
void(RenderPriority, RenderPriority) |
When render priority changes. |
VertexData |
signal_update_complete() |
void() |
When vertex data update is complete. |
GPUProgram |
signal_linked() |
void() |
When the GPU program links successfully. |
GPUProgram |
signal_shader_compiled(ShaderType) |
void(ShaderType) |
When a shader compiles. |
KeyFrameAnimated |
signal_animation_added(...) |
void(KeyFrameAnimated*, const std::string&) |
When an animation is added. |
Mesh |
signal_animation_enabled(...) |
void(Mesh*, MeshAnimationType, uint32_t) |
When an animation is enabled on a mesh. |
GenericTree |
signal_parent_changed(...) |
void(GenericTreeNode*, GenericTreeNode*) |
When a node's parent changes. |
8. One-Shot Connections
The signal system provides connect_once() for callbacks that should fire exactly once and then automatically disconnect:
// This callback fires on the next update, then disconnects itself
app->signal_update().connect_once([&](float dt) {
S_INFO("This only runs once!");
});
Note: The current implementation of connect_once() captures the callback and disconnects after the first invocation. For signals with parameters, be aware that the internal implementation wraps your callback and may have limitations with non-void return types. For more complex one-shot patterns, manually storing and disconnecting a Connection is often clearer:
// Alternative: manual one-shot (works with any signature)
sig::connection conn;
conn = app->signal_update().connect([&](float dt) {
S_INFO("Runs once with dt = {}", dt);
conn.disconnect();
});
9. Signal Performance Considerations
Implementation Characteristics
Simulant's signal implementation uses a linked list of callbacks. Understanding this helps you reason about performance:
- Connect: O(1) -- appended to the tail of the list.
- Emit: O(n) -- iterates all connected callbacks sequentially.
- Disconnect: O(n) -- searches the list for the matching connection.
- Thread safety: The implementation is not thread-safe. Signals should only be emitted and modified from a single thread (typically the main thread).
Guidelines
Minimize connections in hot paths. Every additional listener adds a function call during emission. If a signal fires every frame and has 50 listeners, that is 50 function calls per frame.
Keep callbacks short. Since callbacks fire synchronously during emission, a slow callback blocks all subsequent callbacks and the rest of the game loop.
Prefer
ScopedConnectionwhen possible. The overhead is negligible, and it prevents memory-access bugs from stale callbacks.Avoid creating/destroying connections every frame. Connection management has overhead. Set up connections once during initialization rather than in per-frame code.
Be cautious with signals in tight loops. If you are processing thousands of entities per frame, a per-entity signal emission can become a bottleneck. Consider batching or event queues instead.
10. Common Patterns and Best Practices
10.1. Self-Disconnecting Watcher Pattern
Watch for the destruction of an object you hold a reference to, and clear the reference automatically:
class FindResult {
public:
// ...
T* get() const {
if (!checked_) {
found_ = dynamic_cast<T*>(finder_(node_));
if (found_ && !found_->is_destroyed()) {
// Auto-clear when the target is destroyed
on_destroy_ = found_->signal_destroyed().connect([&]() {
found_ = nullptr;
on_destroy_.disconnect();
});
}
checked_ = true;
}
return found_;
}
private:
mutable T* found_ = nullptr;
mutable bool checked_ = false;
sig::connection on_destroy_;
};
This pattern is used extensively in Simulant's NodeLocator system.
10.2. Debug Frame Counter
Use frame signals for quick debugging without modifying game logic:
class DebugOverlay {
public:
DebugOverlay(Application* app) {
int frame_count = 0;
float fps_timer = 0.0f;
app->signal_frame_started().connect([&]() {
++frame_count;
fps_timer += app->last_frame_time();
if (fps_timer >= 1.0f) {
S_INFO("FPS: {}", frame_count);
frame_count = 0;
fps_timer = 0.0f;
}
});
}
};
10.3. Lifecycle Manager
Centralize shutdown logic using signals:
class GameLifecycle {
public:
GameLifecycle(Application* app) {
app->signal_shutdown().connect([this]() {
save_player_progress();
close_network_connection();
flush_audio_buffer();
});
}
private:
void save_player_progress() { /* ... */ }
void close_network_connection() { /* ... */ }
void flush_audio_buffer() { /* ... */ }
};
10.4. Updateable Mixin Pattern
Any class that inherits from Updateable automatically gets per-frame signals:
class Enemy : public StageNode {
public:
Enemy(Scene* scene) : StageNode(scene, STAGE_NODE_TYPE_ENEMY) {
// StageNode inherits Updateable, so we get signal_update
signal_update().connect([this](float dt) {
update_ai(dt);
});
}
private:
void update_ai(float dt) {
// AI logic runs every frame
}
};
10.5. Testing with Signals
Signals make unit testing straightforward -- connect a lambda and verify it was called:
void test_node_destruction_emits_signal() {
auto actor = scene->create_child<smlt::Actor>(mesh);
bool destroyed = false;
sig::scoped_connection conn = actor->signal_destroyed().connect([&]() {
destroyed = true;
});
actor->destroy();
assert_true(destroyed);
}
10.6. Chaining Signals
You can forward a signal from one source through another:
class GameScene : public Scene {
public:
// Expose a signal that forwards the application's update signal
sig::signal<void(float)>& signal_game_update() {
return signal_game_update_;
}
bool init() override {
// Forward app update to game-specific signal
app->signal_update().connect([this](float dt) {
signal_game_update_(dt);
});
return true;
}
private:
sig::signal<void(float)> signal_game_update_;
};
11. Anti-Patterns and Pitfalls
11.1. Dangling References in Captures
Problem: Capturing references to local variables that go out of scope.
// BAD: local_variable is destroyed when the function returns
void setup_callback() {
int local_variable = 42;
app->signal_update().connect([&local_variable](float) {
// CRASH: local_variable was destroyed!
std::cout << local_variable << std::endl;
});
}
Fix: Capture by value, or ensure the captured data outlives the connection.
// GOOD: captured by value
void setup_callback() {
int local_variable = 42;
app->signal_update().connect([local_variable](float) {
std::cout << local_variable << std::endl;
});
}
11.2. Infinite Recursion
Problem: A signal callback emits the same signal, causing infinite recursion.
// BAD: infinite loop!
signal_value_changed().connect([&](int value) {
if (value > 100) {
signal_value_changed(100); // Calls itself recursively
}
});
Fix: Use a guard flag or restructure the logic.
bool emitting = false;
signal_value_changed().connect([&](int value) {
if (emitting) return; // Guard against recursion
if (value > 100) {
emitting = true;
signal_value_changed_(100);
emitting = false;
}
});
11.3. Connecting Multiple Times
Problem: Calling connect() repeatedly without tracking, causing the callback to fire multiple times.
// BAD: called every frame, adding a new listener each time
void on_frame() {
button->signal_clicked().connect([&]() {
handle_click(); // Will fire N times after N frames!
});
}
Fix: Connect once during initialization.
// GOOD: connect once in init
bool init() override {
button->signal_clicked().connect([&]() {
handle_click();
});
return true;
}
11.4. Assuming Callback Order
Problem: Relying on a specific order when multiple listeners are connected. While the current implementation calls them in connection order, this is an implementation detail that could change.
// Fragile: assumes A fires before B
signal.connect([]() { /* A: setup */ });
signal.connect([]() { /* B: depends on A */ });
Fix: If order matters, combine into a single callback or document the dependency explicitly.
11.5. Not Disconnecting on Object Destruction
Problem: An object is destroyed but its signal connections remain, causing callbacks to access freed memory.
class MyListener {
public:
MyListener(Application* app) {
app->signal_update().connect([this](float dt) {
this->do_work(dt); // CRASH if MyListener is destroyed!
});
}
void do_work(float dt) { /* ... */ }
};
Fix: Use ScopedConnection as a member variable.
class MyListener {
public:
MyListener(Application* app) {
conn_ = app->signal_update().connect([this](float dt) {
this->do_work(dt); // Safe: connection disconnects in destructor
});
}
private:
sig::scoped_connection conn_; // Disconnects when MyListener is destroyed
void do_work(float dt) { /* ... */ }
};
11.6. Blocking Operations in Callbacks
Problem: Performing blocking operations (file I/O, network requests, long computations) inside a signal callback blocks the entire game loop.
// BAD: blocks the main thread
app->signal_shutdown().connect([]() {
save_large_file(); // May take seconds, freezes the game
});
Fix: Offload to a coroutine.
// GOOD: runs asynchronously
app->signal_shutdown().connect([this]() {
cr_async([this]() {
save_large_file();
});
});
12. Complete Examples
Example 1: Event Bus Pattern
A simple event bus that routes game events to interested listeners:
#include <simulant/simulant.h>
using namespace smlt;
// Define event types
struct PlayerKilledEvent {
int player_id;
Vec3 position;
};
struct ScoreChangedEvent {
int player_id;
int new_score;
};
class EventBus {
public:
sig::signal<void(const PlayerKilledEvent&)>& on_player_killed() {
return on_player_killed_;
}
sig::signal<void(const ScoreChangedEvent&)>& on_score_changed() {
return on_score_changed_;
}
void emit_player_killed(int player_id, Vec3 position) {
on_player_killed_({player_id, position});
}
void emit_score_changed(int player_id, int new_score) {
on_score_changed_({player_id, new_score});
}
private:
sig::signal<void(const PlayerKilledEvent&)> on_player_killed_;
sig::signal<void(const ScoreChangedEvent&)> on_score_changed_;
};
// Usage in a game class
class Game : public Application {
public:
Game(const AppConfig& config) : Application(config) {}
bool init() override {
scenes->register_scene<MainScene>("main");
// Listen for player death to show a screen effect
event_bus_.on_player_killed().connect([this](const PlayerKilledEvent& evt) {
S_INFO("Player {} died at {}", evt.player_id, evt.position);
show_death_effect(evt.position);
});
// Listen for score changes to update HUD
event_bus_.on_score_changed().connect([this](const ScoreChangedEvent& evt) {
update_hud_score(evt.player_id, evt.new_score);
});
return true;
}
EventBus& event_bus() { return event_bus_; }
private:
EventBus event_bus_;
void show_death_effect(Vec3 position) { /* ... */ }
void update_hud_score(int player_id, int new_score) { /* ... */ }
};
// Somewhere in game logic:
// game->event_bus().emit_player_killed(1, player_position);
Example 2: Automatic Health Bar Updates
A health bar widget that automatically updates when an entity's health changes, using signals instead of polling:
class HealthBar : public Widget {
public:
HealthBar(Scene* scene, Entity* entity)
: Widget(scene, STAGE_NODE_TYPE_WIDGET),
entity_(entity)
{
if (entity_) {
// Update whenever the entity is updated
health_conn_ = entity_->signal_update().connect(
[this](float dt) { update_bar(); }
);
// Remove ourselves if the entity is destroyed
destroy_conn_ = entity_->signal_destroyed().connect([this]() {
entity_ = nullptr;
this->destroy();
});
}
}
~HealthBar() override {
// Connections clean up automatically (if using scoped_connection),
// but explicit disconnect is fine too
}
private:
Entity* entity_;
sig::scoped_connection health_conn_;
sig::scoped_connection destroy_conn_;
void update_bar() {
if (!entity_) return;
float health_pct = entity_->health() / entity_->max_health();
set_fill_fraction(health_pct);
// Change color based on health level
if (health_pct < 0.25f) {
set_color(Color::RED);
} else if (health_pct < 0.5f) {
set_color(Color::ORANGE);
} else {
set_color(Color::GREEN);
}
}
};
Example 3: Scene Transition System
Using signals to manage scene loading and transitions:
class SceneTransitionManager {
public:
SceneTransitionManager(Application* app) : app_(app) {
// Fade in when a scene activates
app_->scenes()->signal_scene_activated().connect(
[this](const std::string& route, Scene* scene) {
fade_in(scene);
}
);
// Fade out when a scene deactivates
app_->scenes()->signal_scene_deactivated().connect(
[this](const std::string& route, Scene* scene) {
fade_out(scene);
}
);
}
void transition_to(const std::string& scene_name) {
// Start a coroutine for the transition
cr_async([this, scene_name]() {
is_transitioning_ = true;
// Fade out current scene
fade_out_current();
cr_yield_for(0.5f); // Wait 0.5 seconds
// Switch scene
app_->scenes()->set_active_scene(scene_name);
cr_yield_for(0.1f); // Let new scene initialize
// Fade in new scene
fade_in_current();
cr_yield_for(0.5f);
is_transitioning_ = false;
});
}
private:
Application* app_;
bool is_transitioning_ = false;
void fade_in(Scene* scene) { /* animate overlay alpha */ }
void fade_out(Scene* scene) { /* animate overlay alpha */ }
void fade_out_current() { /* ... */ }
void fade_in_current() { /* ... */ }
};
Example 4: Physics-Based Trigger Volumes
Using physics signals to implement trigger zones:
class TriggerVolume : public StageNode {
public:
TriggerVolume(Scene* scene, Vec3 size)
: StageNode(scene, STAGE_NODE_TYPE_COLLIDER)
{
// Create a trigger body (non-colliding, but reports contacts)
body_ = create_trigger_body(size);
// Track who is inside
body_->signal_collision_enter().connect(
[this](const Collision& c) {
auto other = c.other->get_owner();
entities_inside_.insert(other->id());
on_entity_entered(other);
}
);
body_->signal_collision_exit().connect(
[this](const Collision& c) {
auto other = c.other->get_owner();
entities_inside_.erase(other->id());
on_entity_exited(other);
}
);
}
sig::signal<void(StageNode*)>& signal_entity_entered() {
return signal_entity_entered_;
}
sig::signal<void(StageNode*)>& signal_entity_exited() {
return signal_entity_exited_;
}
bool contains(StageNodeID id) const {
return entities_inside_.count(id) > 0;
}
const std::unordered_set<StageNodeID>& entities_inside() const {
return entities_inside_;
}
protected:
virtual void on_entity_entered(StageNode* entity) {
S_INFO("Entity {} entered trigger {}", entity->name(), name());
signal_entity_entered_(entity);
}
virtual void on_entity_exited(StageNode* entity) {
S_INFO("Entity {} exited trigger {}", entity->name(), name());
signal_entity_exited_(entity);
}
private:
PhysicsBody* body_;
std::unordered_set<StageNodeID> entities_inside_;
sig::signal<void(StageNode*)> signal_entity_entered_;
sig::signal<void(StageNode*)> signal_entity_exited_;
};
// Usage:
auto door_trigger = scene->create_child<TriggerVolume>(Vec3(2, 3, 2));
door_trigger->set_name("door_trigger");
door_trigger->signal_entity_entered().connect(
[](StageNode* entity) {
if (entity->name() == "player") {
S_INFO("Player entered door trigger - open the door!");
}
}
);
Example 5: Frame-Rate Independent Timer with Signals
class SignalTimer {
public:
SignalTimer(Application* app) {
conn_ = app->signal_update().connect([this](float dt) {
if (!running_) return;
elapsed_ += dt;
if (elapsed_ >= interval_) {
elapsed_ -= interval_;
on_tick_();
}
});
}
void start(float interval_seconds) {
interval_ = interval_seconds;
elapsed_ = 0.0f;
running_ = true;
}
void stop() {
running_ = false;
}
sig::signal<void()>& on_tick() {
return on_tick_;
}
private:
sig::scoped_connection conn_;
sig::signal<void()> on_tick_;
float interval_ = 1.0f;
float elapsed_ = 0.0f;
bool running_ = false;
};
// Usage: create a timer that fires every 2 seconds
SignalTimer timer(app);
timer.on_tick().connect([]() {
S_INFO("Two seconds elapsed!");
});
timer.start(2.0f);
Summary
| Concept | Key Point |
|---|---|
| Signals | Type-safe event publishers; use sig::signal<Signature> |
| Connections | Returned by connect(). Use disconnect() to remove. |
| ScopedConnection | RAII connection; disconnects automatically on destruction. Preferred in most cases. |
| connect_once() | Auto-disconnects after first invocation. |
| DEFINE_SIGNAL | Macro for declaring signals as class members with public accessors. |
| Emission | Call signal_(args...) or signal_name()(args...) for macro-defined signals. |
| Safety | Always use ScopedConnection when this is captured to avoid dangling callbacks. |
| Performance | Signals use a linked list; emission is O(n). Avoid excessive listeners in hot paths. |
| Thread safety | Not thread-safe. Use only from the main thread. |