Signal Handling
Godot signals are a core communication mechanism in the Godot engine. godot-bevy bridges those signals into Bevy observers so your ECS systems can react to UI, gameplay, and scene-tree events in a type-safe, reactive way.
Outline
- Quick Start
- Multiple Signal Events
- Passing Context (Node, Entity, Arguments)
- Deferred Connections
- Attaching signals to Godot scenes
Quick Start
- Define a Bevy event for your signal:
#![allow(unused)] fn main() { use bevy::prelude::*; use godot_bevy::prelude::*; #[derive(Event, Debug, Clone)] struct StartGameRequested; }
- Register the signals plugin for your event type:
#![allow(unused)] fn main() { fn build_app(app: &mut App) { app.add_plugins(GodotSignalsPlugin::<StartGameRequested>::default()); } }
- Connect a Godot signal and map it to your event:
#![allow(unused)] fn main() { fn connect_button( buttons: Query<&GodotNodeHandle, With<Button>>, signals: GodotSignals<StartGameRequested>, ) { for handle in &buttons { signals.connect( *handle, "pressed", None, |_args, _node_handle, _ent| Some(StartGameRequested), ); } } }
- React to the event with an observer:
#![allow(unused)] fn main() { fn setup(app: &mut App) { app.add_observer(on_start_game); } fn on_start_game( _trigger: On<StartGameRequested>, mut next_state: ResMut<NextState<GameState>>, ) { next_state.set(GameState::Playing); } }
Observers fire immediately when the signal is received, giving you reactive, push-based event handling rather than polling each frame.
Multiple Signal Events
Use one plugin per event type. You can map the same Godot signal to multiple typed events if you like:
#![allow(unused)] fn main() { #[derive(Event, Debug, Clone)] struct ToggleFullscreen; #[derive(Event, Debug, Clone)] struct QuitRequested { source: GodotNodeHandle } fn setup(app: &mut App) { app.add_plugins(GodotSignalsPlugin::<ToggleFullscreen>::default()) .add_plugins(GodotSignalsPlugin::<QuitRequested>::default()) .add_observer(on_toggle_fullscreen) .add_observer(on_quit); } fn connect_menu( menu: Query<(&GodotNodeHandle, &MenuTag)>, toggle: GodotSignals<ToggleFullscreen>, quit: GodotSignals<QuitRequested>, ) { for (button, tag) in &menu { match tag { MenuTag::Fullscreen => { toggle.connect( *button, "pressed", None, |_a, _node_handle, _e| Some(ToggleFullscreen), ); } MenuTag::Quit => { quit.connect( *button, "pressed", None, |_a, node_handle, _e| Some(QuitRequested { source: node_handle }), ); } } } } fn on_toggle_fullscreen(_trigger: On<ToggleFullscreen>, mut godot: GodotAccess) { // Toggle fullscreen } fn on_quit(_trigger: On<QuitRequested>) { // Quit the game } }
Passing Context (Node, Entity, Arguments)
The mapper closure receives:
args: &[Variant]: raw Godot arguments (clone if you need detailed parsing)node_handle: GodotNodeHandle: emitting node handle (use it later withGodotAccess)entity: Option<Entity>: Bevy entity if you passedSome(entity)toconnect
Important: the mapper runs inside the Godot signal callback. Do not call Godot APIs in the mapper; resolve the node_handle in an observer or system with GodotAccess on the main thread. Connections are queued and applied on the main thread; connections made during a frame take effect on the next frame. If you need same-frame connection, use connect_immediate with a GodotAccess parameter. See Thread Safety and Godot APIs.
Example including the entity in the event:
#![allow(unused)] fn main() { #[derive(Event, Debug, Clone, Copy)] struct AreaExited { entity: Entity } fn connect_area( q: Query<(Entity, &GodotNodeHandle), With<Area2D>>, signals: GodotSignals<AreaExited>, ) { for (entity, area) in &q { signals.connect( *area, "body_exited", Some(entity), |_a, _node_handle, e| Some(AreaExited { entity: e.unwrap() }), ); } } fn on_area_exited(trigger: On<AreaExited>, mut commands: Commands) { let entity = trigger.event().entity; commands.entity(entity).despawn(); } }
Deferred Connections
When spawning entities before their GodotNodeHandle is ready, you can defer connections. Add DeferredSignalConnections<T> with a signal-to-event mapper; the GodotTypedSignalsPlugin<T> wires it once the handle appears.
#![allow(unused)] fn main() { #[derive(Component)] struct MyArea; #[derive(Event, Debug, Clone, Copy)] struct BodyEntered { entity: Entity } fn setup(app: &mut App) { app.add_plugins(GodotSignalsPlugin::<BodyEntered>::default()) .add_observer(on_body_entered); } fn spawn_area(mut commands: Commands) { commands.spawn(( MyArea, // Defer until GodotNodeHandle is available on this entity DeferredSignalConnections::<BodyEntered>::with_connection( "body_entered", |_a, _node_handle, e| Some(BodyEntered { entity: e.unwrap() }), ), )); } fn on_body_entered(trigger: On<BodyEntered>) { println!("Body entered area on entity {:?}", trigger.event().entity); } }
Attaching signals to Godot scenes
When spawning an entity associated with a Godot scene, you can schedule
signals to be connected to children of the scene once the scene is spawned.
When inserting a GodotScene resource, use the with_signal_connection builder method to schedule connections.
The method arguments are similar to other typed signal constructors such as connect:
node_path- Path relative to the scene root (e.g., "VBox/MyButton" or "." for root node). Argument supports the same syntax as Node.get_node.signal_name- Name of the Godot signal to connect (e.g., "pressed").mapper- Closure that maps signal arguments to your typed event.- The closure receives three arguments:
args,node_handle, andentity:args: &[Variant]: raw Godot arguments (clone if you need detailed parsing).node_handle: GodotNodeHandle: emitting node handle.entity: Option<Entity>: Bevy entity the GodotScene component is attached to (Always Some).
- The closure returns an optional Bevy Event, or None to not send the event.
- The closure receives three arguments:
impl Command for SpawnPickup {
fn apply(self, world: &mut World) -> () {
let assets = world.get_resource::<PickupAssets>().cloned();
let mut pickup = world.spawn_empty();
pickup
.insert(Name::new("Pickup"))
.insert(Transform::from_xyz(200.0, 200.0, 0.0));
// Only insert GodotScene if Godot engine is running; useful when running tests without Godot.
if let Some(assets) = assets {
pickup.insert(
GodotScene::from_handle(assets.scene.clone())
// Schedule the "area_entered" signal on the Area2D child
// to be connected to PickupAreaEntered event
.with_signal_connection(
"Area2D",
"area_entered",
|_args, _node_handle, _entity| {
// Pickup "area_entered" signal mapped
Some(PickupAreaEntered)
},
),
);
}
}
}
For physics signals (collisions), use the collisions plugin/events instead of raw signals when possible.