Migration Guide: v0.11 to v0.12

This guide covers breaking changes when upgrading from godot-bevy 0.11.x to 0.12.0.

Table of Contents


Breaking: Detached nodes remain alive

A node removed with remove_child is no longer freed by godot-bevy (#263). Its mirrored entity still despawns when the removal is processed. Re-adding the surviving node creates a fresh entity. This differs from an in-tree reparent, which preserves the existing entity and its components.

This is the change most likely to affect code that relied on the old behavior. If detaching meant deleting in your game, call queue_free() explicitly. If you keep nodes for reuse, keep their handles and restore any runtime ECS state after re-entry.

ProtectedNodeEntity keeps the entity and its gameplay components. Cleanup removes its Godot components, index entry, and scene-tree relationships. To reuse that entity, insert the surviving node's GodotNodeHandle before re-entry. Re-entry alone creates a fresh entity.


Behavior: Shutdown runs on destruction

A started BevyApp writes AppExit::Success and runs one final Last pass on real destruction or an explicit teardown() (#262). Detaching or reparenting the app node no longer tears down its world. teardown() is idempotent, so a later call or destruction does not run shutdown again.

If your cleanup reads AppExit, handle it in Last. If you previously detached the app node to dispose of the world, call teardown() between frames instead. On destruction, Godot children may already be gone, so shutdown systems must not assume their nodes are still valid.


Dependencies: Bevy 0.19 and godot 0.5

All projects upgrading from 0.11 need to move Bevy from 0.18 to 0.19 and godot from 0.4 to 0.5 (#225, #238). These are breaking dependency upgrades. Update your manifest:

godot-bevy = "0.12"
bevy = { version = "0.19", default-features = false }
godot = "0.5"

Follow Bevy's 0.18 to 0.19 migration guide and gdext's migration to 0.5 for changes in your own systems and Godot classes. The minimum gdext patch is 0.5.5, as described below.

The Godot API selectors api-4-2, api-4-3, api-4-4, api-4-5, and api-4-6 all build (#252, #264). Projects targeting an older Godot runtime can select its API level. Enable only one selector and keep the GDExtension descriptor's compatibility_minimum consistent with it. Without a selector, the default API is 4.6.


New: Event bridge

The event bridge delivers typed Bevy events from GDScript and Rust (#242). Register a GDScript name and payload decoder with app.add_godot_event::<T>(...), then call BevyAppSingleton.send_event("name", payload) from GDScript. Rust callers can use send_event or clone GodotEventSender for channel sends, including from other threads. Events are queued and delivered to On<T> observers at the next First drain, once per render frame.

If you used GodotMailboxPlugin, replace its field polling with event registration and explicit sends. Replace MessageReader<T> consumers with observers, or accumulate events in a resource when you need fixed-step batches. See The Event Bridge for the migration table and callback timing restrictions.


Breaking: Integration tests live in the game crate

The documented test setup uses one crate with an optional godot-bevy-test dependency behind an itest feature (#260). The old multi-crate layout is no longer documented or supported when it links the game as an rlib, because that creates duplicate GDExtension entry symbols.

Move integration tests into a feature-gated module in the game crate. Keep its existing cdylib and #[bevy_app] entry point, and call godot_bevy_test::declare_test_runner!() there under the same feature. The usual test signature is #[itest] async fn test(ctx: TestContext). The context is owned so it can outlive the spawned task; the explicit #[itest(async)] form remains available.

Configure the game runner and use cargo run --features itest. Direct Godot launches must set GODOT_BEVY_ITEST=1 so the game autoload waits for the test runner. See Testing for dependencies, runner setup, and frame control.


New: GodotNode on tuple structs

GodotNode now supports tuple structs (#261). Exported fields use value0, value1, and so on, based on their tuple index. This lets component-first newtypes and tuples use the same #[gdbevy(export, ...)] attributes as named fields. Existing named structs need no changes. If you convert one to a tuple, update saved property names and any scripts that access those exports.


New: Inspector metadata on generated exports

Generated exports accept description, hint, and hint_string metadata (#262). These work on primary fields and required-component exports. description is a string literal, hint names a Godot PropertyHint variant, and hint_string requires a hint. Existing exports need no changes; add metadata where designers need help text or a specific Inspector control.

Enable the opt-in register-docs feature to show descriptions in Godot's editor help. It requires Godot API 4.3 or later. On API 4.2, leave it disabled. Hints work without it, and descriptions remain Rust docs. See Inspector metadata for the attribute syntax.


New: AttachableComponent

AttachableComponent turns an editor-authored child node into a component on its parent's Bevy entity (#265). Use it for configuration authored as child nodes. The carrier is consumed after successful attachment and never gets its own entity, so do not keep references to it or expect live Inspector synchronization. Existing components need no migration. See Attachable Components for conversion, placement, and lifetime rules.


Known limitations: Web builds

The web/wasm example does not build on current nightly (#268). The library's web features compile, but web execution remains unverified for 0.12. If you ship to browsers, keep this upgrade blocked on your own end-to-end validation and follow #268 for the build failure.


Macro Redesign -- #[gdbevy(...)] Unified Grammar (Breaking Change)

The four old bridging macros (BevyBundle, #[bevy_bundle(...)], #[godot_node(...)], #[godot_export(...)], #[export_fields(...)]) are removed. They are replaced by two derives that share one #[gdbevy(...)] grammar:

  • GodotNode -- component-first. Derive alongside Component; the macro generates the Godot class.
  • BevyComponents -- Godot-first. Derive alongside GodotClass; you own the class.

There are no deprecation aliases -- the old names are gone and will fail to compile.

Godot-first: BevyBundleBevyComponents

Before:

#![allow(unused)]
fn main() {
#[derive(GodotClass, BevyBundle)]
#[class(base = Node2D)]
#[bevy_bundle((Player), (Speed: speed))]
struct PlayerNode {
    base: Base<Node2D>,
    #[export]
    #[bevy_bundle(transform_with = "to_speed")]
    speed: f32,
}
#[godot_api]
impl INode2D for PlayerNode {
    fn init(base: Base<Node2D>) -> Self { Self { base, speed: 250.0 } }
}
}

After:

#![allow(unused)]
fn main() {
#[derive(GodotClass, BevyComponents)]
#[class(base = Node2D, init)]
#[gdbevy(require(Player))]
struct PlayerNode {
    base: Base<Node2D>,
    #[gdbevy(component = Speed, with = to_speed)]
    #[export]
    #[init(val = 250.0)]
    speed: f32,
}
}

Changes:

  • Replace BevyBundle derive with BevyComponents.
  • Replace #[bevy_bundle((Marker), (Comp: field))] with #[gdbevy(require(Marker))] at the struct level and #[gdbevy(component = Comp)] at the field level.
  • Replace #[bevy_bundle(transform_with = "fn")] with #[gdbevy(with = fn)] (unquoted path, not a string).
  • Replace the hand-written init() with gdext's #[class(…, init)] + #[init(val = …)] field defaults.

Struct-level #[bevy_bundle((Comp { field: godot_field, … }))] (N→1 binding) becomes:

#![allow(unused)]
fn main() {
#[gdbevy(require(Comp { bevy_field: godot_field, … }))]
}

Component-first: bundle mode → GodotNode

The old #[derive(Bundle, GodotNode)] + #[export_fields(...)] form is removed. Use the component-first GodotNode path with require(...) companions instead.

Before:

#![allow(unused)]
fn main() {
#[derive(Bundle, GodotNode)]
#[godot_node(base(CharacterBody2D), class_name(Player2D))]
pub struct PlayerBundle {
    pub player: Player,
    #[export_fields(value(export_type(f32), default(250.0)))]
    pub speed: Speed,
    #[export_fields(value(export_type(f32), default(-400.0)))]
    pub jump_velocity: JumpVelocity,
    #[export_fields(value(export_type(f32), default(godot::classes::ProjectSettings::singleton()
        .get_setting("physics/2d/default_gravity")
        .try_to::<f32>()
        .unwrap_or(980.0))))]
    pub gravity: Gravity,
}
}

After:

#![allow(unused)]
fn main() {
#[derive(Component, GodotNode, Default)]
#[gdbevy(base = CharacterBody2D, class_name = Player2D)]
#[gdbevy(
    require(speed: Speed, as = f32, default = 250.0),
    require(jump_velocity: JumpVelocity, as = f32, default = -400.0),
    require(gravity: Gravity, as = f32, default = 980.0),
)]
pub struct Player;
}

Changes:

  • Replace #[derive(Bundle, GodotNode)] with #[derive(Component, GodotNode, Default)].
  • Replace #[godot_node(base(…), class_name(…))] with #[gdbevy(base = …, class_name = …)].
  • Replace #[export_fields(value(export_type(T), default(expr)))] on bundle fields with require(prop: Comp, as = T, default = expr) at the struct level.
  • Gravity default: The old default called ProjectSettings::singleton(), which works on the Godot main thread. The new default = expr also runs inside a required-component constructor (called during pure Bevy spawn), where Godot may not be running. Use a static value: default = 980.0 instead of the ProjectSettings lookup.

The component-first #[godot_export(default(…))] on primary struct fields becomes #[gdbevy(export, default = …)]:

Before:

#![allow(unused)]
fn main() {
#[derive(Component, GodotNode, Default)]
#[godot_node(base(Area2D), class_name(Door2D))]
pub struct Door {
    #[godot_export(default(LevelId::Level1))]
    pub level_id: LevelId,
}
}

After:

#![allow(unused)]
fn main() {
#[derive(Component, GodotNode, Default)]
#[gdbevy(base = Area2D, class_name = Door2D)]
pub struct Door {
    #[gdbevy(export, default = LevelId::Level1)]
    pub level_id: LevelId,
}
}

Reserved keys

into and sync are reserved for the upcoming component-sync feature. Using them now produces a compile error. Remove any speculative use of these keys.

Migration checklist

  • Replace BevyBundle derives with BevyComponents.
  • Replace #[bevy_bundle(...)] struct attributes with #[gdbevy(require(...))].
  • Replace #[bevy_bundle(transform_with = "fn")] with #[gdbevy(with = fn)] (no quotes).
  • Replace bundle-mode #[derive(Bundle, GodotNode)] + #[export_fields] with component-first #[derive(Component, GodotNode, Default)] + require(...).
  • Replace #[godot_node(base(…), class_name(…))] with #[gdbevy(base = …, class_name = …)].
  • Replace #[godot_export(default(…))] with #[gdbevy(export, default = …)].
  • Replace any ProjectSettings-dependent defaults with static values.
  • Remove any into / sync keys.

Behavior: Native schedule ordering

The main schedule now runs in stock Bevy order:

First → PreUpdate → StateTransition → [FixedMain ×N] → Update → PostUpdate → Last

The prefix (First through StateTransition) runs once per render frame. It runs in the first _physics_process, or in _process if the frame has no physics step. Further physics steps run only FixedMain. The suffix (Update through Last) runs in _process. clear_trackers fires once at the end of _process, covering the full render frame.

What changed from v0.11: In v0.11 the prefix ran in _process after the fixed steps. That meant PreUpdate and StateTransition came after the fixed loop instead of before it. The new ordering matches what you'd see in a plain Bevy desktop app.

Practical effects:

  • Changed<T>, Added<T>, and RemovedComponents from PreUpdate mutations are visible in FixedUpdate within the same render frame.
  • OnEnter / OnExit state transitions (fired by StateTransition) are visible to FixedUpdate in the same frame.
  • Systems that do clock-sensitive Godot FFI (e.g. reading physics state) and were previously safe in First/PreUpdate may now run under the physics clock. Move such work to Update or later if it must run after Godot's physics integration for the current frame.

Secondary Bevy SubApps are rejected when the app is built. The driver runs Main directly and cannot extract or update a secondary app. If a plugin creates one, remove that plugin or move its logic into the main app.


Behavior: ButtonInput now works in FixedUpdate

ButtonInput<KeyCode>::just_pressed and just_released are now visible in FixedUpdate. Previously they were always false there because PreUpdate (where keyboard_input_system populates the resource) ran after the fixed steps. With native ordering, PreUpdate runs before the fixed steps -- so when a physics step runs, edges are already set by the time FixedUpdate runs.

#![allow(unused)]
fn main() {
// Now visible in FixedUpdate (when a physics step runs on the edge's frame)
app.add_systems(FixedUpdate, |keys: Res<ButtonInput<KeyCode>>| {
    if keys.just_pressed(KeyCode::Space) { /* see caveats below */ }
});
}

Caveat: just_pressed is a one-render-frame edge (cleared each frame in PreUpdate), so the render frame's physics-step count matters:

  • More physics than display (N steps/frame): just_pressed is true in all N FixedUpdate calls that frame.
  • More display than physics: a frame can run zero physics steps; an edge landing on a step-less frame is never seen in FixedUpdate (cleared by the next frame). Same caveat as stock Bevy.

GodotActions uses Godot's per-tick edge state and has neither issue -- prefer it for fixed-rate gameplay input.

No code change is required -- this is a fix. If you were working around the limitation (e.g. by reading input only in Update and storing state in a resource), the workaround is no longer necessary.


Behavior: TwoWay transform sync -- now per-axis co-authorship

TwoWay transform sync is improved. The Godot→Bevy read runs in FixedFirst on every physics step. On frames with zero physics steps, a PreUpdate fallback reads transforms unless virtual time is paused. FixedUpdate systems see the current step's Godot-authored transform, and Update sees the last read for that frame. If you read synced transforms in PreUpdate, move that work to FixedUpdate or Update.

The echo guard is a per-entity value shadow, so a node can be co-authored per axis: Godot drives some translation/scale components while Bevy drives others, on the same node, every frame, and neither clobbers the other. Rotation is whole (quaternion components aren't independently meaningful); if both sides change the same axis in one frame, Bevy wins.

No changes to the co-authorship setup are required.


Breaking: GodotActions Update readers need .after(GodotInputSet)

GodotActionsPlugin previously polled the process snapshot in First. It now polls in Update (to stay in the suffix, where the process clock is correct). If you have Update systems that read Res<GodotActions>, they must run after the poll:

#![allow(unused)]
fn main() {
// Before: no ordering needed (poll was in First, readers ran later by default)
app.add_systems(Update, my_system);

// After: explicit ordering required
app.add_systems(Update, my_system.after(GodotInputSet));
}

FixedUpdate systems are unaffected -- the fixed-schedule driver polls the physics snapshot automatically.

If you added .after(GodotInputSet) already (as the GodotActions docs recommended), no change is needed.


Breaking: RunFixedMainLoop removed from MainScheduleOrder

RunFixedMainLoop is no longer a member of MainScheduleOrder.labels. Its slot is held by a private marker so app.update() does not drive the fixed loop (godot-bevy drives it from _physics_process instead).

Affected: plugins that insert top-level schedules by calling insert_after(RunFixedMainLoop, MySchedule).

#![allow(unused)]
fn main() {
// This panics -- RunFixedMainLoop is not in the label list
order.insert_after(RunFixedMainLoop, MySchedule);
}

Fix: insert relative to a schedule that is still in the list, or use the public anchor sets:

#![allow(unused)]
fn main() {
// Prefix (before fixed steps): target First, PreUpdate, or StateTransition
order.insert_after(PreUpdate, MyPrefixSchedule);

// Suffix (after fixed steps): target Update, PostUpdate, or Last
order.insert_after(Update, MySuffixSchedule);

// Adjacent to the fixed loop: use the anchor sets on RunFixedMainLoop itself
app.add_systems(
    RunFixedMainLoop,
    my_system.in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop),
);
}

The custom PhysicsUpdate, PrePhysicsUpdate, PhysicsDelta, and SystemDeltaTimer types are removed. godot-bevy now drives Bevy's standard FixedMain schedule directly from Godot's _physics_process.

Move systems to standard Fixed schedules

Before:

app.add_systems(PhysicsUpdate, character_movement);
app.add_systems(PrePhysicsUpdate, read_input);

After:

app.add_systems(FixedUpdate, character_movement);
app.add_systems(FixedPreUpdate, read_input);

The full FixedMain schedule order is:

OldNew equivalent
PrePhysicsUpdateFixedFirst or FixedPreUpdate
PhysicsUpdateFixedUpdate
(none)FixedPostUpdate
(none)FixedLast

Replace PhysicsDelta with Res<Time>

Before:

fn move_player(
    physics_delta: Res<PhysicsDelta>,
    mut q: Query<&mut Transform, With<Player>>,
) {
    let delta = physics_delta.delta_seconds;
    for mut t in &mut q {
        t.translation.x += 100.0 * delta;
    }
}

After:

fn move_player(
    time: Res<Time>,
    mut q: Query<&mut Transform, With<Player>>,
) {
    let delta = time.delta_secs();
    for mut t in &mut q {
        t.translation.x += 100.0 * delta;
    }
}

Res<Time> in a FixedUpdate system automatically reports the fixed physics delta -- no special type needed.

SystemDeltaTimer removed

SystemDeltaTimer is removed. For cooldowns and accumulators inside fixed systems, use Bevy's standard timer types (Timer, Stopwatch) or accumulate time.delta_secs() directly.


Breaking: FixedUpdate now runs at Godot's physics rate

Previously, FixedUpdate ran on Bevy's own 64 Hz clock (independent of Godot). Now it runs on Godot's authoritative physics clock.

Default rate: 60 Hz (controlled by Project Settings → Physics → Common → Physics Ticks Per Second).

Do not hardcode the timestep:

// BAD: breaks if you change physics rate in Project Settings
let delta = 1.0 / 60.0;

// GOOD: always correct
fn my_system(time: Res<Time>) {
    let delta = time.delta_secs();
}

New: Engine.time_scale honored automatically

Engine.time_scale (slow-motion, fast-forward, freeze) now scales both schedules. Godot's physics delta already incorporates the scale, so FixedUpdate systems reading Res<Time> slow down for free. The Update schedule scales too: godot-bevy mirrors time_scale onto Time<Virtual>, so timers, tweens, and camera lerps in Update honor it as well. Time<Real> stays truthful -- read it for anything that must ignore time scaling (diagnostics, real-time UI).


Breaking: SceneTree.paused now runs Update systems (process_mode = ALWAYS)

BevyApp now sets its process_mode to ALWAYS, so _process and _physics_process keep firing while SceneTree.paused is true. Previously an INHERIT/PAUSABLE BevyApp froze both callbacks, so a tree-pause stopped the entire ECS -- including any Bevy-authored pause menu. Now the ECS keeps ticking through a pause, which is what makes a pause menu in Bevy possible.

The trade-off: an Update system that does not scale by delta now runs under SceneTree.paused, where the frozen callback used to stop it. FixedUpdate is unaffected -- godot-bevy freezes the fixed schedule under pause (it keys on Time<Virtual>, and a tree-pause drives that), so physics-rate gameplay still halts.

If you have Update work that must stop on pause, either:

  • scale it by Time<Virtual>::delta() (it is 0 while paused), or
  • gate it: system.run_if(not(bevy_time::common_conditions::paused)).

There is no opt-out from ALWAYS -- it is the single behavior. Pausing the tree, pausing Time<Virtual> directly, and setting Engine.time_scale all still work; only the "the whole app freezes" side effect is gone.


New: Physics interpolation support

godot-bevy now writes transforms at physics rate (FixedLast). To get smooth rendering between physics ticks, enable Godot's built-in physics interpolation.

In Project Settings: Physics → Common → Physics Interpolation = true

In project.godot:

[physics]
common/physics_interpolation=true

When a node teleports (respawn, warp), call reset_physics_interpolation() on it immediately after moving so Godot doesn't interpolate across the jump.

Bevy-side interpolation plugins and rollback netcode are not supported in godot-bevy's transform sync path -- use Godot's built-in interpolation instead.


Dependencies: gdext bumped to 0.5.5

godot-bevy now requires godot >= 0.5.5. Three effects:

  • Editor hot-reload is safer. Custom signal callables connected through godot-bevy's signal bridge are now auto-removed when the extension reloads (gdext #1612), so rebuilding the Rust dylib while a game runs in the editor with connected signals no longer risks an editor crash. (This is a development-time property; exported builds never hot-reload.)
  • Reference counting no longer touches gdext internals. 0.5.5 removed the internal reference-count operations godot-bevy used, so the floor moves with it (#271).
  • No more libclang/LLVM to build from source. gdext 0.5.3+ generates its bindings from JSON and no longer depends on bindgen, so building godot-bevy no longer requires a system libclang/LLVM.

You only need to act if you pin godot to an exact version below 0.5.5 (e.g. godot = "=0.5.1") and also depend on godot-bevy -- bump your pin to 0.5.5. A normal caret dependency (godot = "0.5") resolves to 0.5.5 automatically.


Behavior: Reparenting preserves ECS component state

Moving a mirrored node to a new parent no longer re-runs component initializers or autosync bundle creators, so Transform and your #[derive(GodotNode)] / BevyComponents components keep the values your systems set. Previously a reparent silently reset them to the node's exported values. Node group membership (Groups) is likewise now a spawn-time snapshot rather than being re-read on reparent. If you relied on a reparent refreshing any of these, refresh the component explicitly in a system.

No action required unless you depended on the old reset-on-reparent behavior.


Behavior: CollisionEnded now fires when a colliding node is freed

Freeing a node while it overlaps another now emits CollisionEnded for each pair the node was in, and clears those pairs from Collisions. Previously the pair leaked: the freed node lost its entity mapping before the exit signal resolved, so the channel dropped the Ended and Collisions::contains reported the dead pair forever. This is the "bullet dies on hit" case.

The freed side of CollisionEnded references a despawned entity -- the same contract Avian uses; observers already tolerate a target that may be gone.

The free-driven end surfaces on MessageReader<CollisionEnded> and On<CollisionEnded> observers, not on CollisionState::ended(). The observer fires during the despawn flush in First, while ended_this_frame is cleared at the top of the collision step in FixedFirst (which runs after First), so a First-timed push would be wiped before any reader saw it. Read the message or observer tier for free-driven ends.

No action required unless you filtered these events assuming both entities are always alive.


Behavior: CollisionStarted now fires for Area spawn-into-overlap

An Area2D/Area3D spawned already overlapping something now emits CollisionStarted for that overlap. Previously the overlap was silently missed: the Area's real enter signal fired before godot-bevy connected to it, so nothing recorded the pair. At connect the plugin now seeds the Area's current overlaps (get_overlapping_bodies / get_overlapping_areas) as synthetic starts. The seed is deduplicated against the real signal, so a normal (not-pre-overlapping) spawn is unaffected.

The seed is Area-only. A RigidBody2D/RigidBody3D spawned into an existing overlap still misses its CollisionStarted even with contact_monitor enabled (see the residual limitation below).

No action required.


Behavior: Warning for contact_monitor-less RigidBodies

Connecting a RigidBody2D/RigidBody3D whose contact_monitor is disabled now logs a one-time warning at connect. With contact_monitor off, Godot never fires the body's body_entered/body_exited signals, so godot-bevy would silently report no collisions for that body -- an easy trap to hit. godot-bevy does not auto-enable contact_monitor (it changes physics cost and max_contacts_reported has no library-correct default); set it yourself if you want the events:

# On the RigidBody, in the editor or in code
contact_monitor = true
max_contacts_reported = 4   # > 0, sized to your needs

Residual limitation: enabling contact_monitor makes the signals fire, but the plugin still does not seed RigidBody overlaps (the seed is Area-only). So a contact_monitor- enabled RigidBody spawned into an existing overlap still misses that first CollisionStarted -- its enter fired before connect and there is no seed backstop. Enter overlaps after spawn work normally. If you need spawn-into-overlap detection, use an Area2D/Area3D.


Migration Checklist

  • Replace PhysicsUpdate with FixedUpdate.
  • Replace PrePhysicsUpdate with FixedFirst or FixedPreUpdate.
  • Replace Res<PhysicsDelta> + physics_delta.delta_seconds with Res<Time> + time.delta_secs().
  • Remove any SystemDeltaTimer usage; use standard Bevy Timer/Stopwatch or accumulate time.delta_secs().
  • Remove hardcoded 1.0 / 60.0 timestep constants; read time.delta_secs() instead.
  • Enable physics interpolation in project.godot if your game has moving objects.
  • Call reset_physics_interpolation() on nodes that teleport.
  • If using avian physics, pass FixedUpdate instead of PhysicsUpdate to PhysicsPlugins::new(...).