Migration Guide: v0.10 to v0.11

This guide covers breaking changes and new behavior when upgrading from godot-bevy 0.10.x to 0.11.0.

Table of Contents

Breaking changes:

Breaking: Typed signals now use observers

The typed signals API has been redesigned to use Bevy's observer pattern instead of the Message/MessageReader pattern. This provides more reactive, push-based event handling where signal handlers fire immediately when signals are received.

Type changes

Signal types now derive Event instead of Message:

Before:

#[derive(Message, Debug, Clone)]
struct StartGameRequested;

After:

#[derive(Event, Debug, Clone)]
struct StartGameRequested;

SystemParam renamed

The TypedGodotSignals<T> SystemParam has been renamed to GodotSignals<T>:

Before:

fn connect_button(signals: TypedGodotSignals<StartGameRequested>) {
    // ...
}

After:

fn connect_button(signals: GodotSignals<StartGameRequested>) {
    // ...
}

Method renamed

The connect_map method has been renamed to connect:

Before:

signals.connect_map(*handle, "pressed", None, |_args, _node, _ent| {
    Some(StartGameRequested)
});

After:

signals.connect(*handle, "pressed", None, |_args, _node, _ent| {
    Some(StartGameRequested)
});

Handling signals with observers

Replace MessageReader<T> systems with observers using On<T>:

Before:

fn on_start_game(mut reader: MessageReader<StartGameRequested>) {
    for _ in reader.read() {
        // Start the game
    }
}

app.add_systems(Update, on_start_game);

After:

fn on_start_game(
    _trigger: On<StartGameRequested>,
    mut next_state: ResMut<NextState<GameState>>,
) {
    next_state.set(GameState::Playing);
}

app.add_observer(on_start_game);

Note: Observers are registered with app.add_observer(), not app.add_systems().

Deferred connections renamed

The TypedDeferredSignalConnections<T> component has been renamed to DeferredSignalConnections<T>:

Before:

commands.spawn((
    MyArea,
    TypedDeferredSignalConnections::<BodyEntered>::with_connection(
        "body_entered",
        |_a, _node, e| Some(BodyEntered(e.unwrap())),
    ),
));

After:

commands.spawn((
    MyArea,
    DeferredSignalConnections::<BodyEntered>::with_connection(
        "body_entered",
        |_a, _node, e| Some(BodyEntered { entity: e.unwrap() }),
    ),
));

Plugin renamed

The GodotTypedSignalsPlugin<T> has been renamed to GodotSignalsPlugin<T>:

Before:

app.add_plugins(GodotTypedSignalsPlugin::<ButtonPressed>::default());

After:

app.add_plugins(GodotSignalsPlugin::<ButtonPressed>::default());

Summary of changes

Old APINew API
#[derive(Message)]#[derive(Event)]
GodotTypedSignalsPlugin<T>GodotSignalsPlugin<T>
TypedGodotSignals<T>GodotSignals<T>
connect_map()connect()
connect_map_immediate()connect_immediate()
MessageReader<T>On<T> (observer)
app.add_systems(Update, handler)app.add_observer(handler)
TypedDeferredSignalConnections<T>DeferredSignalConnections<T>

Breaking: Collisions API redesigned

The collision API has been redesigned, providing a cleaner separation between querying current collision state and reacting to collision events.

Collisions component removed

The per-entity Collisions component has been replaced with a Collisions system parameter for querying global collision state.

Before:

fn check_player_death(
    player: Query<(&Player, &Collisions)>,
) {
    for (player, collisions) in player.iter() {
        for &other in collisions.colliding() {
            // handle collision
        }
        for &other in collisions.recent_collisions() {
            // handle new collision this frame
        }
    }
}

After:

fn check_player_death(
    player: Query<(Entity, &Player)>,
    collisions: Collisions,
) {
    for (entity, player) in player.iter() {
        // Query current collisions
        for &other in collisions.colliding_with(entity) {
            // handle collision
        }
        // Check specific pair
        if collisions.contains(entity, enemy) {
            // player is touching enemy
        }
    }
}

New collision events for change detection

To detect when collisions start or end, use the new CollisionStarted and CollisionEnded events instead of querying per-frame.

As messages:

fn handle_hits(mut started: MessageReader<CollisionStarted>) {
    for event in started.read() {
        println!("{:?} hit {:?}", event.entity1, event.entity2);
    }
}

As observers:

app.add_observer(|trigger: Trigger<CollisionStarted>| {
    let event = trigger.event();
    println!("{:?} hit {:?}", event.entity1, event.entity2);
});

CollisionMessage removed

The internal CollisionMessage type has been replaced with the public CollisionStarted and CollisionEnded event types.

Summary of changes

Old APINew API
Query<&Collisions>Collisions system param
collisions.colliding()collisions.colliding_with(entity)
collisions.recent_collisions()MessageReader<CollisionStarted>
CollisionMessageCollisionStarted / CollisionEnded

Breaking: Main-thread access is now explicit via GodotAccess

The #[main_thread_system] macro has been removed. Systems that call Godot APIs must include GodotAccess in their parameters. GodotAccess is a NonSend SystemParam that pins the system to the main thread.

Before:

#[main_thread_system]
fn update_ui(mut q: Query<&mut GodotNodeHandle>) {
    for mut handle in &mut q {
        if let Some(mut label) = handle.try_get::<Label>() {
            label.set_text("Hi");
        }
    }
}

After:

fn update_ui(q: Query<&GodotNodeHandle>, mut godot: GodotAccess) {
    for handle in &q {
        if let Some(mut label) = godot.try_get::<Label>(*handle) {
            label.set_text("Hi");
        }
    }
}

Notes:

  • SceneTreeRef is also a NonSend SystemParam. If a system already takes SceneTreeRef and does not call Godot APIs, you do not need an extra GodotAccess parameter.
  • If you only have an InstanceId, use GodotAccess::try_get_instance_id or get_instance_id.

Breaking: GodotNodeHandle is ID-only; GodotNodeId removed

GodotNodeId has been removed. GodotNodeHandle is now a lightweight, copyable wrapper around an InstanceId.

Before:

struct QuitRequested { source: GodotNodeId }

After:

struct QuitRequested { source: GodotNodeHandle }

Constructors:

Before:

let handle = GodotNodeHandle::from_id(node_id);

After:

let handle = GodotNodeHandle::from_instance_id(node_id);
// or: let handle: GodotNodeHandle = node_id.into();

If you were using GodotNodeHandle::get or try_get directly, switch to GodotAccess::get or GodotAccess::try_get instead.

Breaking: Legacy untyped signals removed

The untyped signal API has been removed:

  • GodotSignalsPlugin
  • GodotSignal message
  • connect_godot_signal

Use typed signals instead (GodotTypedSignalsPlugin::<T> + GodotSignals<T>).

Migration Checklist

  • Replace #[derive(Message)] with #[derive(Event)] for signal types.
  • Replace GodotTypedSignalsPlugin<T> with GodotSignalsPlugin<T>.
  • Replace TypedGodotSignals<T> with GodotSignals<T>.
  • Replace connect_map() with connect().
  • Replace MessageReader<T> signal handlers with observers using On<T>.
  • Replace app.add_systems(Update, handler) with app.add_observer(handler) for signal handlers.
  • Replace TypedDeferredSignalConnections<T> with DeferredSignalConnections<T>.
  • Replace Query<&Collisions> with Collisions system param.
  • Replace collisions.colliding() with collisions.colliding_with(entity).
  • Replace collisions.recent_collisions() with MessageReader<CollisionStarted> or observer.
  • Replace #[main_thread_system] with GodotAccess parameters where you call Godot APIs.
  • Replace GodotNodeId with GodotNodeHandle.
  • Replace handle.get or handle.try_get with godot.get or godot.try_get.
  • Replace legacy untyped signal APIs with typed signals.