Introduction

Welcome to godot-bevy, a Rust library that brings Bevy's powerful Entity Component System (ECS) to the versatile Godot Game Engine.

What is godot-bevy?

godot-bevy enables you to write high-performance game logic using Bevy's ergonomic ECS within your Godot projects. This is not a Godot plugin for Bevy users, but rather a library for Godot developers who want to leverage Rust and ECS for their game logic while keeping Godot's excellent editor and engine features.

Why godot-bevy?

The Best of Both Worlds

  • Godot's Strengths: Visual scene editor, node system, asset pipeline, cross-platform deployment
  • Bevy's Strengths: High-performance ECS, Rust's safety and speed, data-oriented architecture
  • godot-bevy: Seamless integration between the two, letting you use each tool where it shines

Key Benefits

  1. Performance: Bevy's ECS provides cache-friendly data layouts and parallel system execution
  2. Safety: Rust's type system catches bugs at compile time
  3. Modularity: ECS encourages clean, decoupled code architecture
  4. Flexibility: Mix and match Godot nodes with ECS components as needed

Core Features

  • Deep ECS Integration: True Bevy systems controlling Godot nodes
  • Transform Synchronization: Automatic syncing between Bevy and Godot transforms
  • Signal Handling: React to Godot signals in your ECS systems
  • Collision Events: Handle physics collisions through the ECS
  • Resource Management: Load Godot assets through Bevy's asset system
  • Smart Scheduling: Separate physics and rendering update rates

Who Should Use godot-bevy?

This library is ideal for:

  • Godot developers wanting to use Rust for game logic
  • Teams looking for better code organization through ECS
  • Projects requiring high-performance game systems
  • Developers familiar with data-oriented design patterns

Getting Help

Ready to Get Started?

Head to the Installation chapter to begin your godot-bevy journey!

Getting Started

Installation

This guide will walk you through setting up godot-bevy in a Godot project.

Prerequisites

Before you begin, ensure you have:

Installation Methods

There are two ways to set up godot-bevy in your project:

  1. Plugin Installation (Recommended) - Use the godot-bevy editor plugin for automatic setup
  2. Manual Installation - Set up the project manually

Plugin Installation

The easiest way to get started is using the godot-bevy editor plugin, which automatically generates the Rust project and configures the BevyApp singleton.

1. Install the Plugin

  1. Download the addons/godot-bevy folder from the godot-bevy repository
  2. Copy it to your Godot project's addons/ directory
  3. In Godot, go to Project > Project Settings > Plugins
  4. Enable the "Godot-Bevy Integration" plugin

2. Create Your Project

  1. Go to Project > Tools > Setup godot-bevy Project
  2. Configure your project settings:
    • Project name: Used for the Rust crate name
    • godot-bevy version: Library version (default: 0.12.0)
    • Release build: Whether to build in release mode initially
  3. Click "Create Project"

The plugin will automatically:

  • Create a rust/ directory with Cargo.toml and lib.rs
  • Generate the .gdextension file with correct platform paths
  • Create and register the BevyApp singleton scene
  • Build the Rust project
  • Restart the editor to apply changes

3. Run Your Project

After the editor restarts:

  1. Press F5 or click the play button
  2. You should see "Hello from Bevy ECS!" in the output console every second

The generated rust/src/lib.rs includes a complete example.

4. Plugin Features

The plugin provides additional useful features:

  • Add BevyApp Singleton Only: If you already have a Rust project, use Project > Tools > Add BevyApp Singleton to just create and register the singleton
  • Build Rust Project: Use Project > Tools > Build Rust Project to rebuild without restarting the editor

Manual Installation

If you prefer to set up everything manually, follow these steps:

1. Set Up Godot Project

First, create a new Godot project through the Godot editor:

  1. Open Godot and click "New Project"
  2. Choose a project name and location
  3. Select "Compatibility" renderer for maximum platform support
  4. Click "Create & Edit"

2. Set Up Rust Project

In your Godot project directory, create a new Rust library:

cd /path/to/your/godot/project
cargo init --lib rust
cd rust

3. Configure Cargo.toml

Edit rust/Cargo.toml:

[package]
name = "your_game_name"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

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

godot-bevy supports Godot API levels 4.2 through 4.6 via the mutually exclusive Cargo features api-4-2, api-4-3, api-4-4, api-4-5, and api-4-6, with or without default features. Set compatibility_minimum in your .gdextension file to the level you build against; the example below assumes 4.3.

Configure Godot Integration

1. Create Extension File

Create rust.gdextension in your Godot project root:

[configuration]
entry_symbol = "gdext_rust_init"
compatibility_minimum = 4.3
reloadable = true

[libraries]
macos.debug = "res://rust/target/debug/libyour_game_name.dylib"
macos.release = "res://rust/target/release/libyour_game_name.dylib"
windows.debug.x86_32 = "res://rust/target/debug/your_game_name.dll"
windows.release.x86_32 = "res://rust/target/release/your_game_name.dll"
windows.debug.x86_64 = "res://rust/target/debug/your_game_name.dll"
windows.release.x86_64 = "res://rust/target/release/your_game_name.dll"
linux.debug.x86_64 = "res://rust/target/debug/libyour_game_name.so"
linux.release.x86_64 = "res://rust/target/release/libyour_game_name.so"
linux.debug.arm64 = "res://rust/target/debug/libyour_game_name.so"
linux.release.arm64 = "res://rust/target/release/libyour_game_name.so"
linux.debug.rv64 = "res://rust/target/debug/libyour_game_name.so"
linux.release.rv64 = "res://rust/target/release/libyour_game_name.so"

Replace your_game_name with your actual crate name from Cargo.toml.

2. Create BevyApp Autoload

  1. In Godot, create a new scene
  2. Add a BevyApp node as the root
  3. Save it as bevy_app_singleton.tscn
  4. Go to Project → Project Settings → Globals → Autoload
  5. Add the scene with name "BevyAppSingleton"

Write Your First Code

Edit rust/src/lib.rs:

#![allow(unused)]
fn main() {
use godot::prelude::*;
use bevy::prelude::*;
use godot_bevy::prelude::*;

#[bevy_app]
fn build_app(app: &mut App) {
    app.add_systems(Startup, hello_world);
}

fn hello_world() {
    godot::prelude::godot_print!("Hello from godot-bevy!");
}
}

Build and Run

1. Build the Rust Library

cd rust
cargo build

2. Run in Godot

  1. Return to the Godot editor
  2. Press F5 or click the play button
  3. You should see "Hello from godot-bevy!" in the output console

Troubleshooting

Plugin Installation Issues

"Plugin not found" or "Plugin failed to load"

  • Ensure the addons/godot-bevy folder is in the correct location
  • Check that all plugin files are present (plugin.cfg, plugin.gd, etc.)
  • Restart the Godot editor after copying the plugin files

"Setup godot-bevy Project" menu item missing

  • Verify the plugin is enabled in Project Settings > Plugins
  • Check the Godot console for plugin error messages
  • Try disabling and re-enabling the plugin

Plugin setup fails or hangs

  • Ensure you have cargo installed and available in your system PATH
  • Check that you have write permissions in the project directory
  • Look for error messages in the Godot output console

Manual Installation Issues

"Can't open dynamic library"

  • Ensure the paths in rust.gdextension match your library output
  • Check that you've built the Rust project
  • On macOS, you may need to allow the library in System Preferences

"BevyApp not found"

  • Make sure godot-bevy is properly added to your dependencies
  • Rebuild the Rust project
  • Restart the Godot editor

Build errors

  • Verify your Rust version: rustc --version
  • Ensure all dependencies are compatible
  • Check for typos in the crate name

Next Steps

Congratulations! You've successfully set up godot-bevy using either the plugin or manual installation method.

Continue to Basic Concepts to learn more about godot-bevy's architecture and capabilities.

Run your Godot project with Cargo

The following steps will help you set up cargo run to run your Godot project. We will use the gdenv utility which has two parts, a standalone command line tool and a crate library.

We will be working with the following files.

- project-root/
  - Cargo.toml
  - gdenv.toml
  - run_godot.rs

Update Cargo.toml with the following contents:

# Add this new section:
[[bin]]
name = "project_name_here_bin"
path = "run_godot.rs"

# Update your dependencies section with:
[dependencies]
gdenv-lib = { git = "https://github.com/bytemeadow/gdenv.git", tag = "v1.0.0" }
# Add godot-bevy-test if you want to also set up integration tests
godot-bevy-test = { version = "0.12", optional = true }

# Update or add your features section with:
[features]
# Add godot-bevy-test if you want to also set up integration tests
itest = ["dep:godot-bevy-test", "godot-bevy-test/test-frame-signal"]

Create a new run_godot.rs file. Put it at the same folder level as Cargo.toml. Populate it with the following contents:

#[cfg(not(feature = "itest"))] // Keep this conditional compilation statement if you want to set up integration tests
fn main() {
    gdenv_lib::api::godot_runner::GodotRunner::init()
        .and_then(|r| r.build())
        .and_then(|r| r.execute())
        .unwrap_or_else(gdenv_lib::api::errors::print_error_stack);
}

// Keep the following code block if you want to set up integration tests
// Run with `cargo run --features itest` to run integration tests
#[cfg(feature = "itest")]
fn main() {
    unsafe { std::env::set_var("GODOT_BEVY_ITEST", "1") };

    gdenv_lib::api::godot_runner::GodotRunner::init()
        .and_then(|r| {
            r.godot_cli_arguments(Some(vec![
                "--headless".to_string(),
                "--fixed-fps".to_string(),
                "60".to_string(),
                "--scene".to_string(),
                "res://addons/godot-bevy/test/TestRunner.tscn".to_string(),
                "--quit-after".to_string(),
                "10000".to_string(),
            ]))
            .build()
        })
        .and_then(|r| r.execute())
        .unwrap_or_else(gdenv_lib::api::errors::print_error_stack);

    std::process::exit(godot_bevy_test::exit_code::read_and_cleanup_exit_code().unwrap_or(1));
}

Add a gdenv.toml file at the root of your project. Populate it with the following contents:

[godot]
version = "4.6.2"
project_dir = "<relative path to your godot project>"

[gdextension.1.Rust]
cargo_crate_path = "<relative path to your rust project>"

Adjust the Godot version, project_dir, and cargo_crate_path to match your project structure.

The [gdextension.1.Rust] section will automatically generate a rust.gdextension file in your Godot project. If you are using git as your version control system, you can delete rust.gdextension and rust.gdextension.uid from your Godot project and commit the deletions to your version control system. Then you can add the following lines to the .gitignore file in your Godot project:

rust.gdextension
rust.gdextension.uid

This helps make your project more portable across developer machines (specifically if they have changed the cargo build output directory).

Basic Concepts

Before diving into godot-bevy development, it's important to understand the key concepts that make this integration work.

The Hybrid Architecture

godot-bevy creates a bridge between two powerful systems:

Godot Side

  • Scene tree with nodes
  • Visual editor for level design
  • Asset pipeline for resources
  • Rendering engine
  • Physics engine

Bevy Side

  • Entity Component System (ECS)
  • Systems for game logic
  • Components for data
  • Resources for shared state
  • Schedules for execution order

The Bridge

godot-bevy seamlessly connects these worlds:

  • Godot nodes ↔ ECS entities
  • Node properties ↔ Components
  • Signals → Events
  • Resources ↔ Assets

Core Components

Entities

In godot-bevy, Godot nodes are automatically registered as ECS entities:

#![allow(unused)]
fn main() {
// When a node is added to the scene tree,
// it becomes queryable as an entity
fn find_player(
    query: Query<&Name, With<GodotNodeHandle>>,
) {
    for name in query.iter() {
        if name.as_str() == "Player" {
            // Found the player node!
        }
    }
}
}

Components

Components store data on entities. godot-bevy provides several built-in components:

  • GodotNodeHandle - Reference to the Godot node
  • Name - Node name
  • Groups - Godot node groups

For collision detection, use the Collisions system param (see Plugins).

Systems

Systems contain your game logic and run on a schedule:

#![allow(unused)]
fn main() {
fn movement_system(
    time: Res<Time>,
    mut query: Query<&mut Transform, With<Player>>,
) {
    for mut transform in query.iter_mut() {
        transform.translation.x += 100.0 * time.delta_secs();
    }
}
}

The #[bevy_app] Macro

The entry point for godot-bevy is the #[bevy_app] macro:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    // Configure your Bevy app here
    app.add_systems(Update, my_system);
}
}

This macro:

  1. Creates the GDExtension entry point
  2. Sets up the Bevy app
  3. Integrates with Godot's lifecycle
  4. Handles all the bridging magic

Shutdown lifecycle

A BevyApp disposes of its world when the node is destroyed or when you call teardown. Reinitialization, TestApp::cleanup, and TestApp Drop use the same teardown path. Call explicit teardown or reinitialization between frames. Repeated teardown calls do nothing.

After hosted Startup completes, a healthy app receives AppExit::Success and one final Last pass before disposal. Ordinary Last systems also run every render frame. Unstarted apps are dropped without running Startup or Last. Frame panics and an existing unwind suppress the terminal pass.

Destruction includes free, queued node or ancestor deletion, scene replacement for scene-local apps, and graceful engine quit. An autoload survives scene replacement. Detaching or reparenting does not itself shut down a hosted world; scene-mirrored entities still follow their normal membership rules. The caller must eventually free a detached node. Use queue_free to delete a host or its ancestor from a system. Synchronous destruction while its Rust instance is borrowed is unsupported.

At destruction, the node's children are already freed. At engine quit, other nodes may be gone too. Last systems and pending signal connections must tolerate expired Godot handles. Call teardown before deletion if cleanup needs live nodes. A terminal panic is caught and reported, and the app is still dropped. Resource destructor panics are also caught and reported. Other cleanup systems may not finish after a panic. Hard termination and aborting or double panics cannot provide this cleanup guarantee. Bevy AppExit messages do not quit Godot.

The extension's on_stage_deinit hook is a separate process-level lifecycle event; it shuts down extension-wide services such as profiling and is not a replacement for per-BevyApp shutdown.

Configuring Core Behavior

The #[bevy_app] macro accepts configuration attributes to customize godot-bevy's core behavior:

Scene Tree Relationships

godot-bevy mirrors Godot's scene tree with a custom ECS relationship: GodotChildOf / GodotChildren. This avoids conflicts with Bevy's built-in ChildOf / Children relationship (used by many plugins for their own hierarchies).

By default, despawning a parent entity will also despawn its Godot children. You can disable that behavior with the scene_tree_auto_despawn_children attribute:

#![allow(unused)]
fn main() {
#[bevy_app(scene_tree_auto_despawn_children = false)]
fn build_app(app: &mut App) {
    // Children can outlive their parents (useful for pooling or custom lifetimes)
    app.add_plugins(PhysicsPlugins::new(FixedUpdate));
}
}

When to use this:

  • ✅ When you want entities to outlive their Godot nodes
  • ✅ When you manage lifetimes manually (pooling, reuse, gameplay-driven despawns)
  • ❌ When you rely on automatic child cleanup on parent despawn

Default behavior (when not specified): scene_tree_auto_despawn_children = true

Data Flow

Understanding how data flows between Godot and Bevy is crucial:

Godot → Bevy

  1. Node added to scene tree
  2. Entity created with components
  3. Signals converted to events
  4. Input forwarded to systems

Bevy → Godot

  1. Transform components sync to nodes
  2. Commands can modify scene tree
  3. Resources can be loaded
  4. Audio can be played

Key Principles

1. Godot for Content, Bevy for Logic

  • Design levels in Godot's editor
  • Write game logic in Bevy systems
  • Let each tool do what it does best

2. Components as the Source of Truth

  • Store game state in components
  • Use Godot nodes for presentation
  • Sync only what's necessary

3. Systems for Everything

  • Movement? System.
  • Combat? System.
  • UI updates? System.
  • This promotes modularity and reusability

4. Leverage Both Ecosystems

  • Use Godot's assets and tools
  • Use Bevy's plugins and crates
  • Don't reinvent what already exists

5. The Godot Boundary (Main Thread Only)

  • Any call into Godot (via GodotAccess, Gd<T>, Input::singleton, etc.) must run on the main thread
  • Systems that include GodotAccess are forced onto the main thread and run sequentially, so keep them small and push heavy work to parallel systems
  • Treat GodotNodeHandle as an ID; resolve to Gd<T> only via GodotAccess
  • See Thread Safety and Godot APIs for details and patterns

Common Patterns

Finding Nodes by Name

#![allow(unused)]
fn main() {
fn setup(
    mut query: Query<(&Name, Entity)>,
) {
    let player = query.iter()
        .find_entity_by_name("Player")
        .expect("Player node must exist");
}
}

Reacting to Signals

#![allow(unused)]
fn main() {
#[derive(Message, Debug, Clone)]
struct ButtonPressed;

fn handle_button_press(
    mut events: MessageReader<ButtonPressed>,
) {
    for _ in events.read() {
        // Button was pressed!
    }
}
}

Spawning Godot Scenes

#![allow(unused)]
fn main() {
use bevy::app::{App, Plugin, Startup, Update};
use bevy::asset::{AssetServer, Handle};
use bevy::prelude::{Commands, Component, Res, Resource, Single, Transform, With};
use godot_bevy::bridge::GodotNodeHandle;
use godot_bevy::prelude::{GodotResource, GodotScene};

struct EnemyPlugin;

impl Plugin for EnemyPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(Startup, load_assets);
        app.add_systems(Update, spawn_enemy);
    }
}

#[derive(Resource, Debug)]
struct EnemyScene(Handle<GodotResource>);

#[derive(Component, Debug)]
struct Enemy {
    health: i32,
}

#[derive(Component, Debug)]
struct EnemySpawner;

fn load_assets(mut commands: Commands, server: Res<AssetServer>) {
    let handle: Handle<GodotResource> = server.load("scenes/enemy.tscn");
    commands.insert_resource(EnemyScene(handle));
}

fn spawn_enemy(
    mut commands: Commands,
    enemy_scene: Res<EnemyScene>,
    enemy_spawner: Single<&GodotNodeHandle, With<EnemySpawner>>,
) {
    commands.spawn((
        GodotScene::from_handle(enemy_scene.0.clone())
            .with_parent(enemy_spawner.into_inner().id()),
        Enemy { health: 100 },
        Transform::default(),
    ));
}
}

Next Steps

Now that you understand the basic concepts:

  • Try the examples
  • Read about specific systems in detail
  • Start building your game!

Remember: godot-bevy is about using the right tool for the right job. Embrace both Godot and Bevy's strengths!

Plugin System

godot-bevy follows Bevy's philosophy of opt-in plugins, giving you granular control over which features are included in your build. This results in smaller binaries, better performance, and clearer dependencies.

Default Behavior

By default, GodotPlugin (automatically included by the #[bevy_app] macro) only provides minimal core functionality through GodotCorePlugins:

  • Scene tree management (automatic entity mirroring)
  • Asset loading system
  • Basic Bevy setup

All other features must be explicitly added as plugins.

Plugin Groups

  • GodotCorePlugins: Minimal required functionality

    • Automatically included by #[bevy_app] macro via GodotPlugin
    • Includes:
      • GodotBaseCorePlugin: Bevy MinimalPlugins, logging, diagnostics, schedules
      • GodotSceneTreePlugin: Scene tree entity mirroring and management
  • GodotDefaultPlugins: Contains all plugins typically necessary for building a game

    • Includes:
      • GodotAssetsPlugin: Godot resource loading through Bevy's asset system
      • GodotTransformSyncPlugin: Transform synchronization
      • GodotCollisionsPlugin: Collision detection
      • BevyInputBridgePlugin: Bevy input API support
      • GodotAudioPlugin: Audio system
      • GodotPackedScenePlugin: Runtime scene spawning
      • GodotBevyLogPlugin: Unify/improve bevy and godot logging such that info!, debug!, etc log messages are visible in the Godot Editor

Typed signals are opt-in per message type using GodotSignalsPlugin::<T>.

Available Plugins

Core Infrastructure (Included by Default)

  • GodotBaseCorePlugin: Foundation setup

    • Bevy MinimalPlugins (without ScheduleRunnerPlugin)
    • Asset system with Godot resource reader
    • Logging and diagnostics
    • Physics update schedule
    • Main thread marker resource
  • GodotSceneTreePlugin: Scene tree management

    • Automatic entity creation for scene nodes
    • Scene tree change monitoring
    • Transform component addition (configurable)
    • AutoSync bundle registration
    • Groups component for Godot groups
    • NodeEntityIndex resource for O(1) lookup from Godot InstanceId to Bevy Entity

Additional Plugins

  • GodotAssetsPlugin: Asset loading

    • Load Godot resources through Bevy's AssetServer
    • Supports .tscn, .tres, textures, sounds, etc.
    • Development and export path handling
  • GodotTransformSyncPlugin: Transform synchronization

    • Configure sync mode: Disabled, OneWay (default), or TwoWay
    • Synchronizes Bevy Transform components with Godot node transforms
    • Required for moving/positioning nodes from Bevy
  • GodotCollisionsPlugin: Collision detection

    • Monitors Area2D/3D and RigidBody2D/3D collision signals
    • Provides Collisions system param for querying collision state
    • Provides CollisionStarted / CollisionEnded events (messages + observers)
  • GodotSignalsPlugin<T>: Typed signal bridge

    • Add one plugin per message type you want to emit
    • Use GodotSignals<T> to connect signals
    • Essential for UI interactions (button clicks, etc.)
  • GodotInputEventPlugin: Raw input events

    • Provides Godot input as Bevy events
    • Keyboard, mouse, touch, gamepad, and action events
    • Lower-level alternative to BevyInputBridgePlugin
  • BevyInputBridgePlugin: Bevy input API

    • Use Bevy's standard ButtonInput<KeyCode>, mouse events, etc.
    • Automatically includes GodotInputEventPlugin
    • Higher-level, more ergonomic than raw events
  • GodotAudioPlugin: Audio system

    • Channel-based audio API
    • Spatial audio support
    • Audio tweening and easing
    • Integrates with Godot's audio engine
  • GodotPackedScenePlugin: Scene spawning

    • Spawn/instantiate scenes at runtime
    • Support for both asset handles and paths
    • Automatic transform application
  • GodotBevyLogPlugin: Improved logging by default

    • Log message components are color-coded for readability by default. Color coding can be disabled entirely. NOTE: There is a performance penalty for color-coding, so if your application is very performance sensitive, consider disabling this feature
    • Log messages are prefixed with a short timestamp, e.g., 12:00:36.196. Timestamps can be customized or entirely disabled
    • Log messages are prefixed with a short log level, e.g., T for TRACE, D for DEBUG, I for INFO, W for WARN, E for ERROR
    • Log messages are suffixed with a shortened path and line number location, e.g., @ loading_state/systems.rs:186
    • Log level filtering is INFO and higher severity by default, this can be customized directly in your code or set at runtime using RUST_LOG, e.g., RUST_LOG=trace cargo run

Usage Examples

Minimal Setup (Default)

The #[bevy_app] macro automatically provides core functionality:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    // GodotCorePlugins is already added
    // You have scene tree, assets, and basic setup
    app.add_systems(Update, my_game_system);
}
}

Adding Specific Features

Add only the plugins you need:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.add_plugins(GodotTransformSyncPlugin::default())
        .add_plugins(GodotAudioPlugin)
        .add_plugins(BevyInputBridgePlugin);

    app.add_systems(Update, my_game_systems);
}
}

Everything Enabled

For all features or easy migration from older versions:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.add_plugins(GodotDefaultPlugins);
    app.add_systems(Update, my_game_systems);
}
}

Game-Specific Configurations

Pure ECS Game:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.add_plugins(GodotTransformSyncPlugin::default())
        .add_plugins(GodotAudioPlugin)
        .add_plugins(BevyInputBridgePlugin);
}
}

Physics Platformer:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.add_plugins(GodotTransformSyncPlugin {
            sync_mode: TransformSyncMode::Disabled,  // Use Godot physics
            ..Default::default()
        })
        .add_plugins(GodotCollisionsPlugin)
        .add_plugins(GodotSignalsPlugin::<UiSignal>::default())
        .add_plugins(GodotAudioPlugin);
}
}

UI-Heavy Game:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.add_plugins(GodotSignalsPlugin::<UiSignal>::default())
        .add_plugins(BevyInputBridgePlugin)
        .add_plugins(GodotAudioPlugin);
}
}

Plugin Configuration

Transform Sync Modes

#![allow(unused)]
fn main() {
// Default: One-way sync (Bevy → Godot)
app.add_plugins(GodotTransformSyncPlugin::default());

// Two-way sync (Bevy ↔ Godot)
app.add_plugins(GodotTransformSyncPlugin {
    sync_mode: TransformSyncMode::TwoWay,
    ..Default::default()
});

// Disabled (use Godot physics directly)
app.add_plugins(GodotTransformSyncPlugin {
    sync_mode: TransformSyncMode::Disabled,
    ..Default::default()
});
}

Scene Tree Configuration

#![allow(unused)]
fn main() {
// Configure transform component creation
app.add_plugins(GodotSceneTreePlugin::default());
}

Note: This is already included in GodotCorePlugins, so you'd need to disable the default GodotPlugin and build your own plugin setup to customize this.

Plugin Dependencies

Some plugins automatically include their dependencies:

  • BevyInputBridgePlugin → includes GodotInputEventPlugin
  • GodotPlugin → includes GodotCorePlugins

Choosing the Right Plugins

Start with GodotDefaultPlugins. It bundles everything most games need, so the first tutorial and Query<&mut Transform> just work rather than silently matching nothing. Once your game runs, strip the plugins you don't use for smaller binaries and fewer systems -- each one maps to a single feature:

  • Load Godot resources through Bevy's asset systemGodotAssetsPlugin
  • Move/position nodes from BevyGodotTransformSyncPlugin
  • Play sounds and musicGodotAudioPlugin
  • Respond to UI signalsGodotSignalsPlugin::<YourMessage>
  • Detect collisionsGodotCollisionsPlugin
  • Handle inputBevyInputBridgePlugin or GodotInputEventPlugin
  • Spawn scenes at runtimeGodotPackedScenePlugin

In dev builds, godot-bevy prints the active plugin table to Godot's output panel at startup. If a feature silently is not working, check there first -- a missing plugin shows up as off.

Benefits

Smaller Binaries

Only compile the features you actually use.

Better Performance

Skip unused systems and resources.

Clear Dependencies

Your plugin list shows exactly what features you're using.

Future-Proof

New optional features can be added without breaking existing code.

Migration Note

If upgrading from an older version where all features were included by default, simply add:

#![allow(unused)]
fn main() {
app.add_plugins(GodotDefaultPlugins);
}

This restores the old behavior with all features enabled.

Examples

Many additional godot-bevy examples are available in the examples directory. Examples are set up as executable binaries. An example can then be executed using the following cargo command line in the root of the godot-bevy repository:

cargo build -p platformer-2d-example
cargo run --bin platformer_2d_example

The following additional examples are currently available if you want to check them out:

ExampleDescription
Dodge the CreepsPorted example from Godot's tutorial on making a 2D game.
Platformer 2DA more complete example showing how to tag Godot nodes for an editor heavy.
Simple Node2D MovementA minimal example with basic movement.
Timing TestInternal test to measure frames.

Scene Tree

Scene Tree Initialization and Timing

The godot-bevy library automatically parses the Godot scene tree and creates corresponding Bevy entities before your game logic runs. This means you can safely query for scene entities in your Startup systems:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.add_systems(Startup, find_player);
}

fn find_player(query: Query<&Player>) {
    // Your player entity will be here! ✨
    for _player in &query {
        println!("Found the player!");
    }
}
}

How It Works

The scene tree initialization happens in the PreStartup schedule, ensuring entities are ready before any Startup systems run. This process runs two chained systems:

  1. connect_scene_tree - Sets up event listeners for runtime scene changes (nodes being added, removed, or renamed)
  2. initialize_scene_tree - Traverses the entire Godot scene tree and creates Bevy entities with components like GodotNodeHandle, Name, transforms, and more

Both systems run in sequence during PreStartup, and both complete before your Startup systems run. This means you can safely query for Godot scene entities in Startup!

Runtime Scene Updates

After the initial parse, the library continues to listen for scene tree changes during runtime. This is handled by two systems that run in the First schedule:

  • write_scene_tree_messages - Receives events from Godot (via an mpsc channel) and writes them to Bevy's event system
  • read_scene_tree_messages - Processes those events to create/update/remove entities

This separation allows other systems to also react to SceneTreeEvents if needed.

Detaching a node with remove_child() preserves the node and its descendants. Once removal messages are processed, ordinary mirror entities are despawned. ProtectedNodeEntity entities keep their gameplay components but lose their Godot handles, index entries, and scene-tree relationships. The caller must eventually reattach or free detached nodes. Reattaching after cleanup creates fresh entities; it does not restore their former ECS state or reconnect protected survivors. To reuse a protected entity, attach its GodotNodeHandle before re-entry. A reparent completed within the mirrored tree before removal processing preserves the entity and its state. Explicit ECS despawn and explicit GodotNodeHandle removal still queue the node for deletion, including while detachment awaits processing.

What Components Are Available?

When the scene tree is parsed, each Godot node becomes a Bevy entity with these components:

  • GodotNodeHandle - Reference to the Godot node
  • Name - The node's name from Godot
  • Groups - The node's group memberships
  • Node type markers - Components like ButtonMarker, Sprite2DMarker, etc.
  • Custom components - Components from #[derive(GodotNode)] or #[derive(BevyComponents)] are automatically added

For collision detection, use the Collisions system param and CollisionStarted/CollisionEnded events (requires GodotCollisionsPlugin).

Custom Node Component Timing

If you've defined custom Godot node types with GodotNode or BevyComponents, their components are added immediately during scene tree processing. This happens:

  • During PreStartup for nodes that exist when the scene is first loaded
  • During First for nodes added dynamically at runtime

This means custom components are available in Startup systems for initial scene nodes, and immediately available for dynamically added nodes.

#![allow(unused)]
fn main() {
#[derive(Component, GodotNode, Default)]
#[gdbevy(base = Node2D, class_name = Player2D)]
#[gdbevy(require(Health), require(Velocity))]
pub struct Player;

// This will work in Startup - the Health and Velocity components
// are automatically added during PreStartup for existing nodes
fn setup_player(mut query: Query<(Entity, &Health, &Velocity)>) {
    for (entity, health, velocity) in &mut query {
        // Player components are guaranteed to be here!
    }
}
}

Best Practices

  1. Use Startup for initialization - Scene entities are guaranteed to be ready
  2. Use Update for gameplay logic - This is where most of your game code should live
  3. Custom PreStartup systems - If you add systems to PreStartup, be aware they run before scene parsing unless you explicitly order them with .after()

Understanding the Event Flow

Here's what happens when a node is added to the scene tree during runtime:

  1. Godot emits a node_added signal
  2. The SceneTreeWatcher (on the Godot side) receives the signal
  3. It sends a SceneTreeEvent through an mpsc channel
  4. write_scene_tree_messages (in First schedule) reads from the channel and writes to Bevy's event system
  5. read_scene_tree_messages (also in First schedule) processes the event and creates/updates entities

This architecture allows for flexible event handling while maintaining a clean separation between Godot and Bevy.

Querying with Node Type Markers

When godot-bevy discovers nodes in your Godot scene tree, it automatically creates ECS entities with GodotNodeHandle components to represent them. To enable efficient, type-safe querying, the library also adds marker components that indicate what type of Godot node each entity represents.

Overview

Every entity that represents a Godot node gets marker components automatically:

#![allow(unused)]
fn main() {
use godot_bevy::prelude::*;

// Query all Sprite2D entities - no runtime type checking needed!
fn update_sprites(sprites: Query<&GodotNodeHandle, With<Sprite2DMarker>>, mut godot: GodotAccess) {
    for handle in sprites.iter() {
        // The marker guarantees the type; in a per-frame system prefer `.try_get()`
        // and skip on `None` -- the node may have been freed this frame.
        let sprite = godot.get::<Sprite2D>(*handle);
        // Work with the sprite...
    }
}
}

Available Marker Components

Base Node Types

  • NodeMarker - All nodes (every entity gets this)
  • Node2DMarker - All 2D nodes
  • Node3DMarker - All 3D nodes
  • ControlMarker - UI control nodes
  • CanvasItemMarker - Canvas items

Visual Nodes

  • Sprite2DMarker / Sprite3DMarker
  • AnimatedSprite2DMarker / AnimatedSprite3DMarker
  • MeshInstance2DMarker / MeshInstance3DMarker

Physics Bodies

  • RigidBody2DMarker / RigidBody3DMarker
  • CharacterBody2DMarker / CharacterBody3DMarker
  • StaticBody2DMarker / StaticBody3DMarker

Areas and Collision

  • Area2DMarker / Area3DMarker
  • CollisionShape2DMarker / CollisionShape3DMarker
  • CollisionPolygon2DMarker / CollisionPolygon3DMarker

Audio Players

  • AudioStreamPlayerMarker
  • AudioStreamPlayer2DMarker
  • AudioStreamPlayer3DMarker

UI Elements

  • LabelMarker
  • ButtonMarker
  • LineEditMarker
  • TextEditMarker
  • PanelMarker

Cameras and Lighting

  • Camera2DMarker / Camera3DMarker
  • DirectionalLight3DMarker
  • SpotLight3DMarker

Animation and Timing

  • AnimationPlayerMarker
  • AnimationTreeMarker
  • TimerMarker

Path Nodes

  • Path2DMarker / Path3DMarker
  • PathFollow2DMarker / PathFollow3DMarker

Hierarchical Markers

Node type markers follow Godot's inheritance hierarchy. For example, a CharacterBody2D entity will have:

  • NodeMarker (all nodes inherit from Node)
  • Node2DMarker (CharacterBody2D inherits from Node2D)
  • CharacterBody2DMarker (the specific type)

This lets you query at any level of specificity:

#![allow(unused)]
fn main() {
// Query ALL nodes
fn system1(nodes: Query<&GodotNodeHandle, With<NodeMarker>>) { /* ... */ }

// Query all 2D nodes  
fn system2(nodes_2d: Query<&GodotNodeHandle, With<Node2DMarker>>) { /* ... */ }

// Query only CharacterBody2D nodes
fn system3(characters: Query<&GodotNodeHandle, With<CharacterBody2DMarker>>) { /* ... */ }
}

Advanced Query Patterns

Combining Markers

#![allow(unused)]
fn main() {
// Entities that have BOTH a Sprite2D AND a RigidBody2D
fn physics_sprites(
    query: Query<&GodotNodeHandle, (With<Sprite2DMarker>, With<RigidBody2DMarker>)>,
    mut godot: GodotAccess,
) {
    for handle in query.iter() {
        let sprite = godot.get::<Sprite2D>(*handle);
        let body = godot.get::<RigidBody2D>(*handle);
        // Work with both components...
    }
}
}

Excluding Node Types

#![allow(unused)]
fn main() {
// All sprites EXCEPT character bodies (e.g., environmental sprites)
fn environment_sprites(
    query: Query<&GodotNodeHandle, (With<Sprite2DMarker>, Without<CharacterBody2DMarker>)>,
    mut godot: GodotAccess,
) {
    for handle in query.iter() {
        // These are sprites but not character bodies
        let sprite = godot.get::<Sprite2D>(*handle);
        // Work with environmental sprites...
    }
}
}

Multiple Specific Types

#![allow(unused)]
fn main() {
// Handle different audio player types efficiently
fn update_audio_system(
    players_1d: Query<&GodotNodeHandle, With<AudioStreamPlayerMarker>>,
    players_2d: Query<&GodotNodeHandle, With<AudioStreamPlayer2DMarker>>,
    players_3d: Query<&GodotNodeHandle, With<AudioStreamPlayer3DMarker>>,
    mut godot: GodotAccess,
) {
    // Process each type separately - no runtime type checking!
    for handle in players_1d.iter() {
        let player = godot.get::<AudioStreamPlayer>(*handle);
        // Handle 1D audio...
    }
    
    for handle in players_2d.iter() {
        let player = godot.get::<AudioStreamPlayer2D>(*handle);
        // Handle 2D spatial audio...
    }
    
    for handle in players_3d.iter() {
        let player = godot.get::<AudioStreamPlayer3D>(*handle);
        // Handle 3D spatial audio...
    }
}
}

Performance Benefits

Node type markers provide significant performance improvements:

  1. Reduced Iteration: Only process entities you care about
  2. No Runtime Type Checking: The marker guarantees the node type, so you can skip type-mismatch branches -- but it does not guarantee the node is still alive, so still try_get() and skip on None
  3. Better ECS Optimization: Bevy can optimize queries with markers
  4. Cache Efficiency: Process similar entities together

Automatic Application

You don't need to add marker components manually. The library automatically:

  1. Detects the Godot node type during scene tree traversal
  2. Adds the appropriate marker component(s) to the entity
  3. Includes all parent type markers in the inheritance hierarchy
  4. Ensures every entity gets the base NodeMarker

This happens transparently when nodes are discovered in your scene tree, making the markers immediately available for your systems to use.

Best Practices

  • Use specific markers when you know the exact node type: With<Sprite2DMarker>
  • Use hierarchy markers for broader categories: With<Node2DMarker> for all 2D nodes
  • Combine markers to find entities with multiple components
  • Prefer .try_get() and skip on None in systems that run every frame: a node handle can outlive its Godot node by up to a frame -- GDScript (or Bevy) can free() a node while its entity still exists, until the scene-tree removal drain despawns it. None means "freed, the drain will catch up," not an error. Reserve .get() for one-shot code where you just created or resolved the node and a missing node is a genuine bug. godot-bevy's own transform sync uses try_get + skip for exactly this reason.

For migration information from pre-0.7.0 versions, see the Migration Guide.

Excluding Nodes

By default every Godot node becomes a Bevy entity. To keep a node and its descendants out of the ECS, give it the _bevy_exclude metadata. The GDScript watcher filters excluded additions before they reach Rust. Removal messages still reach Bevy so entities can be cleaned up when mirrored nodes move into excluded subtrees.

The typical use is a UI or editor-only subtree you never query from ECS. Set the metadata in the editor (select the node, Inspector > Add Metadata, name it _bevy_exclude, type bool, value true), or from GDScript:

$UI.set_meta("_bevy_exclude", true)

You can also set it from Rust on a node you have a handle to:

#![allow(unused)]
fn main() {
node.set_meta("_bevy_exclude", &true.to_variant());
}

Set it before the node enters the tree -- exclusion is evaluated when the node is added.

Exclusion is subtree-wide

Excluding a node also excludes all of its descendants. Mark one UI or editor-only root and the whole branch stays out of the ECS; you don't tag each child.

What you give up

An excluded node has no entity, so nothing that flows through the ECS applies to it:

  • Collision events. Excluded bodies produce no collision events, and a still-mirrored node that collides with an excluded one gets no entity for its excluded partner.
  • Autosync components. #[derive(GodotNode)] / BevyComponents components and any registered bundles do not materialize for excluded nodes.
  • Transforms, markers, groups -- none of the usual decoration happens.

If you want a node in the ECS but want to skip only its transform reads, don't exclude it -- use DisableGodotTransformRead instead.

Timing

Exclusion is decided when the node is added. Adding or removing the metadata at runtime does not retroactively mirror or unmirror a node. Reparenting a mirrored node into an excluded subtree removes its ordinary mirror entity while preserving the Godot node and its descendants. ProtectedNodeEntity entities keep their gameplay components but lose their Godot handles, index entries, and scene-tree relationships. The caller remains responsible for the surviving nodes. Moving them back into the mirrored tree after cleanup creates fresh entities unless handles were explicitly reassociated with protected survivors before re-entry.

Custom Nodes

This section explains how to work with custom Godot nodes in godot-bevy and the important distinction between automatic markers for built-in Godot types versus custom nodes.

AttachableComponent uses a leaf child node to configure its parent's Bevy entity. The child is consumed after conversion. Choose BevyComponents when the authored node should remain in the scene and receive components on its own entity.

Summary

  • Built-in Godot types get automatic markers (e.g., Sprite2DMarker)
  • Custom nodes do NOT get automatic markers for their type, but DO inherit base class markers
  • Use GodotNode (component-first) to define a Bevy component that generates the Godot class
  • Use BevyComponents (Godot-first) to attach Bevy components to a class you write yourself
  • Prefer semantic components over generic markers
  • Combine base class markers with custom components for powerful queries

This design gives you full control over your ECS architecture while maintaining performance and clarity.

Automatic Markers

godot-bevy automatically creates marker components for all built-in Godot node types:

#![allow(unused)]
fn main() {
// These markers are created automatically:
// Sprite2DMarker, CharacterBody2DMarker, Area2DMarker, etc.

fn update_sprites(sprites: Query<&GodotNodeHandle, With<Sprite2DMarker>>) {
    // Works automatically for any Sprite2D in your scene
}
}

Custom Godot Nodes

Custom nodes defined in Rust or GDScript do NOT receive automatic markers for their custom type, though they DO inherit markers from their base class (e.g., Node2DMarker if they extend Node2D). Use GodotNode or BevyComponents for explicit component control on custom nodes.

#![allow(unused)]
fn main() {
// ❌ PlayerMarker is NOT automatically created
fn update_players(players: Query<&GodotNodeHandle, With<PlayerMarker>>) {
    // PlayerMarker doesn't exist unless you create it
}

// ✅ But you CAN use the base class marker
fn update_player_base(players: Query<&GodotNodeHandle, With<CharacterBody2DMarker>>) {
    // This works but matches ALL CharacterBody2D nodes, not just Players
}

// ✅ Use GodotNode to define explicit components (component-first)
#[derive(Component, GodotNode, Default)]
#[gdbevy(base = CharacterBody2D, class_name = Player2D)]
#[gdbevy(require(Health), require(Speed))]
pub struct Player;

// Now query with your semantic component
fn update_players(players: Query<&GodotNodeHandle, With<Player>>) {
    // Matches only Player2D nodes
}
}

Attachable Components

AttachableComponent turns an editor-authored child node into a component on its parent's Bevy entity. The child is a one-shot carrier: successful attachment queues it for deletion. Its node and path disappear. It never gets a Bevy entity.

Use this derive for configuration authored as child nodes. Use BevyComponents when the authored node should survive and receive components on its own entity. Neither derive provides live Inspector synchronization.

Define a carrier

GodotCorePlugins registers carriers automatically. Derive GodotClass and AttachableComponent, name the target component, and implement the conversion. The derive is available through godot_bevy::prelude::*.

#![allow(unused)]
fn main() {
use bevy::prelude::Component;
use godot::prelude::*;
use godot_bevy::prelude::*;

#[derive(Component)]
struct Movement {
    max_speed: f32,
}

#[derive(AttachableComponent, GodotClass)]
#[class(init, base=Node)]
#[gdbevy(target = Movement)]
struct MovementComponent {
    #[export]
    max_speed: f32,
}

impl From<&MovementComponent> for Movement {
    fn from(value: &MovementComponent) -> Self {
        Self {
            max_speed: value.max_speed,
        }
    }
}
}

In Godot, add MovementComponent as a child of the node to configure and set max_speed in the Inspector. Keep the carrier empty. Ordinary and internal children both cause rejection before the conversion runs. Nested carriers are also rejected. Rejection preserves the authored subtree; ordinary descendants may mirror without a Bevy relationship to the unmirrored carrier.

Placement and lifetime

Attachment runs after ordinary scene-tree messages, so a parent added in the same batch is available. The carrier's live parent is the destination, even if it was reparented before the messages were processed. Duplicate adds within a drain convert once. A carrier already queued for deletion does not convert again.

The parent must be a live, mirrored node. It cannot be the root viewport (/root), another carrier, excluded, or queued for deletion. A carrier directly under the mirrored current scene root is valid. Exclusion applies to the whole subtree and normally prevents add messages from reaching the mirror.

An invalid placement leaves the carrier alive and unmirrored. The warning names its class, path, parent, and the rejection reason. A missing parent mapping is also rejected. Fix the placement and remove and re-add the carrier to trigger a new attempt. Later empty drains do not retry. Freed, detached, or queued carriers do not convert.

Startup scanning and GodotScene instantiation use the same rules. Detaching and re-adding a surviving scene node cannot restore consumed carriers or replay their configuration onto its new entity. Instantiate the saved PackedScene again for fresh carriers.

Conversion references

From may copy values and retain owned resources. Handles may refer to nodes that survive independently, such as the parent or a sibling outside the carrier. Do not capture the carrier, its descendants, other carriers, or paths that pass through consumed nodes. Do not mutate the tree during conversion.

Captured nodes can still be freed later. Resolve their handles with GodotAccess::try_get when their lifetime is uncertain.

Property Mapping — #[gdbevy(...)] Grammar

This page was previously "Property Mapping with BevyBundle". The BevyBundle macro is gone; the unified #[gdbevy(...)] attribute replaces it on both bridging derives.

Both GodotNode (component-first) and BevyComponents (Godot-first) accept #[gdbevy(...)] annotations. The keys available depend on which derive you are using.

Component-first (GodotNode)

Companion component — newtype

Generate one exported property and wrap its value in a newtype component:

#![allow(unused)]
fn main() {
#[derive(Component, GodotNode, Default)]
#[gdbevy(base = CharacterBody2D, class_name = Player2D)]
#[gdbevy(require(speed: Speed, as = f32, default = 250.0))]
pub struct Player;
}
  • speed becomes a #[export] speed: f32 on the generated Player2D class (default 250.0).
  • When the node enters the tree, Speed(speed_value) is inserted.
  • as = f32 is required here — the macro cannot see Speed's inner type.

Companion component — struct

Generate multiple exported properties for a multi-field component:

#![allow(unused)]
fn main() {
#[derive(Component, GodotNode, Default)]
#[gdbevy(base = CharacterBody2D, class_name = Enemy2D)]
#[gdbevy(require(stats: Stats {
    health(as = f32, default = 100.0),
    mana(as = f32, default = 50.0),
}))]
pub struct Enemy;
}

Each inner field(as = T, …) follows the same as/default/with grammar as the newtype form. The name before : (e.g. stats) is required by the grammar but ignored — the generated export properties use the inner field names (health, mana).

Inspector metadata for generated exports

Metadata can be placed on a named or tuple primary field with #[gdbevy(export, ...)], on require(prop: Comp, ...), or on each field inside require(group: Comp { field(as = T, ...), ... }). Use one attribute per primary field.

Export types must implement gdext's Export trait. Rust String does not, so export a GString and convert it when constructing the component:

#![allow(unused)]
fn main() {
use bevy::prelude::Component;
use godot::prelude::GString;
use godot_bevy::prelude::GodotNode;

#[derive(Component)]
pub struct WeaponKind(pub String);

#[derive(Component, GodotNode, Default)]
#[gdbevy(class_name = WeaponNode)]
#[gdbevy(require(
    kind: WeaponKind,
    as = GString,
    with = from_godot_string,
    default = GString::from("Hands"),
    description = "Weapon selected by the designer",
    hint = ENUM,
    hint_string = "Hands,Knife"
))]
pub struct Weapon;

fn from_godot_string(value: GString) -> String {
    value.to_string()
}
}

description takes a string literal. Primary field /// docs are copied before an explicit description. hint names a bare Godot PropertyHint variant, and hint_string takes an expression. A hint string requires a hint.

Enable the register-docs feature on your godot-bevy dependency to register property descriptions in Godot's editor:

godot-bevy = { version = "0.12", features = ["register-docs"] }

This feature is off by default and requires Godot API 4.3 or later. With it off, hints still work and descriptions remain Rust docs. API 4.2 consumers must leave it disabled.

Marker companion

Insert a component via Default with no exported property:

#![allow(unused)]
fn main() {
#[gdbevy(require(Stunned))]
}

Primary field with conversion

Fields on the component struct itself can declare a type conversion:

#![allow(unused)]
fn main() {
#[derive(Component, GodotNode, Default)]
#[gdbevy(base = Node2D, class_name = Slider2D)]
pub struct Slider {
    /// Editor shows 0–100; component gets 0.0–1.0
    #[gdbevy(export, as = f32, with = percentage_to_fraction)]
    pub value: f32,
}

fn percentage_to_fraction(v: f32) -> f32 { v / 100.0 }
}

as = T is optional when the field type already implements gdext's Export trait; add it only when the export type differs from the Rust field type.

Tuple fields

Tuple structs are supported for component-first nodes. Exported fields are named value0, value1, and so on in the generated Godot class.

#![allow(unused)]
fn main() {
#[derive(Component, GodotNode, Default)]
pub struct Velocity(
    #[gdbevy(export)] f32,
    #[gdbevy(export)] f32,
);
}

All component-first keys

PlacementKeyRequired?Meaning
structbase = GodotBaseno (default: Node)Godot class to extend
structclass_name = Nameno (default: <Struct>BevyComponent)Generated class name
structrequire(…)noCompanion component (see forms above)
fieldexportyesMarks the field as a generated Godot export
fieldas = TnoGodot export type
fielddefault = exprnoEditor default (via #[init(val = …)]); a pure-Bevy spawn(T) uses the struct's own Default — make them agree if you rely on spawn(T).
fieldwith = fnnoGodot-value → field-value conversion
require(prop: Comp, …)as = TyesExport type for the generated property
require(prop: Comp, …)default = exprnoExport default
require(prop: Comp, …)with = fnnoConversion before constructing the component
generated primary or companion fielddescription = "..."noProperty docs, registered with register-docs
generated primary or companion fieldhint = NAMEnoBare Godot PropertyHint variant
generated primary or companion fieldhint_string = exprnoHint string, requires hint

Godot-first (BevyComponents)

Field binding

Map a single #[export] property to a newtype component:

#![allow(unused)]
fn main() {
#[derive(GodotClass, BevyComponents)]
#[class(base = Node2D, init)]
pub struct EnemyNode {
    base: Base<Node2D>,

    #[gdbevy(component = Health)]
    #[export]
    max_health: f32,

    #[gdbevy(component = Speed, with = to_speed)]
    #[export]
    #[init(val = 100.0)]
    speed: f32,
}
}

component = Comp is required. with = fn is optional. as and default are not allowed — gdext's #[init(val = …)] owns defaults on this path.

Marker at the struct level

#![allow(unused)]
fn main() {
#[derive(GodotClass, BevyComponents)]
#[class(base = CharacterBody2D, init)]
#[gdbevy(require(Player))]
pub struct PlayerNode {
    base: Base<CharacterBody2D>,
    // ...
}
}

N→1 binding

Build a multi-field component from several existing #[export] properties:

#![allow(unused)]
fn main() {
#[derive(GodotClass, BevyComponents)]
#[class(base = CharacterBody2D, init)]
#[gdbevy(require(Stats { health: max_health, mana: max_mana }))]
pub struct PlayerNode {
    base: Base<CharacterBody2D>,
    #[export] max_health: f32,
    #[export] max_mana: f32,
}
}

All Godot-first keys

PlacementKeyRequired?Meaning
structrequire(Marker)noInsert Marker::default()
structrequire(Comp { bevy_field: godot_field, … })noBuild struct component from existing exports
fieldcomponent = CompyesBevy component type (Comp(value))
fieldwith = fnnoGodot-value → component-value conversion

Godot-first classes own their gdext declaration, so use gdext's native #[var(hint = ..., hint_string = ...)] and Rust documentation attributes for Inspector metadata on those fields.

Native property descriptions also require godot-bevy/register-docs and Godot API 4.3 or later. Keep metadata on the native attributes, outside gdbevy.

Reserved keys

into and sync are reserved for the upcoming component-sync feature and will produce a compile error if used. Use only the keys documented above.

Nodes from Components

godot-bevy bridges Godot nodes and Bevy entities through two derive macros that share one #[gdbevy(...)] attribute grammar:

  • GodotNode (component-first) — you write a Bevy Component; the macro generates the Godot class.
  • BevyComponents (Godot-first) — you write the GodotClass yourself; the macro wires its #[export] fields into Bevy components.

Pick whichever fits your workflow. Both produce the same result at runtime: a Godot scene node whose editor-set values become Bevy components on the entity.

Component-first: GodotNode

Derive Component and GodotNode on a plain Rust struct. The macro generates a Godot class with #[export] properties for each annotated field, plus an autosync registration so that components are inserted when the node enters the scene tree.

Minimal marker node

#![allow(unused)]
fn main() {
#[derive(Component, GodotNode, Default, Debug, Clone)]
#[gdbevy(base = Area2D, class_name = Gem2D)]
pub struct Gem;
}

This generates a Gem2D Godot class (extending Area2D). When Gem2D enters the scene tree, a Gem component is inserted on its entity. No exported properties.

Primary fields with defaults

Fields on the component struct can be exported to the editor:

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

export is required and marks the field as a generated Godot export. default = expr sets the editor default (via #[init(val = …)]). The field's Rust type is used as the Godot export type unless you add as = T.

Available keys on a field-level #[gdbevy(...)]:

KeyMeaning
export(required) Marks the field as a generated Godot export.
as = TGodot export type (defaults to the field's Rust type).
default = exprEditor default value (via #[init(val = …)]). A pure-Bevy spawn(T) uses the struct's own Default — make them agree if you rely on spawn(T).
with = fnConverts the Godot value before assigning to the field.

Companion components

Use require(...) at the struct level to generate exported properties that feed separate companion components. This is useful when a single node should spawn multiple components — without needing a separate bundle type.

#![allow(unused)]
fn main() {
#[derive(Component, GodotNode, Default, Debug, Clone, Reflect)]
#[reflect(Component)]
#[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;
}

The Player2D Godot class gains three #[export] properties (speed, jump_velocity, gravity). When the node enters the tree, Player, Speed(…), JumpVelocity(…), and Gravity(…) are all inserted on the entity.

require forms:

FormMeaning
require(Marker)Insert Marker::default() — no export property.
require(prop: Comp, as = T, default = expr)Generate one export property; build Comp(value). as = T is required.
require(prop: Comp { field(as = T, default = expr), … })Generate multiple properties; build a struct Comp { field: value, … }. The name before : is required by the grammar but ignored — the generated export properties use the inner field names.

with = fn is available on all non-marker forms and converts the Godot value before it is passed to the component constructor.

Pure-Bevy spawn: because GodotNode also registers required components, commands.spawn(Player) in a test or a headless context inserts Speed, JumpVelocity, and Gravity with the declared defaults — no Godot scene needed.

Godot-first: BevyComponents

When you already own the GodotClass struct — or prefer writing gdext code yourself — derive BevyComponents instead of GodotNode. The macro emits only the Bevy side; no new Godot class is generated.

#![allow(unused)]
fn main() {
#[derive(GodotClass, BevyComponents)]
#[class(base = Node2D, init)]
#[gdbevy(require(Player))]
struct PlayerNode {
    base: Base<Node2D>,

    /// Maps the `speed` export to `Speed(to_speed(speed))`.
    #[gdbevy(component = Speed, with = to_speed)]
    #[export]
    #[init(val = 250.0)]
    speed: f32,
}
}

Field-level #[gdbevy(...)] keys on a Godot-first binding:

KeyMeaning
component = Comp(required) The Bevy component to insert — Comp(value).
with = fnConverts the Godot value before constructing the component.

as and default are not allowed on Godot-first field bindings — gdext's #[init(val = …)] owns defaults, and the field's type is already visible.

Struct-level require(...) on the Godot-first path supports markers and N→1 bindings:

FormMeaning
require(Marker)Insert Marker::default().
require(Comp { bevy_field: godot_field, … })Build Comp from existing export fields.

Which derive to use

GodotNodeBevyComponents
Who writes the Godot classMacroYou
base / class_name#[gdbevy(base = …, class_name = …)]#[class(base = …)] in gdext
Required-components (pure Bevy spawn)YesNo
Custom init / #[godot_api]NoYes — full gdext control

Use GodotNode for new nodes defined entirely in Rust. Use BevyComponents when you need custom gdext lifecycle methods, or when the node class is shared with GDScript.

Spawning Godot scenes with NodeTreeView

GodotScene is a Bevy Component that lets us attach and instantiate Godot scene files (.tscn) to Bevy entities. When we add a GodotScene to an entity, it spawns that scene in Godot's scene tree and links it to our Bevy entity, letting us combine Godot's visual editor with Bevy's ECS architecture.

When we spawn scenes, we almost always need to reach into that scene’s node tree to:

  • grab child nodes like sprites, notifiers, or UI controls
  • connect signals
  • drive animations or physics bodies

Doing this manually with raw GodotNodeHandle lookups quickly becomes repetitive and fragile.
The #[derive(NodeTreeView)] macro gives us a typed, ergonomic view of a scene’s node tree, driven by node paths.

This page explains how to:

  1. Define a NodeTreeView for a scene
  2. Spawn the scene via GodotScene
  3. Use the generated view to access nodes and connect signals

1. Spawn Godot scenes with GodotScene

To spawn a Godot scene from Bevy, insert GodotScene into our entity:

use bevy::prelude::*;
use godot_bevy::prelude::{GodotResource, GodotScene};
use bevy::state::app::StatesPlugin;
use bevy_asset_loader::asset_collection::AssetCollection;

#[derive(Debug, Default, Clone, Eq, PartialEq, Hash, States)]
enum GameState {
    #[default]
    Loading,
    InGame,
}

fn plugin(app: &mut App) {
    app.add_plugins(StatesPlugin)
        .init_state::<GameState>()
        .add_loading_state(
            LoadingState::new(GameState::Loading).continue_to_state(GameState::InGame),
        );
    app.configure_loading_state(
        LoadingStateConfig::new(GameState::Loading).load_collection::<PickupAssets>(),
    );
    app.add_message::<PickupBodyEntered>();
    app.add_systems(Update, pickup_system);
}

/// This example uses `bevy_asset_loader` to load the
/// scene file as a packed scene at startup.
#[derive(AssetCollection, Resource)]
pub struct CharacterAssets {
    #[asset(path = "scenes/character.tscn")]
    pub character_scene: Handle<GodotResource>,
}

fn spawn_character(mut commands: Commands, assets: Res<CharacterAssets>) {
    commands
        .spawn_empty()
        // Add additional Bevy components here (e.g. position, gameplay data, etc.)
        .insert(Transform::default())
        .insert(
            // Attach the Godot scene to this Bevy entity
            GodotScene::from_handle(assets.character_scene.clone())
            // Optionally, connect signals here with the
            // `with_signal_connection` builder method discussed below. 
        );
}

At this point, the Bevy entity is linked to the Godot scene instance.
Now we would like to access nodes inside that scene.

2. Access scene children with NodeTreeView

Let's assume we have a Godot scene with the following node structure:

  • Node2D (the “character”)
    • AnimatedSprite2D
    • VisibleOnScreenNotifier2D

We can describe the nodes of the scene we want to access by their path like so:

use godot_bevy::interop::GodotNodeHandle;
use godot_bevy::prelude::NodeTreeView;

#[derive(NodeTreeView)]
pub struct CharacterNodes {
    #[node("AnimatedSprite2D")]
    pub animated_sprite: GodotNodeHandle,

    #[node("VisibleOnScreenNotifier2D")]
    pub visibility_notifier: GodotNodeHandle,
}

The NodeTreeView field types can be GodotNodeHandle or Option<GodotNodeHandle>.

The #[node("<node_path>")] attribute supports wildcards (*). See below or the NodeTreeView docs for more details.

Then we can access the tree view in our system like this:

fn new_character_initialize(
    entities: Query<&GodotNodeHandle, Added<Character>>,
    mut godot: GodotAccess,
) {
    for handle in &entities {
        let character = godot.get::<RigidBody2D>(*handle);
        let character_nodes = CharacterNodes::from_node(character).unwrap();
    }
}

Path patterns

Node paths support simple patterns to avoid hard-coding full names:

  • /root/*/HUD/CurrentLevel - matches any single node name where * appears
  • /root/Level*/HUD/CurrentLevel - matches node names starting with "Level"
  • */HUD/CurrentLevel - matches relative to the base node

Generated path constants

For each #[node("<node_path>")] field, NodeTreeView generates a public string constant named
<UPPERCASE_FIELD_NAME>_PATH inside our struct’s impl.

Given the CharacterNodes example above, the macro generates an impl like:

impl CharacterNodes {
    pub const ANIMATED_SPRITE_PATH: &'static str = "AnimatedSprite2D";
    pub const VISIBILITY_NOTIFIER_PATH: &'static str = "VisibleOnScreenNotifier2D";
}

These constants are very convenient when we need to refer to the same path in multiple places, especially when connecting signals from a spawned scene (covered below).

3. Connect signals to scene children using GodotScene::with_signal_connection

When spawning scenes, we often want to connect signals to child nodes.

There are three useful resources when connecting signals:

  • GodotScene's with_signal_connection builder method.
  • NodeTreeView's generated path constants.
  • godot_bevy::interop::<GODOT_NODE_TYPE>Signals types which contain string constants for all signals of a given Godot node type.

Here is an example using the CharacterNodes NodeTreeView from above and the VisibleOnScreenNotifier2DSignals::SCREEN_EXITED string constant to connect the VisibleOnScreenNotifier2D's screen_exited signal to a Bevy message.

use godot_bevy::interop::VisibleOnScreenNotifier2DSignals;
use godot_bevy::prelude::GodotScene;
use bevy::ecs::entity::Entity;
use bevy::prelude::Message;

#[derive(Message, Debug, Clone, Copy)]
pub struct CharacterScreenExited {
    pub entity: Entity,
}

fn spawn_character_with_signals(mut commands: Commands, assets: Res<CharacterAssets>) {
    commands
        .spawn_empty()
        .insert(Transform::default())
        .insert(
            GodotScene::from_handle(assets.character_scene.clone())
                .with_signal_connection(
                    
                    // Use the NodeTreeView-generated path constant:
                    CharacterNodes::VISIBILITY_NOTIFIER_PATH,
                    
                    // The Godot signal we want to connect:
                    VisibleOnScreenNotifier2DSignals::SCREEN_EXITED,
                    
                    // Closure to turn a Godot signal into a Bevy message:
                    |_args, _node_handle, entity| {
                        Some(CharacterScreenExited {
                            entity: entity.expect("entity was provided"),
                        })
                    },
                ),
        );
}

Transform System Overview

The transform system is one of the most important aspects of godot-bevy, handling position, rotation, and scale synchronization between Bevy ECS and Godot nodes.

Three Approaches to Movement

godot-bevy supports three distinct approaches for handling transforms and movement:

1. ECS Transform Components

Use standard bevy Transform components with automatic syncing from ECS to Godot. This is the default approach. You update transforms in ECS, and we take care of syncing the transforms to the Godot side at the end of each frame. You can also configure bi-directional synchronization, or disable all synchronization.

#![allow(unused)]
fn main() {
use godot_bevy::prelude::*;

fn move_entity(mut query: Query<&mut Transform>) {
    for mut transform in query.iter_mut() {
        transform.translation.x += 1.0;
    }
}
}

2. Direct Godot Physics

Use GodotNodeHandle with GodotAccess to directly control Godot physics nodes. Perfect for physics-heavy games. This usually means you're calling Godot's move methods to have it handle physics for you.

#![allow(unused)]
fn main() {
fn move_character(query: Query<&GodotNodeHandle>, mut godot: GodotAccess) {
    for handle in query.iter() {
        let mut body = godot.get::<CharacterBody2D>(*handle);
        body.set_velocity(Vector2::new(100.0, 0.0));
        body.move_and_slide();
    }
}
}

3. Hybrid Approach

Allows for modifying transforms both from Godot's side and from ECS side. Useful during migration from a GDScript project to godot-bevy or when you're using Godot's physics methods but still want transforms to be updated for reading on the ECS side.

Default Behavior

By default, godot-bevy operates in one-way sync mode:

  • Writing enabled: Changes to ECS transform components update Godot nodes
  • Reading disabled: Changes to Godot nodes don't update ECS components

This is optimal for pure ECS applications where all movement logic lives in Bevy systems.

When to Use Each Approach

Use ECS Transforms When:

  • Building a pure ECS game
  • Movement logic is simple (no complex physics)
  • You want clean separation between logic and presentation
  • Performance of transform sync is acceptable

Use Direct Godot Physics When:

  • Building platformers or physics-heavy games
  • You need Godot's collision detection features
  • Using CharacterBody2D/3D or RigidBody2D/3D
  • You want zero transform sync overhead

Use Hybrid Approach When:

  • Migrating an existing Godot project to ECS
  • Some systems need ECS transforms, others need physics
  • Gradually transitioning from GDScript to Rust

Key Concepts

Transform Components

Use standard bevy Transform components. This is the default approach. You update transforms in ECS, and we take care of syncing the transforms to the Godot side at the end of each frame. You can also configure bi-directional synchronization, or disable all synchronization.

Sync Modes

The transform system supports three synchronization modes:

  1. Disabled - No syncing, no transform components created
  2. OneWay - ECS → Godot only (default)
  3. TwoWay - ECS ↔ Godot bidirectional sync

Performance Considerations

Each approach has different performance characteristics:

  • ECS Transforms: Small overhead from syncing
  • Direct Physics: Zero sync overhead
  • Hybrid: Depends on usage pattern

If transform sync feels slow in a debug build, see Debug Build Performance -- most of the gap is unoptimized dependencies rather than the sync itself.

Next Steps

Transform Sync Modes

godot-bevy provides three transform synchronization modes to fit different use cases. Understanding these modes is crucial for optimal performance and correct behavior.

Available Modes

TransformSyncMode::Disabled

No transform syncing occurs and no transform components are created.

Characteristics:

  • ✅ Zero performance overhead
  • ✅ Best for when your ECS systems rarely read/write Godot Node transforms, or you wish to explicitly control when synchronization occurs
  • ❌ Godot Transform changes aren't automatically reflected in ECS
  • ❌ ECS Transform changes aren't automatically reflected in Godot

Use when:

  • Building platformers with CharacterBody2D
  • You need maximum performance

TransformSyncMode::OneWay (Default)

Synchronizes transforms from ECS to Godot only.

Characteristics:

  • ✅ ECS components control Godot node positions
  • ✅ Good performance (minimal overhead)
  • ✅ Clean ECS architecture
  • ❌ Godot changes don't reflect in ECS

Use when:

  • Building pure ECS games
  • All movement logic is in Bevy systems
  • You don't need to read Godot transforms
  • Physics is controlled in ECS, i.e., you've disabled all Godot Physics engines and use something like Avian physics

TransformSyncMode::TwoWay

Full bidirectional synchronization between ECS and Godot.

Characteristics:

  • ✅ Changes in either system are reflected
  • ✅ Works with Godot animations
  • ✅ Supports hybrid architectures
  • ✅ Per-axis co-authorship -- Godot and Bevy can drive different axes of the same node
  • ✅ Bevy reads the latest Godot value before your systems run (read every physics step in FixedFirst; on 0-step frames, in PreUpdate)
  • ❌ Highest performance cost

Use when:

  • Migrating from GDScript to ECS
  • Using Godot's AnimationPlayer
  • Mixing ECS and GDScript logic

Co-authorship semantics

The Godot→Bevy read occurs in FixedFirst, running once per physics step before your FixedUpdate systems, so a node moved from Godot -- by GDScript, an AnimationPlayer, or physics -- between steps stays visible every step (matching the FixedLast write cadence) rather than being clobbered by a stale whole-transform write. On a render frame with no physics step the read falls back to PreUpdate, keeping idle frames covered. Either way the last read precedes the Update suffix, so your Update systems see Godot's latest value the same frame. The Bevy→Godot write runs in FixedLast and pushes only what Bevy changed, tracked against a per-entity value shadow. So a single node can be co-authored per axis:

# quad.gd -- Godot drives x
func _process(_dt): position.x = sin(t) * 100.0
#![allow(unused)]
fn main() {
// Bevy drives y; x stays whatever Godot set it to
fn move_y(mut q: Query<&mut Transform, With<Quad>>) {
    for mut t in &mut q { t.translation.y = cos(time) * 100.0; }
}
}

What this guarantees and what it doesn't:

  • Translation and scale are co-authored per component (x/y/z independently).
  • Rotation is whole -- quaternion components aren't independently meaningful, so rotation is authored by one side at a time (2D rotation is a single angle anyway).
  • If both sides change the same axis in the same frame, Bevy wins.
  • A value authored in Godot's idle phase (_process, AnimationPlayer in idle) is seen by Bevy the next frame, since Bevy's read runs in the physics phase, before Godot's idle phase within a frame -- the same one-frame relationship any Godot _physics_process reader has with an idle-phase writer.

Freshness trade: because the read occurs in FixedFirst, any prefix schedule (First, PreUpdate, StateTransition) on a frame with one or more physics steps sees last frame's synced Transform -- the fresh Godot value isn't merged until FixedFirst runs. Read this-frame's Godot value in FixedUpdate onward or in the Update suffix, not in a prefix schedule.

Opting out of reads

In TwoWay mode the Godot→Bevy read polls every mirrored Node2D/Node3D each physics step. If a specific entity should be Bevy-authoritative -- ECS owns its transform and Godot-side moves should be ignored -- opt it out of the read with DisableGodotTransformRead. The write path is unaffected, so the entity still pushes its Bevy Transform to Godot; it just stops reading Godot back.

The easiest way is by Godot group: add the node to the NO_TRANSFORM_READ_GROUP group ("godot_bevy_no_transform_read") and it is tagged at spawn.

$Player.add_to_group("godot_bevy_no_transform_read")

Or attach the marker directly from a system:

#![allow(unused)]
fn main() {
commands.entity(entity).insert(DisableGodotTransformRead);
}

Skipping the read leaves the entity's shadow stale, so Godot-side moves are silently ignored -- that is the point of one-way ownership, but it is a real behavior to keep in mind.

Configuration

Configure the sync mode in your #[bevy_app] function:

Disabled Mode

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.insert_resource(GodotTransformConfig::disabled());
    
    // Use direct physics instead
    app.add_systems(Update, physics_movement);
}
}

One-Way Mode (Default)

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    // One-way is the default, no configuration needed
    // Or explicitly:
    app.insert_resource(GodotTransformConfig::one_way());
    
    app.add_systems(Update, ecs_movement);
}
}

Two-Way Mode

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.insert_resource(GodotTransformConfig::two_way());
    
    app.add_systems(Update, hybrid_movement);
}
}

Performance Impact

Disabled Mode Performance

Transform Components: Not created
Sync Systems: Not running
Memory Usage: None
CPU Usage: None

One-Way Mode Performance

Transform Components: Created
Write Systems: Running (FixedLast schedule)
Read Systems: Not running
Memory Usage: ~48 bytes per entity
CPU Usage: O(changed entities)

Two-Way Mode Performance

Transform Components: Created
Write Systems: Running (FixedLast schedule)
Read Systems: Running (FixedFirst every step, PreUpdate 0-step fallback)
Memory Usage: ~48 bytes per entity
CPU Usage: O(all entities with transforms)

Implementation Details

System Execution Order

Write Systems (ECS → Godot)

  • Schedule: FixedLast (physics rate, once per fixed step)
  • Only processes changed transforms
  • Runs for both OneWay and TwoWay modes

Read Systems (Godot → ECS)

  • Schedule: FixedFirst (every physics step) and PreUpdate (0-step frames only)
  • Checks all transforms for external changes
  • Only runs in TwoWay mode

Change Detection

The system uses Bevy's change detection to optimize writes:

#![allow(unused)]
fn main() {
fn post_update_transforms(
    mut query: Query<
        (&Transform, &mut GodotNodeHandle),
        Or<(Added<Transform>, Changed<Transform>)>
    >
) {
    // Only processes entities with new or changed transforms
}
}

Common Patterns

Switching Modes at Runtime

While not common, you can change modes during runtime:

#![allow(unused)]
fn main() {
fn switch_to_physics_mode(
    mut commands: Commands,
) {
    commands.insert_resource(GodotTransformConfig::disabled());
}
}

Note: Existing transform components remain but stop syncing.

Checking Current Mode

#![allow(unused)]
fn main() {
fn check_sync_mode(
    config: Res<GodotTransformConfig>,
) {
    match config.sync_mode {
        TransformSyncMode::Disabled => {
            println!("Using direct physics");
        }
        TransformSyncMode::OneWay => {
            println!("ECS drives transforms");
        }
        TransformSyncMode::TwoWay => {
            println!("Bidirectional sync active");
        }
    }
}
}

Best Practices

  1. Choose mode early - Switching modes mid-project can be complex
  2. Default to OneWay - Unless you specifically need other modes
  3. Benchmark your game - Measure actual performance impact
  4. Document your choice - Help team members understand the architecture

Render interpolation

godot-bevy drives FixedMain directly from Godot's _physics_process, so Time<Fixed>::overstep_fraction() is always 0.0 -- there is no fractional leftover between the physics clock and the render clock. Interpolation plugins that read this value to ease between fixed steps -- bevy_transform_interpolation, avian's PhysicsInterpolationPlugin -- will snap positions on every frame rather than smooth them. Neither is supported.

Use Godot's built-in physics interpolation instead:

Project Settings → Physics → Common → Physics Interpolation = true

or 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 through the jump.

The same caveat applies to overstep-based camera-smoothing systems placed in the BeforeFixedMainLoop / AfterFixedMainLoop sets, and to bevy_gizmos' fixed gizmo-context -- both are untested and unsupported under Godot-owned physics. In practice this is largely moot since godot-bevy renders through Godot rather than bevy_render.

Troubleshooting

"Transform changes not visible"

  • Check you're not in Disabled mode
  • Ensure transform components exist on entities
  • Verify systems are running in correct schedules

"Performance degradation with many entities"

  • Consider switching from TwoWay to OneWay
  • Use Disabled mode for physics entities
  • Profile to identify bottlenecks

"Godot animations not affecting ECS"

  • Enable TwoWay mode for animated entities
  • An ECS system writing the same axis the animation drives will win the conflict (Bevy-wins); let each side own different axes, or move that ECS logic off the animated axis. ECS writing other axes is fine -- co-authorship is per-axis (see above).
  • For rotation specifically, only one side should author it (rotation is whole, not per-axis)

Custom Transform Sync

For performance-critical applications, you can create custom transform sync systems that only synchronize specific entities. This uses compile-time queries for maximum performance and automatically handles both 2D and 3D nodes.

When to Use Custom Sync

Use custom transform sync when:

  • You have many entities but only some need synchronization
  • Performance is critical and you want to minimize overhead
  • You need fine-grained control over which entities sync
  • Different entity types need different sync directions

Basic Usage

1. Disable Auto Sync

Option A: When Adding the Plugin Manually

Use the .without_auto_sync() method to disable automatic transform syncing while keeping the Transform and TransformSyncMetadata components:

#![allow(unused)]
fn main() {
use godot_bevy::prelude::*;

#[bevy_app]
fn build_app(app: &mut App) {
    // Disable auto sync but keep transform components
    app.add_plugins(
        GodotTransformSyncPlugin::default()
            .without_auto_sync()
    );
}
}

Option B: When Using GodotDefaultPlugins

If you're using GodotDefaultPlugins, you need to disable the included GodotTransformSyncPlugin and add your own configured version:

#![allow(unused)]
fn main() {
use godot_bevy::prelude::*;

#[bevy_app]
fn build_app(app: &mut App) {
    // Remove the default transform sync plugin and add a custom one
    app.add_plugins(
        GodotDefaultPlugins
            .build()
            .disable::<GodotTransformSyncPlugin>()
    );

    // Add your custom-configured transform sync plugin
    app.add_plugins(
        GodotTransformSyncPlugin::default()
            .without_auto_sync()
    );
}
}

2. Define Custom Systems

Use the add_transform_sync_systems! macro to define which entities should sync:

#![allow(unused)]
fn main() {
use godot_bevy::add_transform_sync_systems;
use godot_bevy::interop::node_markers::*;
use bevy::ecs::query::{Or, With};

#[bevy_app]
fn build_app(app: &mut App) {
    // Disable auto sync
    app.add_plugins(
        GodotTransformSyncPlugin::default()
            .without_auto_sync()
    );

    // Sync all physics bodies (both 2D and 3D automatically)
    add_transform_sync_systems! {
        app,
        PhysicsEntities = Or<(
            With<RigidBody2DMarker>,
            With<CharacterBody2DMarker>,
            With<StaticBody2DMarker>,
            With<RigidBody3DMarker>,
            With<CharacterBody3DMarker>,
            With<StaticBody3DMarker>,
        )>
    }
}
}

Advanced Usage

Directional Sync Control

You can specify which direction of synchronization you need for optimal performance:

#![allow(unused)]
fn main() {
add_transform_sync_systems! {
    app,
    // Only ECS → Godot (one-way sync)
    UIElements = bevy_to_godot: With<UIElement>,

    // Only Godot → ECS (useful for reading physics results)
    PhysicsResults = godot_to_bevy: With<PhysicsActor>,

    // Full bidirectional sync
    Player = With<Player>,
}
}

This provides significant performance benefits:

  • bevy_to_godot only: Skips reading Godot transforms, ideal for UI elements and ECS-driven entities
  • godot_to_bevy only: Skips writing to Godot, useful for reading physics results
  • Both directions (no prefix): Full synchronization when needed

Real Example: High-Volume Transform Sync

From the perf-test example, which benchmarks transform sync with tens of thousands of entities:

#![allow(unused)]
fn main() {
use godot_bevy::{add_transform_sync_systems, prelude::*};

#[derive(Component)]
struct Particle;

#[bevy_app]
fn build_app(app: &mut App) {
    // Disable auto sync since we want custom sync for performance
    app.add_plugins(
        GodotTransformSyncPlugin::default()
            .without_auto_sync()
    );

    // Add custom transform sync systems for Particle entities only
    // Only sync Bevy -> Godot since particles are driven by ECS movement systems
    add_transform_sync_systems! {
        app,
        Particle = bevy_to_godot: With<Particle>
    }

    // ... movement systems, etc.
}
}

Multiple Sync Systems in One Call

You can define multiple sync systems with different directions in a single macro call:

#![allow(unused)]
fn main() {
add_transform_sync_systems! {
    app,
    // All physics bodies (bidirectional) - both 2D and 3D
    PhysicsBodies = Or<(
        With<RigidBody2DMarker>,
        With<CharacterBody2DMarker>,
        With<StaticBody2DMarker>,
        With<RigidBody3DMarker>,
        With<CharacterBody3DMarker>,
        With<StaticBody3DMarker>,
    )>,

    // UI elements (ECS-driven only) - both 2D and 3D
    UIElements = bevy_to_godot: Or<(
        With<ButtonMarker>,
        With<LabelMarker>,
        With<Sprite3DMarker>,
    )>,

    // Physics result readers (Godot-driven only) - both 2D and 3D
    PhysicsReaders = godot_to_bevy: With<PhysicsListener>,
}
}

Custom Marker Components

For maximum control, create custom marker components:

#![allow(unused)]
fn main() {
use bevy::prelude::*;

#[derive(Component)]
struct NeedsTransformSync;

#[derive(Component)]
struct HighPrioritySync;

#[derive(Component)]
struct ReadOnlyTransform;

// Opt-in sync systems
add_transform_sync_systems! {
    app,
    // Only entities explicitly marked for sync
    OptInEntities = With<NeedsTransformSync>,

    // High priority entities (bidirectional)
    HighPriorityEntities = With<HighPrioritySync>,

    // Read-only from Godot
    ReadOnlyEntities = godot_to_bevy: With<ReadOnlyTransform>,
}

// In your spawning systems
fn spawn_entity(mut commands: Commands) {
    commands.spawn((
        RigidBody3DMarker,
        NeedsTransformSync,  // Only entities with this will sync
        // ... other components
    ));
}
}

Key Features

Built-in Change Detection

The custom sync systems automatically use TransformSyncMetadata to prevent infinite loops:

#![allow(unused)]
fn main() {
// The generated systems automatically include change detection
// No need to manually handle sync loops - it's built in!
add_transform_sync_systems! {
    app,
    Player = With<Player>,  // Safe bidirectional sync
}
}

Compile-time Optimization

Each sync system targets only specific entities, avoiding unnecessary iteration:

#![allow(unused)]
fn main() {
// This creates separate optimized systems for each query
add_transform_sync_systems! {
    app,
    FastEntities = With<Player>,           // Only checks Player entities
    SlowEntities = With<DebugMarker>,      // Only checks DebugMarker entities
    PhysicsEntities = With<RigidBody2DMarker>, // Only checks physics entities
}
}

Automatic System Registration

Custom sync registers the same shared sync systems as auto sync, just restricted to your query filter, so the schedules match exactly:

  • bevy_to_godot (Bevy → Godot write) runs in FixedLast — the physics rate, which is what Godot's physics interpolation expects
  • godot_to_bevy (Godot → Bevy read) runs in PreUpdate
  • Bidirectional sync (no prefix) runs in both schedules

Custom sync resets physics interpolation on an entity's first write, so freshly spawned nodes don't slide from their old position.

2D and 3D Support

The macro automatically handles both 2D and 3D nodes in the same system:

  • Uses AnyOf<(&Node2DMarker, &Node3DMarker)> to query both types
  • Runtime type detection chooses the appropriate transform conversion
  • Single system per query instead of separate 2D/3D systems

Common Use Cases

UI Elements (ECS → Godot only)

UI elements are typically driven by ECS systems and don't need to be read back:

#![allow(unused)]
fn main() {
#[derive(Component)]
struct HealthBar;

#[derive(Component)]
struct MenuItem;

add_transform_sync_systems! {
    app,
    UIElements = bevy_to_godot: Or<(
        With<HealthBar>,
        With<MenuItem>,
        With<LabelMarker>,
    )>
}
}

Physics Results (Godot → ECS only)

When using Godot physics, you often only need to read the results:

#![allow(unused)]
fn main() {
#[derive(Component)]
struct PhysicsActor;

add_transform_sync_systems! {
    app,
    PhysicsActors = godot_to_bevy: Or<(
        With<RigidBody2DMarker>,
        With<CharacterBody2DMarker>,
        With<RigidBody3DMarker>,
        With<CharacterBody3DMarker>,
        With<PhysicsActor>,
    )>
}
}

Interactive Elements (Bidirectional)

Player characters and interactive objects often need both directions:

#![allow(unused)]
fn main() {
#[derive(Component)]
struct Player;

#[derive(Component)]
struct NPC;

add_transform_sync_systems! {
    app,
    Interactive = Or<(With<Player>, With<NPC>)>,
}
}

Best Practices

1. Start Simple

Begin with a single, broad filter and optimize as needed:

#![allow(unused)]
fn main() {
#[derive(Component)]
struct GameEntity;

add_transform_sync_systems! {
    app,
    GameEntities = With<GameEntity>
}
}

2. Use Descriptive Names

Choose clear names for your sync systems:

#![allow(unused)]
fn main() {
add_transform_sync_systems! {
    app,
    MovingEntities = Or<(With<Player>, With<Enemy>)>,
    StaticUI = bevy_to_godot: With<StaticUIElement>,
}
}

3. Avoid Over-Optimization

Don't create too many specialized systems unless profiling shows it's necessary:

#![allow(unused)]
fn main() {
// Good: Logical groups
add_transform_sync_systems! {
    app,
    GameEntities = Or<(With<Player>, With<Enemy>, With<Pickup>)>,
    UiElements = bevy_to_godot: Or<(With<ButtonMarker>, With<LabelMarker>)>,
}

// Avoid: Too many micro-optimizations
add_transform_sync_systems! {
    app,
    Players = With<Player>,
    Enemies = With<Enemy>,
    Pickups = With<Pickup>,
    Buttons = bevy_to_godot: With<ButtonMarker>,
    Labels = bevy_to_godot: With<LabelMarker>,
    // ... too granular
}
}

4. Profile Performance

Use Bevy's diagnostic tools to measure the impact of your custom sync systems:

#![allow(unused)]
fn main() {
use bevy::diagnostic::{FrameTimeDiagnosticsPlugin, LogDiagnosticsPlugin};

#[bevy_app]
fn build_app(app: &mut App) {
    app.add_plugins((
        FrameTimeDiagnosticsPlugin,
        LogDiagnosticsPlugin::default(),
    ));

    // Your custom sync systems
    add_transform_sync_systems! {
        app,
        OptimizedEntities = With<Player>,
    }
}
}

Syntax Reference

The macro supports three sync directions:

#![allow(unused)]
fn main() {
add_transform_sync_systems! {
    app,
    // Bidirectional sync (default)
    EntityName = With<Component>,

    // One-way: ECS → Godot only
    EntityName = bevy_to_godot: With<Component>,

    // One-way: Godot → ECS only
    EntityName = godot_to_bevy: With<Component>,
}
}

You can mix multiple directions in a single macro call, and use any Bevy query filter:

#![allow(unused)]
fn main() {
add_transform_sync_systems! {
    app,
    PhysicsBodies = Or<(With<CharacterBody2DMarker>, With<RigidBody2DMarker>)>,
    UIElements = bevy_to_godot: (With<UIElement>, Without<Disabled>),
    PlayerInputs = godot_to_bevy: With<PlayerInput>,
}
}

Asset Loading

godot-bevy loads assets through Bevy's AssetServer, but Godot owns the filesystem. There are two lanes, and picking the wrong one is the mistake that works in the editor and 404s in an export.

Two lanes

Godot-imported resources -- anything Godot imports and manages: .png, .ogg, .glb, .tscn, .tres, .res, .wav. Load these as GodotResource and cast:

let scene: Handle<GodotResource> = asset_server.load("res://player.tscn");

// later, once loaded
if let Some(res) = assets.get_mut(&scene) {
    if let Some(packed) = res.try_cast::<PackedScene>() {
        // use the PackedScene
    }
}

This lane goes through Godot's ResourceLoader, which resolves imports, .remaps, and uid:// references -- the things a raw file read can't.

Raw data files -- your own formats: .ron, .toml, .json, a custom binary. Write a normal Bevy AssetLoader for the type and load it directly. The reader hands your loader the real file bytes:

#[derive(Asset, TypePath)]
struct Level { /* ... */ }

struct LevelLoader;
impl AssetLoader for LevelLoader {
    type Asset = Level;
    type Settings = ();
    type Error = std::io::Error;

    async fn load(&self, reader: &mut dyn Reader, _: &(), _: &mut LoadContext<'_>)
        -> Result<Level, std::io::Error> {
        let mut bytes = Vec::new();
        reader.read_to_end(&mut bytes).await?;
        // parse bytes into a Level
        unimplemented!()
    }
    fn extensions(&self) -> &[&str] { &["level"] }
}
app.init_asset::<Level>().register_asset_loader(LevelLoader);
let level: Handle<Level> = asset_server.load("res://levels/one.level");

The imported-vs-data rule

The lanes are not interchangeable, and the reason only shows up in an exported build.

When Godot imports player.png, the export .pck contains the imported form (a .ctex) plus a remap entry, not the original player.png. ResourceLoader follows the remap; a raw file read cannot. So a byte loader pointed at res://player.png works in the editor (the original file is right there on disk) and fails in an export (the original isn't in the .pck).

Rule of thumb: load imported types as GodotResource; use byte loaders only for genuine data files that Godot doesn't import.

Export include filter

Godot only packs resources into an export by default. Your .ron/.toml/.json/custom-extension files are not resources, so they're excluded, and dev works while the export 404s.

Add them under the export preset's Resources → "Filters to export non-resource files", e.g.:

*.ron, *.level

Without the filter the file isn't in the .pck.

uid:// is resource-only

uid://<hash> is a resource reference resolved by Godot's ResourceUID/ResourceLoader, not a path a file read can open. Load uid:// targets as GodotResource. Byte loaders over uid:// are not supported.

load_folder is not supported over res:///user://

A Godot asset directory is full of .import/.uid/.gd sidecars that have no Bevy loader, and an untyped folder load aborts on the first one it can't load. load_folder over a Godot source returns an empty result rather than trying. Load the files you need explicitly.

Input Handling

Bevy vs Godot Input

godot-bevy offers two distinct approaches to handling input: Bevy's built-in input system and godot-bevy's bridged Godot input system. Understanding when to use each is crucial for building the right game experience.

Two Input Systems

Bevy's Built-in Input

Use Bevy's standard input resources for simple, direct input handling:

#![allow(unused)]
fn main() {
fn movement_system(
    keys: Res<ButtonInput<KeyCode>>,
    mut query: Query<&mut Transform, With<Player>>,
) {
    for mut transform in query.iter_mut() {
        if keys.pressed(KeyCode::ArrowLeft) {
            transform.translation.x -= 200.0;
        }
        if keys.pressed(KeyCode::ArrowRight) {
            transform.translation.x += 200.0;
        }
    }
}
}

godot-bevy's Bridged Input

Use godot-bevy's event-based system for more advanced input handling:

#![allow(unused)]
fn main() {
fn movement_system(
    mut events: MessageReader<ActionInput>,
    mut query: Query<&mut Transform, With<Player>>,
) {
    for event in events.read() {
        if event.pressed {
            match event.action.as_str() {
                "move_left" => {
                    // Handle left movement
                }
                "move_right" => {
                    // Handle right movement
                }
                _ => {}
            }
        }
    }
}
}

When to Use Each System

🚀 Use Bevy Input For:

Simple desktop games and rapid prototyping

Advantages:

  • Zero setup - works immediately
  • State-based queries - easy "is key held?" checks
  • Rich API - just_pressed(), pressed(), just_released()
  • Direct and fast - no event processing overhead
  • Familiar - standard Bevy patterns

Limitations:

  • Desktop-focused - limited mobile/console support
  • Hardcoded keys - players can't remap controls
  • No Godot integration - can't use input maps

Example use cases:

  • Game jams and prototypes
  • Desktop-only games
  • Simple control schemes
  • Internal tools

🎮 Use godot-bevy Input For:

Production games and cross-platform releases

Advantages:

  • Cross-platform - desktop, mobile, console support
  • User remappable - integrates with Godot's input maps
  • Touch support - native mobile input handling
  • Action-based - semantic controls ("jump" vs "spacebar")
  • Flexible - supports complex input schemes

Trade-offs:

  • Event-based - requires more complex state tracking
  • Setup required - need to define input maps in Godot
  • More complex - steeper learning curve

Example use cases:

  • Commercial releases
  • Mobile games
  • Console ports
  • Games with complex controls

Input Event Processing

godot-bevy processes Godot's dual input system intelligently to prevent duplicate events:

  • Normal Input Events: Generate ActionInput events for mapped keys/buttons
  • Unhandled Input Events: Generate raw GodotKeyboardInput, GodotMouseButtonInput, etc. for unmapped inputs

This ensures:

  • No duplicate events - each physical input generates exactly one event
  • Proper input flow - mapped inputs become actions, unmapped inputs become raw events
  • Clean event streams - predictable, non-redundant event processing
#![allow(unused)]
fn main() {
// For a key mapped to "jump" action in Godot's Input Map:
// ✅ Generates ONE ActionInput { action: "jump", pressed: true }
// ❌ Does NOT generate duplicate GodotKeyboardInput events

// For an unmapped key (e.g., 'Q' with no action mapping):
// ✅ Generates ONE GodotKeyboardInput { keycode: Q, pressed: true }
// ❌ Does NOT generate ActionInput events
}

Available Input Events

godot-bevy provides several input event types:

ActionInput

The most important event type - maps to Godot's input actions:

#![allow(unused)]
fn main() {
fn handle_actions(mut events: MessageReader<ActionInput>) {
    for event in events.read() {
        println!("Action: {}, Pressed: {}, Strength: {}", 
                 event.action, event.pressed, event.strength);
    }
}
}

GodotKeyboardInput

Direct keyboard events:

#![allow(unused)]
fn main() {
fn handle_keyboard(mut events: MessageReader<GodotKeyboardInput>) {
    for event in events.read() {
        if event.pressed && event.keycode == Key::SPACE {
            println!("Space pressed!");
        }
    }
}
}

GodotMouseButtonInput

Mouse button events:

#![allow(unused)]
fn main() {
fn handle_mouse(mut events: MessageReader<GodotMouseButtonInput>) {
    for event in events.read() {
        println!("Mouse button: {:?} at {:?}", 
                 event.button, event.position);
    }
}
}

GodotMouseMotion

Mouse movement events:

#![allow(unused)]
fn main() {
fn handle_mouse_motion(mut events: MessageReader<GodotMouseMotion>) {
    for event in events.read() {
        println!("Mouse moved by: {:?}", event.delta);
    }
}
}

Gamepad input

Three ways to read a gamepad, depending on what you want:

  • Gameplay (recommended) -- bind the action in Godot's Input Map and read it through GodotActions (actions.pressed("jump"), actions.strength("accelerate")). Works in Update and FixedUpdate, and reuses Godot's controller remapping.
  • Bevy-native Query<&Gamepad> -- the default bevy_gamepad feature pulls in Bevy's gilrs backend, which populates the Gamepad entity directly from the OS. No godot-bevy code involved; use it exactly as in any Bevy app.
  • Raw events + Godot device id -- the GamepadButtonInput / GamepadAxisInput messages carry the Godot device id and work on every platform, including WASM (where gilrs isn't available).

Don't feed Godot's gamepad into Bevy's Gamepad entity yourself while bevy_gamepad is on -- you'd get two entities per controller and doubled input. gilrs already owns that path.

Quick Reference

FeatureBevy Inputgodot-bevy Input
Setup complexityNoneModerate
Cross-platformLimitedFull
User remappingNoYes
Touch supportNoYes
State queriesEasyManual tracking
PerformanceFastestFast
Godot integrationNoneFull

Choosing Your Approach

Start with Bevy Input if:

  • Building a prototype or game jam entry
  • Targeting desktop only
  • Using simple controls
  • Want immediate results

Use godot-bevy Input if:

  • Building for release
  • Need cross-platform support
  • Want user-configurable controls
  • Using complex input schemes
  • Targeting mobile/console

Mixing Both Systems

You can use both systems in the same project:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.add_systems(Update, (
        // Debug controls with Bevy input
        debug_controls,
        // Game controls with godot-bevy input
        game_controls,
    ));
}

fn debug_controls(keys: Res<ButtonInput<KeyCode>>) {
    if keys.just_pressed(KeyCode::F1) {
        // Toggle debug overlay
    }
}

fn game_controls(mut events: MessageReader<ActionInput>) {
    for event in events.read() {
        // Handle game actions
    }
}
}

This gives you the best of both worlds: simple debug controls and flexible game controls.

Fixed-timestep input under Godot-owned physics

godot-bevy drives Bevy's RunFixedMainLoop schedule from Godot's _physics_process. Third-party input plugins that hook BeforeFixedMainLoop / AfterFixedMainLoop -- e.g. leafwing-input-manager -- work out of the box.

ButtonInput in FixedUpdate

Keyboard and mouse ButtonInput edges (just_pressed/just_released) work in Update and FixedUpdate. The bridge emits Bevy KeyboardInput events; keyboard_input_system in PreUpdate populates the resource. Because PreUpdate runs in the physics-process prefix -- before the fixed steps -- edges are already set by the time FixedUpdate runs for the frame.

Two caveats, both because just_pressed is a one-render-frame edge (cleared each frame by keyboard_input_system in PreUpdate):

  • More physics than display (e.g. 60 Hz display / 120 Hz physics): when N steps fire in one render frame, just_pressed is true in every one of those N FixedUpdate calls.
  • More display than physics (e.g. 144 Hz display / 60 Hz physics): a render frame can run zero physics steps. If the edge lands on a step-less frame, FixedUpdate never sees it -- by the next frame the edge is already cleared. This is the same caveat stock Bevy has with raw ButtonInput in FixedUpdate.

GodotActions (below) uses Godot's per-tick edge state and has neither issue -- it is the right tool for fixed-rate gameplay input.

#![allow(unused)]
fn main() {
// Visible in FixedUpdate -- but only when a physics step runs on the edge's
// render frame (see the zero-step caveat above); use GodotActions for fixed-rate.
app.add_systems(FixedUpdate, |keys: Res<ButtonInput<KeyCode>>| {
    if keys.just_pressed(KeyCode::Space) { /* may be missed on a 0-step frame */ }
});

// Also works: edges in Update (once per render frame, unambiguous)
app.add_systems(Update, |keys: Res<ButtonInput<KeyCode>>| {
    if keys.just_pressed(KeyCode::Space) { /* correct */ }
});

// Preferred for fixed-rate gameplay: GodotActions is clock-aware and per-tick
app.add_systems(FixedUpdate, |actions: Res<GodotActions>| {
    if actions.just_pressed("jump") { /* correct, exactly once per physics tick */ }
});
}

For fixed-rate gameplay input, GodotActions is the preferred tool -- it tracks per-tick edges independently across the process and physics clocks.

GodotActions

GodotActions lets you read Godot's InputMap actions identically in Update, FixedUpdate, or a helper shared by both. The resource tracks the executing clock via an active-clock flag set by the schedule driver, so the same Res<GodotActions> read returns the correct snapshot for whichever schedule is running.

Opt in per-app:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.add_plugins(GodotActionsPlugin);
}
}

Same function, both schedules -- no change needed:

#![allow(unused)]
fn main() {
fn jump_system(actions: Res<GodotActions>) {
    if actions.just_pressed("jump") { ... }
}

app.add_systems(Update, jump_system);
app.add_systems(FixedUpdate, jump_system); // same fn, correct in both
}

For frequently-called code, use a typed Action handle instead of &str -- no string hash per call, and typo-resistant:

#![allow(unused)]
fn main() {
let jump = Action::new("jump"); // construct once, store in a Resource for cross-frame reuse
if actions.just_pressed(&jump) { ... }
}

The &str overload warns once in debug if the action isn't in the InputMap, so typos surface instead of silently returning false.

All accessors:

#![allow(unused)]
fn main() {
actions.pressed("run")
actions.just_pressed("jump")
actions.just_released("attack")
actions.strength("fire")            // deadzoned [0.0, 1.0]
actions.raw_strength("fire")        // before deadzone
actions.axis("move_left", "move_right")                              // [-1.0, 1.0]
actions.vector("move_left", "move_right", "move_up", "move_down")   // Vec2
}

The process snapshot is polled in Update (the GodotInputSet set). Update systems that read GodotActions must run after the poll:

#![allow(unused)]
fn main() {
app.add_systems(Update, my_system.after(GodotInputSet));
}

FixedUpdate readers need no ordering -- the fixed-schedule driver refreshes the physics snapshot before FixedMain runs.

Limitations:

  • Physics just_pressed lags one tick. Godot stamps physics action edges with a +1 frame offset -- the same lag as is_action_just_pressed in GDScript's _physics_process. Intentional; matches GDScript.
  • Action set is cached on first poll. Actions added to the InputMap after startup aren't picked up.
  • axis/vector use per-action InputMap deadzones. vector() does not replicate Godot's circular get_vector deadzone -- each action's deadzone is applied independently.

Third-party: bevy_enhanced_input

bevy_enhanced_input supports fixed-rate input contexts natively and derives its own edge state per physics step:

#![allow(unused)]
fn main() {
app.add_input_context_to::<FixedPreUpdate, MyInputContext>();
}

It covers button, key, and gamepad bindings. Use it for Bevy-centric action definitions; use GodotActions for Godot InputMap integration.

Analog caveat: mouse-motion and scroll-wheel bindings consumed in FixedPreUpdate draw from accumulated-delta resources that are frame-scoped. When N physics steps run in one render frame, each step reads the full accumulated delta -- multiplying the effect by N. Consume analog inputs at render rate or divide by the physics step count.

Anchor cadence

BeforeFixedMainLoop and AfterFixedMainLoop run once per physics step, not once per render frame -- N times when N steps fire in a single frame. Idempotent per-step work (e.g. leafwing's input-buffer swap) is fine; systems that assume once-per-frame batch accumulation will over-apply.

Migration

Phase 1 input changes (keyboard bridge + type renames)

Three things changed in this release; each may require a small update.

1. keyboard edges now work in Update

The bridge previously pressed ButtonInput<KeyCode> directly, which meant just_pressed/just_released were always false -- they were cleared before Update ever ran. The bridge now emits Bevy KeyboardInput events instead, so Bevy's own PreUpdate systems populate the resource correctly. pressed(), just_pressed(), and just_released() all work in Update. No code change needed -- this is a fix.

2. third-party EventReader<KeyboardInput> consumers now receive events

If you have egui text fields, an accessibility plugin, a key-rebinding UI, or a hand-rolled Bevy keyboard bridge, they will start receiving KeyboardInput events for the first time. Dormant text widgets can activate; a hand-rolled bridge that also reads raw Godot events may double-handle. Check for anything in your dependency tree that reads EventReader<bevy_input::keyboard::KeyboardInput> and confirm the behavior is correct.

3. godot-bevy message types are renamed

The types that shadowed Bevy's same-named types are now Godot-prefixed. Update your imports:

oldnew
KeyboardInput (godot-bevy)GodotKeyboardInput
MouseButtonInput (godot-bevy)GodotMouseButtonInput
MouseMotion (godot-bevy)GodotMouseMotion
MouseButton (godot-bevy)GodotMouseButton
#![allow(unused)]
fn main() {
// before
use godot_bevy::plugins::input::{KeyboardInput, MouseButtonInput, MouseMotion, MouseButton};

// after
use godot_bevy::plugins::input::{
    GodotKeyboardInput, GodotMouseButtonInput, GodotMouseMotion, GodotMouseButton,
};
}

Phase 2 input changes (GodotActions)

GodotActionsPlugin is additive -- nothing existing breaks. If you were accumulating ActionInput messages to track held state or detect first-press, replace with Res<GodotActions>:

#![allow(unused)]
fn main() {
// before -- message-based, Update only
fn my_system(mut reader: MessageReader<ActionInput>) {
    for event in reader.read() {
        if event.action == "jump" && event.pressed { ... }
    }
}

// after -- works in Update and FixedUpdate
fn my_system(actions: Res<GodotActions>) {
    if actions.just_pressed("jump") { ... }
}
}

MessageReader<ActionInput> continues to work; removal is a future phase.

Troubleshooting

Duplicate Events (Fixed in v0.7.0+)

If you're seeing duplicate ActionInput events for the same key press, you may be using an older version of godot-bevy. This was fixed in version 0.7.0 through improved input event processing.

Symptoms:

#![allow(unused)]
fn main() {
// Old behavior (before v0.7.0):
🎮 Action: 'jump' pressed    // First event
🎮 Action: 'jump' pressed    // Duplicate event (unwanted)
}

Solution: Update to godot-bevy v0.7.0 or later where input processing was improved to eliminate duplicates.

Mouse Events Only on Movement

GodotMouseMotion events are only generated when the mouse actually moves. If you need continuous mouse position tracking, consider using Godot's Input.get_global_mouse_position() in a system that runs every frame.

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

  1. Define a Bevy event for your signal:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use godot_bevy::prelude::*;

#[derive(Event, Debug, Clone)]
struct StartGameRequested;
}
  1. Register the signals plugin for your event type:
#![allow(unused)]
fn main() {
fn build_app(app: &mut App) {
    app.add_plugins(GodotSignalsPlugin::<StartGameRequested>::default());
}
}
  1. 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),
        );
    }
}
}
  1. 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 with GodotAccess)
  • entity: Option<Entity>: Bevy entity if you passed Some(entity) to connect

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();
}
}

Connecting to Non-Entity Objects

The connect method works with GodotNodeHandle, which represents nodes tracked as ECS entities. However, some Godot objects like SceneTree are not tracked as entities. For these cases, use connect_object:

#![allow(unused)]
fn main() {
use bevy::prelude::*;
use godot_bevy::prelude::*;
use godot_bevy::interop::signal_names::SceneTreeSignals;

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

fn setup(app: &mut App) {
    app.add_plugins(GodotSignalsPlugin::<SceneChanged>::default())
       .add_systems(Startup, connect_scene_tree)
       .add_observer(on_scene_changed);
}

fn connect_scene_tree(
    signals: GodotSignals<SceneChanged>,
    mut scene_tree: SceneTreeRef,
) {
    let tree = scene_tree.get().clone();
    signals.connect_object(tree, SceneTreeSignals::SCENE_CHANGED, |_args| {
        Some(SceneChanged)
    });
}

fn on_scene_changed(_trigger: On<SceneChanged>) {
    println!("Scene changed!");
}
}

The connect_object method accepts any Gd<T> where T inherits from Object. This is useful for:

  • SceneTree signals - scene_changed, tree_changed, node_added, etc.
  • Autoload singletons - Custom autoloads that emit signals
  • Non-node objects - Any Godot object that isn't tracked as an ECS entity

The mapper closure for connect_object is simpler than connect since there's no associated entity:

#![allow(unused)]
fn main() {
// connect_object mapper: just args
|args: &[Variant]| -> Option<MyEvent>

// connect mapper: args, node handle, and optional entity
|args: &[Variant], node_handle: GodotNodeHandle, entity: Option<Entity>| -> Option<MyEvent>
}

Deferred Connections

When spawning entities before their GodotNodeHandle is ready, you can defer connections. Add DeferredSignalConnections<T> with a signal-to-event mapper; the GodotSignalsPlugin<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, and entity:
      • 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.
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.

Signal Name Constants

godot-bevy provides auto-generated constants for Godot signal names, offering type-safe, discoverable alternatives to string literals. These are located in godot_bevy::interop::signal_names.

#![allow(unused)]
fn main() {
use godot_bevy::interop::signal_names::{BaseButtonSignals, SceneTreeSignals, Area2DSignals};

// Instead of string literals:
signals.connect(button, "pressed", None, mapper);

// Use constants:
signals.connect(button, BaseButtonSignals::PRESSED, None, mapper);

// SceneTree signals
signals.connect_object(tree, SceneTreeSignals::SCENE_CHANGED, mapper);
signals.connect_object(tree, SceneTreeSignals::TREE_CHANGED, mapper);

// Area2D signals
signals.connect(area, Area2DSignals::BODY_ENTERED, Some(entity), mapper);
signals.connect(area, Area2DSignals::AREA_ENTERED, Some(entity), mapper);
}

Benefits of using constants:

  • Compile-time validation - Typos are caught at compile time
  • IDE autocompletion - Discover available signals easily
  • Documentation - Constants include doc comments explaining when each signal fires

Common signal constant structs:

StructCommon Signals
BaseButtonSignalsPRESSED, BUTTON_UP, BUTTON_DOWN, TOGGLED
Area2DSignalsBODY_ENTERED, BODY_EXITED, AREA_ENTERED, AREA_EXITED
Area3DSignalsBODY_ENTERED, BODY_EXITED, AREA_ENTERED, AREA_EXITED
SceneTreeSignalsSCENE_CHANGED, TREE_CHANGED, NODE_ADDED, NODE_REMOVED
AnimationPlayerSignalsANIMATION_FINISHED, ANIMATION_STARTED, ANIMATION_CHANGED
TimerSignalsTIMEOUT
VisibleOnScreenNotifier2DSignalsSCREEN_ENTERED, SCREEN_EXITED

Frame Execution Model

Understanding how godot-bevy integrates with Godot's frame timing is crucial for building performant games.

Two Godot Callbacks, One Bevy Frame

godot-bevy splits Bevy's standard Main schedule across Godot's two frame callbacks at the fixed-loop boundary. app.update() is never called in production: the prefix and the fixed loop run in _physics_process, and the suffix runs in _process.

Physics Frames (_physics_process)

The prefix of the main schedule plus the fixed loop run here, on Godot's physics clock.

The TwoWay notes below refer to TransformSyncMode::TwoWay (bidirectional Godot↔ECS transform sync); they only affect where the Godot→ECS read runs.

What runs:

  • First
  • PreUpdate (TwoWay: reads Godot → ECS transforms only on 0-physics-step frames)
  • StateTransition
  • FixedMain (zero or more times per render frame)
    • FixedFirst (TwoWay: reads Godot → ECS every physics step)
    • FixedPreUpdate
    • FixedUpdate (your physics logic)
    • FixedPostUpdate
    • FixedLast (writes ECS → Godot transforms at physics rate)

The prefix (FirstStateTransition) runs once per render frame, on the first physics step; FixedMain runs once per physics step.

Frequency: Godot's physics tick rate (default 60 Hz)

Use for:

  • Physics calculations
  • Movement that needs to sync with Godot physics
  • Collision detection
  • Anything that must run at a fixed, deterministic rate

Visual Frames (_process)

The suffix of the main schedule runs here, then clear_trackers fires once for the whole render frame.

What runs:

  • Update
  • PostUpdate
  • Last

Frequency: Matches Godot's visual framerate (typically 60–144 FPS)

Use for:

  • Game logic
  • UI updates
  • Rendering-related systems
  • Most gameplay code

On a render frame with no physics step, the prefix runs in _process before the suffix, so a frame is never skipped.

Schedule Execution Order

Physics Frame (_physics_process)

Physics Frame Start
    ├── First           ┐
    ├── PreUpdate       │ prefix: once per render frame (TwoWay read only on 0-step frames)
    ├── StateTransition ┘
    └── FixedMain       (once per physics step)
        ├── FixedFirst      (TwoWay: reads Godot → ECS every physics step)
        ├── FixedPreUpdate
        ├── FixedUpdate     (your physics/fixed logic)
        ├── FixedPostUpdate
        └── FixedLast       (writes ECS → Godot transforms)
Physics Frame End

Visual Frame (_process)

Visual Frame Start
    ├── Update     (your visual-rate logic)
    ├── PostUpdate
    ├── Last
    └── clear_trackers (once per render frame)
Visual Frame End

Physics steps run on Godot's authoritative clock: each render frame drives the prefix and 0, 1, or N FixedMain steps in _physics_process before the suffix runs in _process — a deterministic order, not independent schedules.

BeforeFixedMainLoop / AfterFixedMainLoop anchors

godot-bevy runs the whole RunFixedMainLoop schedule once per physics step, so the BeforeFixedMainLoop and AfterFixedMainLoop anchor sets fire once per step — 0, 1, or N times per render frame — not Bevy's stock once per frame. Order ecosystem systems (e.g. leafwing's input-buffer swap) against them with that cadence in mind.

A GodotActions read inside either anchor sees the process-clock snapshot: the active clock is flipped to physics only around FixedMain itself. Read actions in FixedUpdate (physics snapshot) or Update (process snapshot), not in the anchors.

Frame Rate Relationships

ScheduleRateUse case
Visual schedules (Update, etc.)Display refresh (60–144 Hz)Rendering, UI, general logic
FixedUpdate / FixedMainGodot's physics rate (default 60 Hz)Physics, deterministic simulation

Note: Bevy's default FixedUpdate rate (64 Hz) is not used. godot-bevy drives FixedMain directly from _physics_process, so the rate is always Godot's physics rate — whatever is set in Project Settings → Physics → Common → Physics Ticks Per Second.

Practical Example

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    // Visual-rate systems
    app.add_systems(Update, (
        ui_system,
        camera_follow,
        animation_system,
    ));

    // Fixed-rate physics systems (runs at Godot's physics rate, default 60 Hz)
    app.add_systems(FixedUpdate, (
        character_movement,
        collision_response,
        ai_behavior,
    ));
}
}

Delta Time

In Update Systems

#![allow(unused)]
fn main() {
fn movement_system(
    time: Res<Time>,
    mut query: Query<&mut Transform>,
) {
    let delta = time.delta_secs();
    // Visual-frame delta — varies with framerate
}
}

In FixedUpdate Systems

#![allow(unused)]
fn main() {
fn physics_movement(
    time: Res<Time>,
    mut query: Query<&mut Transform>,
) {
    let delta = time.delta_secs();
    // Fixed delta — always equals Godot's physics delta (e.g. 1/60 s at 60 Hz)
    // Engine.time_scale is baked in, so slow-motion works automatically
}
}

Res<Time> works correctly in both schedules — in FixedUpdate it reports the fixed physics delta automatically. Do not hardcode 1.0 / 60.0; read time.delta_secs() instead.

Physics Interpolation

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

Project Settings → Physics → Common → Physics Interpolation = true

or 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 through the jump.

Note: Bevy-side interpolation plugins (e.g. avian's PhysicsInterpolationPlugin, bevy_transform_interpolation) and rollback netcode (bevy_ggrs) are not supported in godot-bevy's transform sync path — use Godot's built-in interpolation instead.

Common Pitfalls

Don't put physics logic in Update

#![allow(unused)]
fn main() {
// BAD: Update runs at variable framerate; physics won't be deterministic
app.add_systems(Update, character_movement);

// GOOD: FixedUpdate runs at physics rate
app.add_systems(FixedUpdate, character_movement);
}

Don't hardcode the physics timestep

#![allow(unused)]
fn main() {
// BAD: breaks if physics rate changes in Project Settings
let delta = 1.0 / 60.0;

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

Don't expect immediate cross-schedule visibility

Godot transforms written in FixedLast aren't read back into ECS until the next physics step's FixedFirst (TwoWay), or — on a render frame with no physics step — that frame's PreUpdate fallback.

Because the TwoWay read occurs in FixedFirst, any prefix schedule (First, PreUpdate, StateTransition) on a frame with one or more physics steps sees last frame's synced Transform — the fresh Godot value isn't merged until FixedFirst runs. Read this frame's Godot value in FixedUpdate onward (after FixedFirst) or in the Update suffix (after the last step's read), not in a prefix schedule.

Performance Considerations

  1. Visual frames vary widely (30–144+ FPS)
  2. FixedUpdate runs at a constant rate driven by Godot's physics clock
  3. Transform syncing from ECS → Godot happens in FixedLast; Godot → ECS happens every physics step in FixedFirst (TwoWay), and in PreUpdate on 0-step frames

Note: Scene tree entities are initialized during PreStartup, before any Startup systems run. You can safely query Godot scene entities in Startup systems. See Scene Tree Initialization and Timing for details.

Thread Safety and Godot APIs

Some Godot APIs are not thread-safe and must be called exclusively from the main thread. This creates an important constraint when working with Bevy's multi-threaded ECS, where systems typically run in parallel across multiple threads. For additional details, see Thread-safe APIs — Godot Engine.

The Main Thread Requirement

Any system that interacts with Godot APIs—such as calling methods on Node, accessing scene tree properties, or manipulating UI elements—must run on the main thread. This includes:

  • Scene tree operations (add_child, queue_free, etc.)
  • Transform modifications on Godot nodes
  • UI updates (setting text, visibility, etc.)
  • Audio playback controls
  • Input handling via Godot's Input singleton
  • File I/O operations through Godot's resource system

Main-thread access with GodotAccess

Use the GodotAccess SystemParam whenever you need to call Godot APIs. It carries a NonSend guard, so any system that includes it is scheduled on the main thread:

#![allow(unused)]
fn main() {
use godot_bevy::prelude::*;

fn update_ui_labels(
    query: Query<&GodotNodeHandle, With<PlayerStats>>,
    stats: Res<GameStats>,
    mut godot: GodotAccess,
) {
    for handle in &query {
        if let Some(mut label) = godot.try_get::<Label>(*handle) {
            label.set_text(&format!("Score: {}", stats.score));
        }
    }
}
}

SceneTreeRef is also a NonSend SystemParam. If a system already takes SceneTreeRef, it is pinned to the main thread and you do not need an extra GodotAccess parameter unless you actually call Godot APIs.

Best Practices: Minimize Systems That Call Godot APIs

While GodotAccess makes Godot API access explicit, systems that use it cannot execute in parallel with other main thread-assigned systems. This can become a performance bottleneck in complex applications, as all systems requiring Godot API access must wait their turn to execute sequentially on this single thread.

The most efficient approach is to minimize main thread systems by using an event-driven architecture:

  1. Multi-threaded systems handle game logic and emit events
  2. Main thread systems consume events and update Godot APIs

Benefits of Event-Driven Architecture

  • Better parallelization: Core game logic runs on multiple threads
  • Cleaner separation: Business logic decoupled from presentation layer
  • Easier testing: Game logic systems can be tested without Godot APIs
  • Reduced main thread contention: Fewer systems competing for main thread time

Profiling

Godot-Bevy, together with Bevy native, supports several methods of profiling. In this article, we'll discuss using Tracy. We recommend you read Bevy's profiling doc first.

Instructions

  • In your Cargo.toml, under dependencies add necessary tracy dependencies, e.g.:
[dependencies]
tracing = "0.1"
tracing-tracy = { version = "0.11.4", default-features = false, features = [
  "enable",
  "manual-lifetime",
  "ondemand",
  "broadcast",       # announce presence
], optional = true }
  • In your Cargo.toml, under features add a trace_tracy (feel free to rename it):
[features]
trace_tracy = ["dep:tracing-tracy", "godot-bevy/trace_tracy"]
  • Install Tracy, see https://github.com/bevyengine/bevy/blob/main/docs/profiling.md for details on picking the correct version to install. As of July 2025, you need Tracy Profiler 0.12.2, which you can obtain from The official site. Alternatively, you can use the zig-built version, which makes it much easier to build c binaries across platforms, see https://github.com/allyourcodebase/tracy
  • Once built, run the Tracy Profiler (tracy-profiler), and hit the Connect button so it's listening/ready to receive real time data from your game
  • Build your game. You can use either dev or release, both work, though we recommend release since you'll still get symbol resolution and your profiling numbers will reflect what you're actually shipping in addition to being much faster than a dev build.
  • Run your game, you should see real time data streaming into the Tracy profiler GUI.
  • For a complete example of this in action, see our perf-test example

Notes

Note for version 0.9.3+: The check-cfg workaround is no longer needed. Tracy integration has been refactored to prevent dependency leaks.

Note for version 0.12.0+: The Godot editor uses a different Tracy client port than game instances. The default editor port is 7867. It can be overridden by setting the GODOT_EDITOR_TRACY_PORT=7867 environment variable. You override the game instance Tracy port as usual with TRACY_PORT=8086.

Debug Build Performance

Debug builds run much slower than release builds. For per-frame work that crosses the Rust/Godot boundary many times -- transform sync over hundreds of nodes, for example -- a debug build can be several times slower than release. This is normal, and most of the gap comes from two independent causes you can tune separately.

Optimize your dependencies

By default a debug build compiles everything at opt-level = 0, including Bevy, gdext, and the rest of your dependency tree. Those crates do the heavy per-frame math, so leaving them unoptimized is what makes debug builds feel sluggish.

Optimize your dependencies while leaving your own crate unoptimized:

[profile.dev.package."*"]
opt-level = 3

Your own code still compiles quickly and stays fully debuggable, while the hot paths in your dependencies run at release speed. In transform-heavy scenes this is worth roughly a 3x improvement. It is standard Rust practice rather than anything specific to godot-bevy -- the Bevy book recommends the same setting. The godot-bevy project wizard scaffolds it into your Cargo.toml automatically.

Tune gdext safeguards

gdext runs runtime validity checks on calls that cross the FFI boundary. These catch real bugs (use-after-free, type mismatches) but add per-call overhead. gdext exposes three levels:

  • Strict -- default for dev builds, maximum checking.
  • Balanced -- default for release builds, basic validity checks, fast.
  • Disengaged -- most checks off, fastest, unsafe.

If FFI validation shows up in your profile, drop the dev level to balanced:

godot = { version = "0.x", features = ["safeguards-dev-balanced"] }

There is a matching safeguards-release-disengaged for shipping builds, but only reach for it after testing thoroughly on a higher level. See gdext's safeguard levels documentation for details.

Debugging

Godot-bevy includes a built-in entity inspector that displays your Bevy ECS state directly in the Godot editor. When running your game, you can see all entities, their components, and parent-child relationships in real time.

Entity Inspector

The inspector appears as a "Entities" tab next to the Scene tab in the editor's left dock. It shows:

  • All Bevy entities with their names and appropriate icons
  • Entity hierarchy (scene tree via GodotChildOf/GodotChildren)
  • Components attached to each entity with type-specific icons
  • Entities with Godot nodes show their node type icon (e.g., Node2D, Sprite2D)

Enabling the Inspector

The inspector is included in GodotDefaultPlugins:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.add_plugins(GodotDefaultPlugins);
}
}

Or add it individually:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.add_plugins(GodotDebuggerPlugin);
}
}

Configuration

Control the inspector through the DebuggerConfig resource:

#![allow(unused)]
fn main() {
fn configure_debugger(mut config: ResMut<DebuggerConfig>) {
    config.enabled = true;       // Toggle on/off
    config.update_interval = 0.5; // Seconds between updates
}
}

Using the Inspector

  1. Open your project in Godot
  2. Look for the "Bevy" tab in the left dock (next to Scene/Import)
  3. Run your game
  4. The inspector populates with your ECS state

Entities display as a tree. Click to expand and see:

  • Child entities (nested under parents)
  • Components (shown in blue, with full type path on hover)

Entity icons indicate the Godot node type when a marker component is present (e.g., Node2DMarker shows the Node2D icon). Entities with a GodotNodeHandle but no specific marker show the Godot logo.

Debugging Hierarchy Issues

The inspector mirrors the Godot scene tree via GodotChildOf/GodotChildren, not Bevy's built-in ChildOf/Children. If an entity appears at the wrong level:

  1. Verify the Godot node was in the scene tree when the entity was created
  2. If you reparent nodes, wait a frame for the hierarchy update to process

Performance Considerations

The inspector sends data every 0.5 seconds by default. For games with thousands of entities, you may want to increase the interval or disable it in release builds:

#![allow(unused)]
fn main() {
fn setup_debugger(mut config: ResMut<DebuggerConfig>) {
    #[cfg(debug_assertions)]
    {
        config.enabled = true;
        config.update_interval = 1.0; // Slower updates for large scenes
    }
    
    #[cfg(not(debug_assertions))]
    {
        config.enabled = false;
    }
}
}

Integration Testing

Integration tests run game code in Godot with real frame progression. Use them when a system depends on the scene tree, Godot nodes, or Godot's frame loop. Pure Rust logic is still a good fit for unit tests.

Project layout

Tests live in the game crate and run in its Godot project:

my-game/
├── godot/
│   ├── addons/godot-bevy/
│   ├── project.godot
│   └── .godot/extension_list.cfg
└── rust/
    ├── Cargo.toml
    ├── run_godot.rs
    └── src/
        ├── lib.rs
        └── itests.rs

Install or symlink the addon into godot/addons/godot-bevy, and configure its BevyAppSingleton autoload. The generated GDExtension must be imported once so Godot records it in .godot/extension_list.cfg.

Setup

Add the optional test dependency and enable the frame signal used by the harness:

godot-bevy-test = { version = "0.12", optional = true }
itest = ["dep:godot-bevy-test", "godot-bevy-test/test-frame-signal"]

Register the runner in the game library. Keep #[bevy_app] fn build_app as the only GDExtension entry point.

#![allow(unused)]
fn main() {
#[cfg(feature = "itest")]
mod itests;

#[cfg(feature = "itest")]
godot_bevy_test::declare_test_runner!();
}

Set up run_godot.rs as shown in Cargo Run Godot. Its itest path starts the runner scene and sets GODOT_BEVY_ITEST=1 for the child Godot process.

#[bevy_app] leaves build_app as a normal function, so TestApp::new(&ctx, build_app) works. Most tests should add only the plugins they cover.

Writing tests

Use #[itest] on an async function with an owned TestContext. TestApp initializes the autoload, waits for the initial scene-tree population, and gives the test explicit frame control.

#![allow(unused)]
fn main() {
#[itest]
async fn gem_collected(ctx: TestContext) {
    let mut app = TestApp::new(&ctx, |app| {
        app.add_plugins(GemPlugin);
    })
    .await;

    let (_, gem_entity) = app.add_node::<Area2D>("gem").await;
    let (_, player_entity) = app.add_node::<Area2D>("player").await;
    app.with_world_mut(|world| {
        world.entity_mut(gem_entity).insert(Gem);
        world.entity_mut(player_entity).insert(Player);
        world.trigger(godot_bevy::prelude::CollisionStarted {
            entity1: gem_entity,
            entity2: player_entity,
        });
    });

    app.update().await;

    app.with_world(|world| {
        assert_eq!(world.resource::<GemsCollected>().0, 1);
        assert!(!world.entities().contains(gem_entity));
    });

    app.cleanup().await;
}
}

Use with_world for read-only access. Use with_world_mut for mutations and queries, since creating a Bevy query needs mutable world access.

#![allow(unused)]
fn main() {
#[itest]
async fn game_components_keep_their_defaults(ctx: TestContext) {
    let mut app = TestApp::new(&ctx, |app| {
        app.add_plugins((GodotCollisionsPlugin, DoorPlugin));
    })
    .await;

    app.with_world_mut(|world| {
        world.spawn((Gem, Door::default(), Player));

        let mut doors = world.query::<&Door>();
        assert_eq!(doors.iter(world).count(), 1);
    });

    app.update().await;
    app.cleanup().await;
}
}

#[itest(async)] fn test(ctx: &TestContext) -> godot::task::TaskHandle remains available when a test returns an explicitly spawned task. Async functions use an owned context, because a reference cannot outlive the spawned task.

Running tests

Run the game crate's runner:

cargo run --features itest

After adding or changing the extension, import the project once. Then a direct invocation is:

godot --headless --path godot --import
GODOT_BEVY_ITEST=1 godot --headless --fixed-fps 60 --path godot --scene res://addons/godot-bevy/test/TestRunner.tscn --quit-after 10000

Godot can crash on exit after a headless import once a GDExtension is loaded (godot#111645). The .godot folder is written before that, so the crash is harmless and the runner skips the import on later runs.

ITEST_FILTER selects comma-separated, case-sensitive name substrings. ITEST_REPEAT repeats selected tests. ITEST_JSON_PATH writes a report. #[itest(skip)] reports a skipped test, while #[itest(focus)] selects focused tests; set ITEST_DENY_FOCUS=1 in CI to reject focus mode. The full configuration table is in the godot-bevy-test README.

Troubleshooting

BevyApp defined multiple times

The game and test dependencies resolved different copies of godot-bevy. Run cargo tree -d, then align their sources and versions. A second test crate that links the game as an rlib is unsupported because it creates duplicate GDExtension entry symbols.

IntegrationTests class not found

Godot did not load the extension. Run --import once and check that .godot/extension_list.cfg lists the generated GDExtension.

Must be run in headless mode

The test runner only runs headlessly. Pass --headless when starting Godot.

Game code runs before the first test

Without GODOT_BEVY_ITEST, the game autoload boots for a frame or two before the runner. Its startup logs and any nodes it adds under root appear before Run godot-bevy integration tests and leak into every test's scene scan. Set GODOT_BEVY_ITEST=1 in the process that launches Godot.

Benchmarks

#[bench] runs a function repeatedly and requires a return value so its work is not optimized away:

#![allow(unused)]
fn main() {
#[bench]
fn name() -> i32 {
    42
}

#[bench(repeat = 50)]
fn repeated_name() -> i32 {
    42
}
}

Build the Rust crate with --release, then launch the benchmark runner:

godot --headless --path godot --scene res://addons/godot-bevy/test/BenchRunner.tscn --quit-after 30000

Platform Targets

Platform-specific setup guides for godot-bevy projects.

  • Android - Android development setup

Android

This guide covers building godot-bevy projects for Android devices. Android development requires cross-compilation from your development machine to ARM64 architecture used by most modern Android devices.

Prerequisites

  1. Android NDK - The Native Development Kit provides the compilers and tools needed to build native code for Android. Download from Android NDK Downloads

  2. Rust target - Install the Android ARM64 compilation target for Rust:

    rustup target add aarch64-linux-android
    

Step 1: Configure Build Environment

The Rust cc build system needs explicit paths to Android NDK compilers for cross-compilation. This tells Rust which Android-specific compilers to use instead of your system's default compilers.

Option A: Environment Variables

Set these once per terminal session:

export NDK_HOME="/path/to/your/android/ndk"
export CC="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang"
export CXX="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang++"
export AR="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"

Option B: Inline Command

Self-contained approach that doesn't modify your shell environment:

CC="$NDK_HOME/..." CXX="$NDK_HOME/..." AR="$NDK_HOME/..." cargo build --target aarch64-linux-android

Step 2: Configure Cargo Linker

Create .cargo/config.toml in your rust/ directory. This tells Cargo how to link the compiled code into Android-compatible libraries:

[target.aarch64-linux-android]
linker = "/path/to/your/android/ndk/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang"
ar = "/path/to/your/android/ndk/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"

Step 3: Modify Entry Point

Android requires a specific entry point function name. Change your Bevy app entry point (likely lib.rs) from:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_bevy_app(app: &mut App) {
    // ...
}
}

To:

#![allow(unused)]
fn main() {
#[bevy_app]
#[no_mangle]
fn android_main(app: &mut App) {
    // ...
}
}

The #[no_mangle] attribute prevents Rust from changing the function name during compilation, ensuring Android can find the entry point.

Step 4: Build for Android

Build your Rust library for Android:

cargo build --target aarch64-linux-android

or use the self-contained command from above if you haven't setup the envs.

This creates a shared library file lib{your_app_name}.so in rust/target/aarch64-linux-android/debug/ that Android can load.

Step 5: Update Godot Configuration

Update rust.gdextension

Tell Godot where to find your Android library by adding these paths to your rust.gdextension file:

[libraries]
# ... existing entries ...
android.debug.arm64 = "res://rust/target/aarch64-linux-android/debug/lib{your_app_name}.so"
android.release.arm64 = "res://rust/target/aarch64-linux-android/release/lib{your_app_name}.so"

Configure Godot Export

  1. Go to Project → Export...
  2. Select Android preset
  3. Under Architectures, ensure arm64-v8a is selected

Note: The Rust target aarch64-linux-android corresponds to Android's arm64-v8a architecture

Export your app and deploy to your Android device.

Project Transition Patterns

This section documents practical patterns for migrating existing Godot projects to godot-bevy.

Unlike Migration Guides, these pages are about project architecture transition, not upgrading between godot-bevy crate versions.

The Event Bridge

Bevy systems react to Godot through observers. The event bridge is how something outside a Bevy system — a Godot node's Rust method, or a plain GDScript script — gets a Bevy Event into that observer loop. The event lands as an On<T> trigger, the same place a Godot signal lands.

Every fire enqueues onto a per-app channel and is delivered on the next First drain — it never triggers synchronously. From inside a Bevy system you'd use Commands::trigger; the bridge is for callers who aren't in the ECS yet.

There are two ways in, depending on who's sending.

From Rust

If you have a Gd<BevyApp>, you send a typed event straight in — no Variant, no string name, no registration:

#[derive(Event, Clone)]
struct Damage { amount: i32 }

#[godot_api]
impl Enemy {
    #[func]
    fn take_hit(&mut self, amount: i32) {
        if let Some(app) = BevyApp::try_singleton() {
            app.bind().send_event(Damage { amount });
        }
    }
}

try_singleton() resolves the /root/BevyAppSingleton autoload. If you run more than one BevyApp, skip it and send to the one you mean: godot_bevy::send_event(&that_app, Damage { amount }) — the same call, spelled as a free function.

Handle it like any observer:

app.add_observer(|trigger: On<Damage>, mut hp: ResMut<Health>| {
    hp.0 -= trigger.event().amount;
});

Fire these from the main thread, from a node callback between frames — they bind the app to reach its world, so don't fire from inside a running Bevy frame (a signal a system emitted synchronously). Off the main thread, hold a cloned GodotEventSender (a Res<GodotEventSender> you cloned on the main thread) and .send() through that — it's a plain channel send, safe from anywhere.

From GDScript

GDScript can't name a Rust type, so you register the name once on the Rust side with a mapper from the payload Variant:

#[derive(Event, Clone)]
struct Damage { amount: i64 }   // GDScript ints arrive as i64

app.add_godot_event::<Damage>("damage", |payload| {
    let dict = payload.try_to::<VarDictionary>().ok()?;
    Some(Damage { amount: dict.get("amount")?.try_to::<i64>().ok()? })
});

Then any script fires it through the autoload:

get_node("/root/BevyAppSingleton").send_event("damage", { "amount": 10 })

For a transparent newtype you can drop the mapper — GodotConvert gives you the decode for free (gdext derives FromGodot from it):

#[derive(Event, Clone, GodotConvert)]
#[godot(transparent)]
struct Volume(f64);

app.add_godot_event_from::<Volume>("volume");

A unit event takes a null payload: send_event("game_over", null).

The decode is strict on purpose: a float where you asked for an i64, or anything out of range, is dropped with a warning rather than quietly coerced. An unknown name is dropped with a warning that lists the names that are registered, so a typo is quick to spot. Your mapper returns None to reject a payload — it must never panic.

One limit is inherent to the GDScript path: send_event is a #[func] on BevyApp, so a script handler that fires it in response to a signal a running Bevy system just emitted re-enters a node that's already borrowed for the frame, and gdext panics before the bridge runs. Fire GDScript events between frames, not from inside the bridge's own frame.

Timing

The channel drains once a frame, in First. First runs in the per-frame prefix — in _physics_process when a physics step runs, or in _process on a frame with no step — so:

  • Enqueue before this frame's physics_process (from _input, for instance) and it's drained at this frame's First, visible to FixedUpdate and Update the same frame.
  • Enqueue mid-frame (from a running system, or a re-entered signal handler) and the drain has already passed — it lands on the next frame's First.

Delivery is quantized to that one render-frame drain, not per fixed step: in a frame with several physics steps, the frame's events all arrive on the first FixedUpdate step. If you need a system ordered around delivery, the drain runs in the public EventBridgeSet::Drain, so .after(EventBridgeSet::Drain) does what you'd expect.

Signals or send_event?

Both arrive at the same On<T> observers, so pick by where the event comes from:

  • A built-in node signal — a Button's pressed, an Area2D's body_enteredGodotSignalsPlugin + GodotSignals::connect.
  • A GDScript script raising its own event → add_godot_event + send_event.
  • A Rust node method raising its own event → send_event(&app, event).

Migrating from the mailbox

Earlier versions shipped a poll-based GodotMailboxPlugin: a FixedFirst system scanned marked nodes every step, read script-side "pending" fields off each, and wrote a Message<T>. The bridge replaces it — a push instead of a poll, decoded once per fire instead of scanned per entity per step.

Mailbox (poll)Bridge (push)
impl GodotMailboxMessage + drain_from_node reads node fieldsadd_godot_event::<T>("name", |v| ...) decoder; GDScript fires send_event("name", payload)
drain_from_node's source: GodotNodeHandlepass {"source": self} in the payload; the mapper extracts it, the observer resolves node → Entity via NodeEntityIndex
GodotMailboxPlugin<T, Marker> per message typeno plugin; add_godot_event per GDScript name
MessageReader<T> batch loopan observer accumulates into a Resource, an ordered system drains it (below)
O(entities) FFI scan every fixed stepone decode per fire, deferred to the next First

Observers fire per event, not as one batched read. If a consumer wants the mailbox's batch shape — drain everything in an ordered fixed-step system — accumulate in the observer and drain in FixedUpdate:

#[derive(Resource, Default)]
struct PendingDamage(Vec<PlayerDamageRequest>);

app.add_godot_event::<PlayerDamageRequest>("damage", |v| {
    let dict = v.try_to::<VarDictionary>().ok()?;
    let node = dict.get("source")?.try_to::<Gd<Node>>().ok()?;   // the old `source`
    Some(PlayerDamageRequest {
        target: GodotNodeHandle::from_instance_id(node.instance_id()),
        force: dict.get("amount")?.try_to::<f32>().ok()?,
    })
})
.init_resource::<PendingDamage>()
.add_observer(|ev: On<PlayerDamageRequest>, mut q: ResMut<PendingDamage>| q.0.push(ev.event().clone()))
.add_systems(FixedUpdate, |mut q: ResMut<PendingDamage>, idx: Res<NodeEntityIndex>| {
    for req in q.0.drain(..) {
        let Some(_entity) = idx.get(req.target.instance_id()) else { continue };
        // apply req.force to _entity
    }
});
# Was: each enemy set a mailbox field on itself every tick; the drain scanned every Enemy node.
BevyAppSingleton.send_event("damage", {"source": self, "amount": 10.0})

Migration Guides

This section contains migration guides for various versions.

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

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:

New features:

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

New: Connect to non-entity objects with connect_object

A new connect_object method allows connecting to signals from Godot objects that aren't tracked as ECS entities, such as SceneTree:

#![allow(unused)]
fn main() {
use godot_bevy::prelude::*;
use godot_bevy::interop::signal_names::SceneTreeSignals;

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

fn connect_scene_tree(
    signals: GodotSignals<SceneChanged>,
    mut scene_tree: SceneTreeRef,
) {
    let tree = scene_tree.get().clone();
    signals.connect_object(tree, SceneTreeSignals::SCENE_CHANGED, |_args| {
        Some(SceneChanged)
    });
}
}

This is useful for:

  • SceneTree signals (scene_changed, tree_changed, etc.)
  • Autoload singletons that emit signals
  • Any Godot object not tracked as an ECS entity

See the Signal Handling guide for more details.

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.

Migration Guide: v0.9 to v0.10

This guide covers breaking changes and new features when upgrading from godot-bevy 0.9.x to 0.10.0.

Table of Contents

Breaking changes:

Breaking: Upgrade to Bevy 0.17

✨ Godot-Bevy is now up to date with Bevy 0.17! ✨

Here is the Bevy 0.16 to 0.17 migration guide for reference: https://bevy.org/learn/migration-guides/0-16-to-0-17/

The biggest difference is that Godot-Bevy now uses Message instead of Event.

Migration Path

Cargo.toml

🗑️ Before:

bevy = { version = "0.16", default-features = false, features = ["bevy_state"] }
bevy_asset_loader = "0.23.0"

🟢 After:

bevy = { version = "0.17", default-features = false, features = ["bevy_state"] }
bevy_asset_loader = "0.24.0-rc.1"

Event to Message

🗑️ Before:

#[derive(Event, Debug)]
pub enum SceneOperationEvent {

🟢 After:

#[derive(Message, Debug)]
pub enum SceneOperationMessage {

🗑️ Before:

app.add_event::<SceneOperationEvent>()

🟢 After:

app.add_message::<SceneOperationMessage>()

🗑️ Before:

mut operation_events: EventReader<SceneOperationEvent>,

🟢 After:

mut operation_events: MessageReader<SceneOperationMessage>,

Migration Checklist

  • Update Bevy to 0.17.
  • Update Bevy Asset Loader to 0.24.0-rc.1.
  • Replace usages of Event with Message.

Breaking: Custom Scene Tree Relationships

Godot's scene tree is now represented by a custom ECS relationship: GodotChildOf / GodotChildren. The built-in Bevy ChildOf / Children relationship is no longer used for scene tree mirroring.

This avoids conflicts with other plugins that use Bevy's hierarchy for their own purposes (physics, AI, scene graphs, etc.).

Migration Path

Queries and Traversal

🗑️ Before:

fn parent_of(entity: Entity, query: Query<&ChildOf>) -> Option<Entity> {
    query.get(entity).ok().map(|parent| parent.parent())
}

fn children_of(entity: Entity, query: Query<&Children>) -> Vec<Entity> {
    query
        .get(entity)
        .map(|children| children.iter().copied().collect())
        .unwrap_or_default()
}

🟢 After:

fn parent_of(entity: Entity, query: Query<&GodotChildOf>) -> Option<Entity> {
    query.get(entity).ok().map(|parent| parent.get())
}

fn children_of(entity: Entity, query: Query<&GodotChildren>) -> Vec<Entity> {
    query
        .get(entity)
        .map(|children| children.iter().copied().collect())
        .unwrap_or_default()
}

Configuration

The scene_tree_add_child_relationship attribute and GodotSceneTreePlugin { add_child_relationship: ... } have been removed.

If you want children to outlive their parents, use the new scene_tree_auto_despawn_children attribute (or the plugin config).

🗑️ Before:

#[bevy_app(scene_tree_add_child_relationship = false)]
fn build_app(app: &mut App) {}

app.add_plugins(GodotSceneTreePlugin {
    add_child_relationship: true,
});

🟢 After:

#[bevy_app(scene_tree_auto_despawn_children = false)]
fn build_app(app: &mut App) {}

app.add_plugins(GodotSceneTreePlugin {
    auto_despawn_children: true,
});

Breaking changes

  • Bevy ChildOf / Children are no longer used for Godot scene tree hierarchy.
  • scene_tree_add_child_relationship was removed.
  • New scene_tree_auto_despawn_children configuration option.

Migration Checklist

  • Replace ChildOf / Children queries with GodotChildOf / GodotChildren.
  • Remove scene_tree_add_child_relationship usage.
  • Use scene_tree_auto_despawn_children if you need to keep children alive on parent despawn.

Breaking: NodeTreeView::from_node now returns a Result type

NodeTreeView::from_node derive macro now returns a Result<Self, NodeTreeViewError> type to avoid surprise panic in user code if a godot node is not found.

Migration Path

Use match, if let or unwrap to handle the Result type returned by NodeTreeView::from_node.

Example 1:

🗑️ Before:

let mut mob_nodes = MobNodes::from_node(mob);

🟢 After:

let mut mob_nodes = MobNodes::from_node(mob).unwrap();

Example 2:

🗑️ Before:

match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| MenuUi::from_node(root))) {
    Ok(menu_ui) => {
        info!("MainMenu: Successfully found menu nodes");
    }
    Err(_) => {
        debug!("MainMenu: Menu nodes not ready yet, will retry next frame");
    }
}

🟢 After:

match MenuUi::from_node(root) {
    Ok(menu_ui) => {
        info!("MainMenu: Successfully found menu nodes");
    }
    Err(_) => {
        debug!("MainMenu: Menu nodes not ready yet, will retry next frame");
    }
}

Breaking changes

Return type of NodeTreeView::from_node derive macro changed from Self to Result<Self, NodeTreeViewError>.

Migration Checklist

  • Use match, if let or unwrap to handle the Result type returned by NodeTreeView::from_node.

Breaking: connect_map Callback Now Returns Option<T>

The callback provided to TypedGodotSignals::connect_map (and related APIs) must now return Option<T> instead of T. When an event is emitted, return Some(event) or return None to suppress event emission. This applies to both immediate (connect_map) and deferred (TypedDeferredSignalConnections) signal connections.

This enables advanced uses like filtering certain signal events, but requires code update for all users of connect_map and deferred connection mappers.

Migration Path

🗑️ Before (old API: return event value directly):

typed.connect_map(&mut node, "signal", None, |_args, _node_id, _ent| MyEvent {});
// or for deferred:
TypedDeferredSignalConnections::<MyEvent>::with_connection("signal", |_a, _node_id, _e| MyEvent {});

🟢 After (new API: return event value inside Some, or None to suppress):

typed.connect_map(&mut node, "signal", None, |_args, _node_id, _ent| Some(MyEvent {}));
// or for deferred:
TypedDeferredSignalConnections::<MyEvent>::with_connection("signal", |_a, _node_id, _e| Some(MyEvent {}));

Migration Checklist

  • Update all usages of TypedGodotSignals<...>::connect_map to return Some(event) (or None to suppress event).
  • Update all TypedDeferredSignalConnections::<...>::with_connection mappers for typed deferred signals to return Option<T>.

Migration Guide: v0.8 to v0.9

This guide covers breaking changes and new features when upgrading from godot-bevy 0.8.x to 0.9.0.

Table of Contents

Godot Bevy now uses standard Bevy Transforms (Breaking Change)

What Changed

In v.0.9.0, we've made significant changes to how we use bevy Transform components. We now operate directly on standard Transform components and you can too, whereas before, we had wrapped the Transform component in higher level Transform2D and Transform3D components and required you to use the wrappers. While wrapping provided important benefits (change detection, dual-godot/bevy-API access with built-in multi-threaded safety) it came with some notable drawbacks (incompatible with other bevy ecosystem plugins that operate directly on Transforms, extra memory overhead, less ergonomic as it required extra API calls to access the underlying data).

Migration Path

The main change is switching all of your usages of godot_bevy::prelude::Transform2D or godot_bevy::prelude::Transform3D to bevy::transform::components::Transform.

Before (v.0.9.0)

#![allow(unused)]
fn main() {
fn orbit_system(
    // The `transform` parameter is a Bevy `Query` that matches all `Transform2D` components.
    // `Transform2D` is a Godot-Bevy-provided component that matches all Node2Ds in the scene.
    // (https://docs.rs/godot-bevy/latest/godot_bevy/plugins/core/transforms/struct.Transform2D.html)
    mut transform: Query<(&mut Transform2D, &InitialPosition, &mut Orbiter)>,

    // This is equivalent to Godot's `_process` `delta: float` parameter.
    process_delta: Res<Time>,
) {
    // For single matches, you can use `single_mut()` instead:
    // `if let Ok(mut transform) = transform.single_mut() {`
    for (mut transform, initial_position, mut orbiter) in transform.iter_mut() {
        transform.as_godot_mut().origin =
            initial_position.pos + Vector2::from_angle(orbiter.angle) * 100.0;
        orbiter.angle += process_delta.as_ref().delta_secs();
        orbiter.angle %= 2.0 * PI;
    }
}
}

After (v.0.9.0)

#![allow(unused)]
fn main() {
fn orbit_system(
    // The `transform` parameter is a Bevy `Query` that matches all `Transform` components.
    // `Transform` is a Godot-Bevy-provided component that matches all Node2Ds in the scene.
    // (https://docs.rs/godot-bevy/latest/godot_bevy/plugins/core/transforms/struct.Transform.html)
    mut transform: Query<(&mut Transform, &InitialPosition, &mut Orbiter)>,

    // This is equivalent to Godot's `_process` `delta: float` parameter.
    process_delta: Res<Time>,
) {
    // For single matches, you can use `single_mut()` instead:
    // `if let Ok(mut transform) = transform.single_mut() {`
    for (mut transform, initial_position, mut orbiter) in transform.iter_mut() {
        let position2d = initial_position.pos + Vector2::from_angle(orbiter.angle) * 100.0;
        transform.translation.x = position2d.x;
        transform.translation.y = position2d.y;
        orbiter.angle += process_delta.as_ref().delta_secs();
        orbiter.angle %= 2.0 * PI;
    }
}
}

Breaking changes

  • godot_bevy::prelude::Transform2D and godot_bevy::prelude::Transform3D were removed

Migration Checklist

  • Transform components changed: Replaced godot_bevy::prelude::Transform2D and godot_bevy::prelude::Transform3D with bevy::transform::components::Transform. The APIs from the former must be mapped to the latter:
    • Remove the now extra as_bevy() and as_bevy_mut() calls, since you're operating directly on bevy Transforms, e.g., transform.as_bevy_mut().translation.x -> transform.translation.x. These changes should be easy.
    • Remap the as_godot() and as_godot_mut() calls. These changes may be tricky, as there may not be direct replacements for all Godot APIs in native Bevy transforms. One important benefit of doing this work is that it promotes a clean separation where your bevy transform systems remain portable to other Bevy projects (with or without godot-bevy). You can always fall back on using GodotNodeHandle to get at the original Godot Node APIs, then replicate position, scale, and rotation back to the bevy Transform as necessary.

Assets Plugin Moved to Optional (Breaking Change)

What Changed

In v0.9.0, GodotAssetsPlugin has been moved from GodotCorePlugins (included by default) to GodotDefaultPlugins (optional). This change provides a cleaner architecture where core functionality is truly minimal and reduces runtime overhead for applications that don't need to load Godot resources through Bevy's asset system.

Who Is Affected

You are affected if:

  • You use GodotCorePlugins directly (without GodotDefaultPlugins)
  • You load Godot resources using Handle<GodotResource> or AssetServer
  • You use GodotAudioPlugin or GodotPackedScenePlugin (they require assets)

Migration Path

If you use GodotDefaultPlugins

No changes needed - GodotAssetsPlugin is included in GodotDefaultPlugins.

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.add_plugins(GodotDefaultPlugins); // ✅ Assets included
}
}

If you use GodotCorePlugins and need asset loading

Add GodotAssetsPlugin explicitly:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    // GodotCorePlugins no longer includes assets
    app.add_plugins(GodotAssetsPlugin)      // Add this line
       .add_plugins(GodotAudioPlugin)       // Requires GodotAssetsPlugin
       .add_plugins(GodotPackedScenePlugin); // Requires GodotAssetsPlugin
}
}

If you don't need asset loading

No changes needed - enjoy reduced runtime overhead!

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    // Now truly minimal - no asset loading overhead
    app.add_plugins(GodotTransformSyncPlugin)
       .add_plugins(GodotSignalsPlugin)
       .add_plugins(BevyInputBridgePlugin);
}
}

Breaking Changes

  • GodotCorePlugins no longer includes GodotAssetsPlugin
  • GodotAssetsPlugin is now in GodotDefaultPlugins
  • GodotAudioPlugin and GodotPackedScenePlugin require GodotAssetsPlugin to function

Migration Checklist

  • Using GodotDefaultPlugins: No action needed
  • Using GodotCorePlugins + asset loading: Add app.add_plugins(GodotAssetsPlugin)
  • Using GodotAudioPlugin: Ensure GodotAssetsPlugin is included
  • Using GodotPackedScenePlugin: Ensure GodotAssetsPlugin is included
  • Pure ECS without assets: Consider removing unused plugins for better runtime performance

Gamepad Support Now Optional

What Changed

In v0.9.0, gamepad support through Bevy's GilrsPlugin is now controlled by an optional feature flag bevy_gamepad. This feature is enabled by default but can be disabled to reduce compile time and dependencies for applications that don't use gamepads.

Migration Path

If you use gamepads with Bevy's input API

No changes needed if using GodotDefaultPlugins - GilrsPlugin is included automatically.

If using custom plugin setup: Add GilrsPlugin manually:

#![allow(unused)]
fn main() {
use godot_bevy::prelude::*; // Includes bevy_prelude::GilrsPlugin

#[bevy_app]
fn build_app(app: &mut App) {
    app.add_plugins(GilrsPlugin)  // Available via bevy_prelude
       .add_plugins(BevyInputBridgePlugin);
}
}

If you only use Godot's gamepad input

No changes needed - Godot's gamepad support through GodotInputEventPlugin works regardless of the feature flag.

If you don't use gamepads at all

Optional: Disable the feature for faster compile times:

[dependencies]
godot-bevy = { version = "0.9", default-features = false, features = [...] }

What Still Works Without the Feature

  • ✅ Godot's gamepad input via GodotInputEventPlugin
  • ✅ Raw gamepad events in EventReader<GamepadButtonInput> and EventReader<GamepadAxisInput>
  • ✅ All keyboard, mouse, and touch input

What Requires the Feature

  • ❌ Bevy's standard gamepad API (ButtonInput<GamepadButton>, Axis<GamepadAxis>)
  • GilrsPlugin functionality
  • ❌ Cross-platform gamepad detection outside of Godot

Note: GilrsPlugin is included in GodotDefaultPlugins when the feature is enabled, but must be added manually if using a custom plugin configuration.

Scene Tree Plugin Configuration Simplified

What Changed

The add_transforms configuration option has been removed from GodotSceneTreePlugin. Transform components are now automatically added to scene tree entities when the GodotTransformSyncPlugin is included in your app.

Migration Path

If you were using the add_transforms configuration option, you can simply remove it. Transform components will be automatically added if you include the transform plugin.

Before (v0.8.x)

#![allow(unused)]
fn main() {
app.add_plugins(GodotSceneTreePlugin {
    add_transforms: true,
    add_child_relationship: true,
});
}

After (v0.9.0)

#![allow(unused)]
fn main() {
// Transform components are automatically added when GodotTransformSyncPlugin is included
app.add_plugins(GodotSceneTreePlugin {
    add_child_relationship: true,
});

// Add the transform plugin to get automatic transform components
app.add_plugins(GodotTransformSyncPlugin::default());
}

Migration Guide: v0.7 to v0.8

This guide covers breaking changes and new features when upgrading from godot-bevy 0.7.x to 0.8.0.

Table of Contents

Opt-in Plugin System (Breaking Change)

What Changed

In v0.8.0, godot-bevy has adopted Bevy's philosophy of opt-in plugins. This gives users granular control over which features are included in their build.

Breaking Change: GodotPlugin now only includes minimal core functionality by default (basic scene tree access and assets). Automatic entity creation and other features must be explicitly opted-in.

Migration Path

The quickest migration is to use GodotDefaultPlugins for the old behavior, but we recommend adding only the plugins you need.

Option 1: Quick Migration (Old Behavior)

Replace the #[bevy_app] macro usage with explicit plugin registration:

Before (v0.7.x):

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    // GodotPlugin automatically included all features:
    // - Automatic entity creation for scene tree nodes
    // - Transform synchronization
    // - Collision detection
    // - Signal handling
    // - Input events
    // - Audio system
    app.add_systems(Update, my_game_systems);
}
}

After (v0.8.0) - Quick Fix:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    // Add all features like before
    app.add_plugins(GodotDefaultPlugins);
    app.add_systems(Update, my_game_systems);
}
}

Pure ECS game (transforms + basic features):

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.add_plugins(GodotSceneTreeMirroringPlugin {
            add_transforms: true,
        })
        .add_plugins(GodotTransformSyncPlugin::default())  // OneWay sync
        .add_plugins(GodotAudioPlugin);                    // Audio system
    app.add_systems(Update, my_game_systems);
}
}

Platformer (no transform conflicts):

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.add_plugins(GodotSceneTreeMirroringPlugin {
            add_transforms: false,  // Use Godot physics instead
        })
        .add_plugins(GodotCollisionsPlugin)         // Collision detection
        .add_plugins(GodotAudioPlugin)              // Audio system
        .add_plugins(GodotSignalsPlugin);           // UI signals
    app.add_systems(Update, my_game_systems);
}
}

Full-featured game:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    app.add_plugins(GodotDefaultPlugins);   // Everything
    app.add_systems(Update, my_game_systems);
}
}

Available Plugins

Core (Always Included):

  • GodotCorePlugins - Basic scene tree access, assets, basic setup (automatically included by #[bevy_app])

Scene Tree Plugins:

  • GodotSceneTreeRefPlugin - Basic scene tree access (included in GodotCorePlugins)
  • GodotSceneTreeEventsPlugin - Monitor scene tree changes without creating entities
  • GodotSceneTreeMirroringPlugin - Auto-create entities for scene nodes (equivalent to v0.7.x behavior)

Optional Feature Plugins:

  • GodotTransformSyncPlugin - Add if you want to move/position nodes from Bevy systems
  • GodotAudioPlugin - Add if you want to play sounds and music from Bevy systems
  • GodotSignalsPlugin - Add if you want to respond to Godot signals (button clicks, etc.) in Bevy systems
  • GodotCollisionsPlugin - Add if you want to detect collisions and physics events in Bevy systems
  • GodotInputEventPlugin - Add if you want to handle input from Godot in Bevy systems
  • BevyInputBridgePlugin - Add if you prefer Bevy's input API (auto-includes GodotInputEventPlugin)
  • GodotPackedScenePlugin - Add if you want to spawn scenes dynamically from Bevy systems

Convenience Bundles:

  • GodotDefaultPlugins - All plugins enabled (equivalent to old v0.7.x behavior)

Plugin Dependencies

Some plugins automatically include their dependencies:

  • GodotSceneTreeMirroringPlugin automatically includes GodotSceneTreeEventsPlugin
  • BevyInputBridgePlugin automatically includes GodotInputEventPlugin

Benefits of the New System

  1. Smaller binaries - Only compile what you use
  2. Better performance - Skip unused systems
  3. Clearer dependencies - Explicit about what your game needs
  4. Future-proof - Easy to add new optional features

Migration Checklist

  • Quick fix: Add app.add_plugins(GodotDefaultPlugins) to your build_app function
  • Optimization: Replace GodotDefaultPlugins with only the specific plugins you need
  • Test: Ensure all features work correctly with your plugin selection
  • Consider: Whether you can disable some features (e.g., transform sync for physics games)

GodotSignals Resource (Breaking Change)

What Changed

In v0.8.0, the signal connection system has been significantly simplified and improved:

New GodotSignals SystemParam: Signal connections are now handled through a dedicated GodotSignals resource

Migration Path

The main change is switching from the standalone connect_godot_signal function to the new GodotSignals SystemParam.

Before (v0.7.x)

#![allow(unused)]
fn main() {
use godot_bevy::prelude::*;

fn connect_signals(
    mut scene_tree: SceneTreeRef,
) {
    if let Some(root) = scene_tree.get().get_root() {
        if let Some(button) = root.try_get_node_as::<Button>("UI/MyButton") {
            let mut handle = GodotNodeHandle::from_instance_id(button.instance_id());
            // Old function signature required SceneTreeRef parameter
            connect_godot_signal(&mut handle, "pressed", &mut scene_tree);
        }
    }
}
}

After (v0.8.0)

#![allow(unused)]
fn main() {
use godot_bevy::prelude::*;

fn connect_signals(
    mut scene_tree: SceneTreeRef,
    signals: GodotSignals,  // ← New SystemParam
) {
    if let Some(root) = scene_tree.get().get_root() {
        if let Some(button) = root.try_get_node_as::<Button>("UI/MyButton") {
            let mut handle = GodotNodeHandle::from_instance_id(button.instance_id());
            // New simplified API
            signals.connect(&mut handle, "pressed");
        }
    }
}
}

Breaking Changes

  1. Function signature changed: connect_godot_signal no longer requires SceneTreeRef parameter
  2. New SystemParam required: Add GodotSignals parameter to systems that connect signals
  3. Recommended API change: Use signals.connect() instead of direct connect_godot_signal() calls

Migration Checklist

  • Add GodotSignals parameter to systems that connect signals
  • Replace connect_godot_signal(&mut handle, signal_name, &mut scene_tree) with signals.connect(&mut handle, signal_name)
  • Remove unused SceneTreeRef parameters if they were only used for signal connections
  • Test that all signal connections work correctly with the new system

Summary

The v0.8.0 signal system simplifies signal connections while improving performance. The main migration step is:

  1. Add GodotSignals parameter to systems that connect signals
  2. Replace connect_godot_signal(&mut handle, signal, &mut scene_tree) with signals.connect(&mut handle, signal)
  3. Remove unused SceneTreeRef parameters

The signal event handling (EventReader<GodotSignal>) remains unchanged, so only the connection setup needs to be updated.

Multithreaded Bevy and #[main_thread_system] (New Feature)

What's New

In v0.8.0, godot-bevy now enables Bevy's multithreaded task executor by default, allowing systems to run in parallel for better performance. However, since Godot's APIs are not thread-safe, we've introduced the #[main_thread_system] attribute to mark systems that must run on the main thread.

Key Changes:

  1. Multithreaded Bevy enabled: Systems can now run in parallel by default
  2. New #[main_thread_system] attribute: Mark systems that use Godot APIs
  3. Better performance: ECS systems can utilize multiple CPU cores

Migration Path

Most existing code will continue to work without changes, but you should add the #[main_thread_system] attribute to any system that directly calls Godot APIs.

When to Use #[main_thread_system]

Add this attribute to systems that:

  • Use SceneTreeRef or other Godot resources
  • Call any Godot API functions that are not thread-safe

Examples

Systems that need #[main_thread_system]:

#![allow(unused)]
fn main() {
use godot_bevy::prelude::*;

// ✅ Using SceneTreeRef - needs main thread
#[main_thread_system]
fn spawn_enemy(
    mut commands: Commands,
    scene_tree: SceneTreeRef,
    enemy_spawner: Res<EnemySpawner>,
) {
    if let Some(scene) = scene_tree.get().get_root() {
        // Spawn enemy logic using Godot APIs
    }
}

// ✅ Calling non-thread-safe Godot APIs - needs main thread
#[main_thread_system]
fn play_sound_effects(
    mut audio_events: EventReader<AudioEvent>,
    audio_player: Res<AudioStreamPlayer>,
) {
    for event in audio_events.read() {
        // Direct Godot API calls are not thread-safe
        audio_player.play();
    }
}
}

Benefits

  1. Better Performance: ECS systems can now utilize multiple CPU cores
  2. Explicit Threading: Clear distinction between main-thread and multi-thread systems
  3. Safety: Prevents accidental concurrent access to Godot APIs
  4. Scalability: Better performance on multi-core systems

Migration Checklist

  • Review existing systems: Identify which systems use Godot APIs
  • Add #[main_thread_system]: Mark systems that use SceneTreeRef or call non-thread-safe Godot APIs
  • Test performance: Verify that multithreading improves your game's performance
  • Consider refactoring: Separate pure ECS logic from Godot API calls for better parallelization

Common Patterns

Pattern 1: Separate data processing from rendering

#![allow(unused)]
fn main() {
// Multi-threaded: Process game logic
fn calculate_damage(
    mut health_query: Query<&mut Health>,
    damage_events: EventReader<DamageEvent>,
) {
    // Pure ECS logic - runs on any thread
}

// Main thread: Use SceneTreeRef for scene management
#[main_thread_system]
fn update_scene_structure(
    scene_tree: SceneTreeRef,
    spawn_events: EventReader<SpawnEvent>,
) {
    // SceneTreeRef access - runs on main thread
}
}

Pattern 2: Use events to bridge threads

#![allow(unused)]
fn main() {
// Multi-threaded: Game logic generates events
fn enemy_ai_system(
    mut attack_events: EventWriter<AttackEvent>,
    enemy_query: Query<&Transform, With<Enemy>>,
) {
    // Send events instead of directly calling Godot APIs
}

// Main thread: Handle events with non-thread-safe Godot APIs
#[main_thread_system]
fn handle_attack_events(
    mut attack_events: EventReader<AttackEvent>,
    audio_player: Res<AudioStreamPlayer>,
) {
    // Process events using non-thread-safe Godot APIs
    for event in attack_events.read() {
        audio_player.play();
    }
}
}

Summary

The multithreaded Bevy feature significantly improves performance by allowing systems to run in parallel. The main migration step is adding #[main_thread_system] to systems that use Godot APIs, ensuring thread safety while maximizing performance.

BevyBundle Enhanced Property Mapping (New Feature)

What's New

In v0.8.0, the BevyBundle macro has been significantly enhanced with more flexible property mapping options:

  1. Struct Component Mapping: Map multiple Godot properties to fields in a struct component
  2. Transform Functions: Apply transformation functions to convert values during mapping
  3. Improved Syntax: More intuitive syntax for single and multi-field mappings

New Mapping Options

Struct Component Mapping

You can now map multiple Godot properties to fields in a struct component:

#![allow(unused)]
fn main() {
#[derive(Component)]
struct Stats {
    health: f32,
    mana: f32,
    stamina: f32,
}

#[derive(GodotClass, BevyBundle)]
#[class(base=CharacterBody2D)]
#[bevy_bundle((Player), (Stats { health: max_health, mana: max_mana, stamina: max_stamina }))]
pub struct PlayerCharacter {
    base: Base<CharacterBody2D>,
    #[export] max_health: f32,
    #[export] max_mana: f32,
    #[export] max_stamina: f32,
}
}

Transform Functions

Apply transformation functions to convert Godot values before assigning to components:

#![allow(unused)]
fn main() {
fn percentage_to_fraction(value: f32) -> f32 {
    value / 100.0
}

#[derive(GodotClass, BevyBundle)]
#[class(base=Node2D)]
#[bevy_bundle((Enemy), (Health: health_percentage))]
pub struct Enemy {
    base: Base<Node2D>,
    #[export]
    #[bundle(transform_with = "percentage_to_fraction")]
    health_percentage: f32,  // Editor shows 0-100, component gets 0.0-1.0
}
}

Backwards Compatibility

All existing v0.7.x BevyBundle syntax remains fully supported:

#![allow(unused)]
fn main() {
// Still works in v0.8.0
#[bevy_bundle((Player), (Health: max_health))]
}

Benefits

  • Better Component Design: Create struct components that group related data
  • Editor-Friendly Values: Use transform functions to convert between editor-friendly and system-friendly values
  • Type Safety: All mappings are verified at compile time
  • Flexibility: Mix and match different mapping styles as needed

For complete documentation on the new features, see the Custom Node Markers section.

Migration Guide: v0.6 to v0.7

This guide covers breaking changes and new features when upgrading from godot-bevy 0.6.x to 0.7.0.

Table of Contents

Node Type Markers (New Feature)

What Changed

Starting in v0.7.0, all entities representing Godot nodes automatically receive marker components that indicate their node type. This enables type-safe, efficient ECS queries without runtime type checking.

Migration Path

This change is backwards compatible - your existing code will continue to work. However, you can improve performance and safety by migrating to marker-based queries.

Before (v0.6.x approach - still works)

#![allow(unused)]
fn main() {
use godot_bevy::prelude::*;

fn update_sprites(mut all_nodes: Query<&mut GodotNodeHandle>) {
    for mut handle in all_nodes.iter_mut() {
        // Runtime type checking - works but inefficient
        if let Some(sprite) = handle.try_get::<Sprite2D>() {
            sprite.set_modulate(Color::RED);
        }
    }
}

fn update_character_bodies(mut all_nodes: Query<&mut GodotNodeHandle>) {
    for mut handle in all_nodes.iter_mut() {
        // Check every single entity in your scene
        if let Some(mut body) = handle.try_get::<CharacterBody2D>() {
            body.move_and_slide();
        }
    }
}
}
#![allow(unused)]
fn main() {
use godot_bevy::prelude::*;

fn update_sprites(mut sprites: Query<&mut GodotNodeHandle, With<Sprite2DMarker>>) {
    for mut handle in sprites.iter_mut() {
        // ECS pre-filters to only Sprite2D entities - much faster!
        let sprite = handle.get::<Sprite2D>(); // No Option<> - guaranteed to work
        sprite.set_modulate(Color::RED);
    }
}

fn update_character_bodies(mut bodies: Query<&mut GodotNodeHandle, With<CharacterBody2DMarker>>) {
    for mut handle in bodies.iter_mut() {
        // Only iterates over CharacterBody2D entities
        let mut body = handle.get::<CharacterBody2D>();
        body.move_and_slide();
    }
}
}

Benefits of Migration

  1. Performance: Only iterate over entities you care about
  2. Safety: No more Option<> handling or potential panics
  3. Clarity: Query signatures clearly show what node types you expect
  4. Optimization: Better ECS query optimization and caching

Common Migration Patterns

Pattern 1: Single Node Type

Before:

#![allow(unused)]
fn main() {
fn system(mut all_nodes: Query<&mut GodotNodeHandle>) {
    for mut handle in all_nodes.iter_mut() {
        if let Some(mut timer) = handle.try_get::<Timer>() {
            if timer.is_stopped() {
                timer.start();
            }
        }
    }
}
}

After:

#![allow(unused)]
fn main() {
fn system(mut timers: Query<&mut GodotNodeHandle, With<TimerMarker>>) {
    for mut handle in timers.iter_mut() {
        let mut timer = handle.get::<Timer>();
        if timer.is_stopped() {
            timer.start();
        }
    }
}
}

Pattern 2: Multiple Node Types

Before:

#![allow(unused)]
fn main() {
fn audio_system(mut all_nodes: Query<&mut GodotNodeHandle>) {
    for mut handle in all_nodes.iter_mut() {
        if let Some(mut player) = handle.try_get::<AudioStreamPlayer>() {
            player.set_volume_db(-10.0);
        } else if let Some(mut player_2d) = handle.try_get::<AudioStreamPlayer2D>() {
            player_2d.set_volume_db(-10.0);
        } else if let Some(mut player_3d) = handle.try_get::<AudioStreamPlayer3D>() {
            player_3d.set_volume_db(-10.0);
        }
    }
}
}

After:

#![allow(unused)]
fn main() {
fn audio_system(
    mut players_1d: Query<&mut GodotNodeHandle, With<AudioStreamPlayerMarker>>,
    mut players_2d: Query<&mut GodotNodeHandle, With<AudioStreamPlayer2DMarker>>,
    mut players_3d: Query<&mut GodotNodeHandle, With<AudioStreamPlayer3DMarker>>,
) {
    // Process each type separately - much more efficient!
    for mut handle in players_1d.iter_mut() {
        let mut player = handle.get::<AudioStreamPlayer>();
        player.set_volume_db(-10.0);
    }
    
    for mut handle in players_2d.iter_mut() {
        let mut player = handle.get::<AudioStreamPlayer2D>();
        player.set_volume_db(-10.0);
    }
    
    for mut handle in players_3d.iter_mut() {
        let mut player = handle.get::<AudioStreamPlayer3D>();
        player.set_volume_db(-10.0);
    }
}
}

Pattern 3: Complex Conditions

Before:

#![allow(unused)]
fn main() {
fn physics_sprites(mut all_nodes: Query<&mut GodotNodeHandle>) {
    for mut handle in all_nodes.iter_mut() {
        if let Some(sprite) = handle.try_get::<Sprite2D>() {
            if let Some(body) = handle.try_get::<RigidBody2D>() {
                // Entity has both Sprite2D and RigidBody2D
                handle_physics_sprite(sprite, body);
            }
        }
    }
}
}

After:

#![allow(unused)]
fn main() {
fn physics_sprites(
    mut entities: Query<&mut GodotNodeHandle, (With<Sprite2DMarker>, With<RigidBody2DMarker>)>
) {
    for mut handle in entities.iter_mut() {
        // ECS guarantees both components exist
        let sprite = handle.get::<Sprite2D>();
        let body = handle.get::<RigidBody2D>();
        handle_physics_sprite(sprite, body);
    }
}
}

Available Marker Components

All marker components are available in the prelude:

#![allow(unused)]
fn main() {
use godot_bevy::prelude::*;

// Examples of available markers:
// Sprite2DMarker, CharacterBody2DMarker, Area2DMarker, 
// AudioStreamPlayerMarker, LabelMarker, ButtonMarker,
// Camera2DMarker, RigidBody2DMarker, etc.
}

See the complete list of markers in the querying documentation.

Performance Impact

Marker-based queries provide several performance advantages:

  • Reduced iteration: Only process entities that match your node type, rather than checking every entity in the scene
  • Eliminated runtime type checking: Skip try_get() calls since the ECS guarantees type matches
  • Better cache locality: Process similar entities together rather than jumping between different node types
  • ECS optimization: Bevy can better optimize queries when it knows the component filters upfront

The actual performance improvement will depend on your scene size and how many entities match your queries, but the benefits are most noticeable in systems that run frequently (like every frame) and in larger scenes.

When NOT to Migrate

You might want to keep the old approach if:

  1. Rare usage: The system runs infrequently and performance isn't critical
  2. Dynamic typing: You genuinely need to handle unknown node types at runtime
  3. Gradual migration: You're updating a large codebase incrementally

The old try_get() patterns will continue to work indefinitely.

Troubleshooting

"Entity doesn't have expected component"

If you get panics when using .get() instead of .try_get(), it usually means:

  1. Wrong marker: Make sure you're using the right marker for your query
  2. Node freed: The Godot node was freed but the entity still exists
  3. Timing issue: The node was removed between query execution and access

Solution: Use marker-based queries to ensure type safety, or fall back to .try_get() if needed.

"Query doesn't match any entities"

If your marker-based query returns no entities:

  1. Check node types: Verify your scene has the expected node types
  2. Check marker names: Ensure you're using the correct marker component
  3. Check timing: Make sure the scene tree has been processed

Solution: Use Query<&GodotNodeHandle, With<NodeMarker>> to see all entities, then check what markers they have.

Summary

The node type markers feature in v0.7.0 provides a significant upgrade to querying performance and type safety. While migration is optional, it's highly recommended for any systems that process specific Godot node types frequently.

The migration path is straightforward:

  1. Replace broad Query<&mut GodotNodeHandle> with specific marker queries
  2. Replace try_get() calls with get() when using markers
  3. Handle multiple node types with separate queries rather than runtime checks

This results in cleaner, faster, and safer code while maintaining the flexibility of the ECS architecture.

BevyBundle Autosync Simplification

What Changed

In v0.7.0, the autosync parameter has been removed from #[derive(BevyBundle)]. All BevyBundle derives now automatically register their bundles and apply them during scene tree processing.

Migration Path

This change requires minimal code changes but may affect your app architecture if you were manually managing bundle systems.

Before (v0.6.x)

#![allow(unused)]
fn main() {
// Manual autosync control
#[derive(GodotClass, BevyBundle)]
#[class(base=Node2D)]
#[bevy_bundle((Health), (Velocity), autosync=true)]  // ← autosync parameter
pub struct Player {
    base: Base<Node2D>,
}

// Alternative: manually registering the system
#[derive(GodotClass, BevyBundle)]
#[class(base=Node2D)]
#[bevy_bundle((Health), (Velocity))]  // ← autosync=false (default)
pub struct Enemy {
    base: Base<Node2D>,
}

#[bevy_app]
fn build_app(app: &mut App) {
    // Had to manually add the sync system
    app.add_systems(Update, EnemyAutoSyncPlugin);
}
}

After (v0.7.0)

#![allow(unused)]
fn main() {
// Automatic registration - much simpler!
#[derive(GodotClass, BevyBundle)]
#[class(base=Node2D)]
#[bevy_bundle((Health), (Velocity))]  // ← No autosync parameter needed
pub struct Player {
    base: Base<Node2D>,
}

#[derive(GodotClass, BevyBundle)]
#[class(base=Node2D)]
#[bevy_bundle((Health), (Velocity))]  // ← Always automatic now
pub struct Enemy {
    base: Base<Node2D>,
}

#[bevy_app]
fn build_app(app: &mut App) {
    // No manual system registration needed!
    // Bundles are automatically applied during scene tree processing
}
}

Breaking Changes

  1. Remove autosync=true: This parameter no longer exists and will cause compilation errors
  2. Remove manual sync systems: If you were manually adding bundle sync systems, remove them
  3. Timing change: Bundle components are now available in Startup systems (was previously only available in Update)

Benefits of This Change

  1. Simplified API: No need to remember to set autosync=true
  2. Better timing: Bundle components are available earlier in the frame lifecycle
  3. Unified behavior: Both initial scene loading and dynamic node addition work the same way
  4. No missed registrations: Impossible to forget to register a bundle system

Migration Checklist

  • Remove autosync=true and autosync=false from all #[bevy_bundle()] attributes
  • Remove any manually registered bundle sync systems from your app
  • Test that bundle components are available in Startup systems (they now are!)
  • Update any documentation or comments that reference the old autosync behavior

Example Migration

Before (v0.6.x):

#![allow(unused)]
fn main() {
#[derive(GodotClass, BevyBundle)]
#[class(base=CharacterBody2D)]
#[bevy_bundle((Speed: speed), (Health: max_health), autosync=true)]
pub struct Player {
    base: Base<CharacterBody2D>,
    #[export] speed: f32,
    #[export] max_health: f32,
}

#[bevy_app]
fn build_app(app: &mut App) {
    app.add_systems(Startup, setup_game)
       .add_systems(Update, player_movement);
}

fn setup_game(players: Query<&Health>) {
    // This would be empty in v0.6.x because bundles
    // weren't applied until the first Update
    println!("Found {} players", players.iter().count());
}
}

After (v0.7.0):

#![allow(unused)]
fn main() {
#[derive(GodotClass, BevyBundle)]
#[class(base=CharacterBody2D)]
#[bevy_bundle((Speed: speed), (Health: max_health))]  // ← Removed autosync
pub struct Player {
    base: Base<CharacterBody2D>,
    #[export] speed: f32,
    #[export] max_health: f32,
}

#[bevy_app]
fn build_app(app: &mut App) {
    app.add_systems(Startup, setup_game)
       .add_systems(Update, player_movement);
}

fn setup_game(players: Query<&Health>) {
    // This now works in Startup! Bundle components are available immediately
    println!("Found {} players", players.iter().count());
}
}

This change makes BevyBundle usage more intuitive and eliminates a common source of timing-related bugs.

Transform Sync Modes (Breaking Change)

What Changed

In v0.7.0, transform synchronization behavior has changed significantly:

  1. New TransformSyncMode system: Transform syncing is now configurable via GodotTransformConfig
  2. Default changed from two-way to one-way: Previously, transforms were synced bidirectionally by default. Now the default is one-way (ECS → Godot only)
  3. Explicit configuration required: You must now explicitly choose your sync mode

Migration Path

If your v0.6.x code relied on the implicit two-way transform sync, you need to explicitly enable it in v0.7.0.

Before (v0.6.x - implicit two-way sync)

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    // Transform syncing was always bidirectional
    app.add_systems(Update, movement_system);
}

fn movement_system(
    mut query: Query<&mut Transform2D>,
) {
    // Could read Godot transform changes automatically
}
}

After (v0.7.0 - explicit configuration)

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    // Restore v0.6.x behavior with explicit two-way sync
    app.insert_resource(GodotTransformConfig::two_way());
    
    app.add_systems(Update, movement_system);
}
}

Available Sync Modes

  1. TransformSyncMode::OneWay (NEW DEFAULT)

    • ECS transform changes update Godot nodes
    • Godot transform changes are NOT reflected in ECS
    • Best for pure ECS architectures
  2. TransformSyncMode::TwoWay (v0.6.x default behavior)

    • Full bidirectional sync between ECS and Godot
    • Required for Godot animations affecting ECS
    • Higher performance overhead
  3. TransformSyncMode::Disabled (NEW)

    • No transform components created
    • Zero sync overhead
    • Perfect for physics-only games

Common Migration Scenarios

Scenario 1: Using Godot's AnimationPlayer

If you use Godot's AnimationPlayer to move entities:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    // Must use two-way sync for animations
    app.insert_resource(GodotTransformConfig::two_way());
}
}

Scenario 2: Pure ECS Movement

If all movement is handled by Bevy systems:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    // One-way is the default, but you can be explicit
    app.insert_resource(GodotTransformConfig::one_way());
}
}

Scenario 3: Physics-Only Game

If using CharacterBody2D or RigidBody2D exclusively:

#![allow(unused)]
fn main() {
#[bevy_app]
fn build_app(app: &mut App) {
    // Disable transform syncing entirely
    app.insert_resource(GodotTransformConfig::disabled());
}
}

Breaking Changes Checklist

  • Default behavior changed: If you relied on reading Godot transform changes in ECS, you must enable two-way sync
  • Performance may improve: One-way sync has less overhead than the old default
  • New optimization opportunity: Consider disabling transforms for physics entities

Troubleshooting

"Transform changes in Godot not visible in ECS"

This is the most common issue when migrating. The solution is to enable two-way sync:

#![allow(unused)]
fn main() {
app.insert_resource(GodotTransformConfig::two_way());
}

"Transform components missing"

If you disabled sync mode but still need transforms:

#![allow(unused)]
fn main() {
// Either switch to one-way or two-way mode
app.insert_resource(GodotTransformConfig::one_way());
}

Performance Comparison

v0.6.x (implicit two-way):
- Read systems: Always running (PreUpdate)
- Write systems: Always running (Last)
- Overhead: O(all entities) every frame

v0.7.0 one-way (new default):
- Read systems: Not running
- Write systems: Running (Last)
- Overhead: O(changed entities) only

v0.7.0 disabled:
- No systems running
- Zero overhead

Summary

The transform sync system in v0.7.0 gives you explicit control over performance and behavior. While this is a breaking change for projects that relied on implicit two-way sync, it provides better defaults and more optimization opportunities. Simply add app.insert_resource(GodotTransformConfig::two_way()) to restore v0.6.x behavior.