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
- Performance: Bevy's ECS provides cache-friendly data layouts and parallel system execution
- Safety: Rust's type system catches bugs at compile time
- Modularity: ECS encourages clean, decoupled code architecture
- 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
- Discord: Join our community Discord
- Documentation: Check the API docs
- Examples: Browse the example projects
- Issues: Report bugs on GitHub
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:
- Rust 1.88.0 or later - Install Rust
- Godot 4.3 - Download Godot
- Basic familiarity with both Rust and Godot
Installation Methods
There are two ways to set up godot-bevy in your project:
- Plugin Installation (Recommended) - Use the godot-bevy editor plugin for automatic setup
- 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
- Download the
addons/godot-bevyfolder from the godot-bevy repository - Copy it to your Godot project's
addons/directory - In Godot, go to Project > Project Settings > Plugins
- Enable the "Godot-Bevy Integration" plugin
2. Create Your Project
- Go to Project > Tools > Setup godot-bevy Project
- 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
- Click "Create Project"
The plugin will automatically:
- Create a
rust/directory with Cargo.toml and lib.rs - Generate the
.gdextensionfile 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:
- Press F5 or click the play button
- 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:
- Open Godot and click "New Project"
- Choose a project name and location
- Select "Compatibility" renderer for maximum platform support
- 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
- In Godot, create a new scene
- Add a
BevyAppnode as the root - Save it as
bevy_app_singleton.tscn - Go to Project → Project Settings → Globals → Autoload
- 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
- Return to the Godot editor
- Press F5 or click the play button
- 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-bevyfolder 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
cargoinstalled 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.gdextensionmatch 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 nodeName- Node nameGroups- 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:
- Creates the GDExtension entry point
- Sets up the Bevy app
- Integrates with Godot's lifecycle
- 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
- Node added to scene tree
- Entity created with components
- Signals converted to events
- Input forwarded to systems
Bevy → Godot
- Transform components sync to nodes
- Commands can modify scene tree
- Resources can be loaded
- 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
GodotAccessare forced onto the main thread and run sequentially, so keep them small and push heavy work to parallel systems - Treat
GodotNodeHandleas an ID; resolve toGd<T>only viaGodotAccess - 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 viaGodotPlugin - Includes:
GodotBaseCorePlugin: Bevy MinimalPlugins, logging, diagnostics, schedulesGodotSceneTreePlugin: Scene tree entity mirroring and management
- Automatically included by
-
GodotDefaultPlugins: Contains all plugins typically necessary for building a game- Includes:
GodotAssetsPlugin: Godot resource loading through Bevy's asset systemGodotTransformSyncPlugin: Transform synchronizationGodotCollisionsPlugin: Collision detectionBevyInputBridgePlugin: Bevy input API supportGodotAudioPlugin: Audio systemGodotPackedScenePlugin: Runtime scene spawningGodotBevyLogPlugin: Unify/improve bevy and godot logging such thatinfo!,debug!, etc log messages are visible in the Godot Editor
- Includes:
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
NodeEntityIndexresource for O(1) lookup from GodotInstanceIdto BevyEntity
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), orTwoWay - Synchronizes Bevy Transform components with Godot node transforms
- Required for moving/positioning nodes from Bevy
- Configure sync mode:
-
GodotCollisionsPlugin: Collision detection- Monitors Area2D/3D and RigidBody2D/3D collision signals
- Provides
Collisionssystem param for querying collision state - Provides
CollisionStarted/CollisionEndedevents (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
- Use Bevy's standard
-
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.,
TforTRACE,DforDEBUG,IforINFO,WforWARN,EforERROR - Log messages are suffixed with a shortened path and line number location, e.g.,
@ loading_state/systems.rs:186 - Log level filtering is
INFOand higher severity by default, this can be customized directly in your code or set at runtime usingRUST_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→ includesGodotInputEventPluginGodotPlugin→ includesGodotCorePlugins
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 system →
GodotAssetsPlugin - Move/position nodes from Bevy →
GodotTransformSyncPlugin - Play sounds and music →
GodotAudioPlugin - Respond to UI signals →
GodotSignalsPlugin::<YourMessage> - Detect collisions →
GodotCollisionsPlugin - Handle input →
BevyInputBridgePluginorGodotInputEventPlugin - Spawn scenes at runtime →
GodotPackedScenePlugin
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
The examples directory contains four learning projects and a performance diagnostic. Run an example from the repository root:
cargo run --manifest-path examples/simple-node2d-movement/rust/Cargo.toml
Replace simple-node2d-movement with the directory name below. The launcher builds the Rust library, generates its GDExtension descriptor, and opens Godot. Put the Godot binary on your PATH.
Learning projects
| Project | Lesson |
|---|---|
| simple-node2d-movement | Move a sprite with Bevy systems and transform sync. |
| two-way-sync-demo | Let GDScript and Bevy write different axes of the same transform. |
| dodge-the-creeps-2d | Organize a game with states, signals, and a menu setting sent through the event bridge. |
| platformer-2d | Build gameplay around editor-authored nodes, exported components, collisions, and audio. |
These projects run natively. The platformer enables editor property documentation, which requires Godot 4.3 or later. The simple movement web build is blocked on #268; its CI job remains disabled.
Diagnostic tool
perf-test compares rendered particle load in GDScript and godot-bevy. Use it to investigate performance on your machine. It is separate from the learning sequence, and its FPS readings are diagnostic.
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:
connect_scene_tree- Sets up event listeners for runtime scene changes (nodes being added, removed, or renamed)initialize_scene_tree- Traverses the entire Godot scene tree and creates Bevy entities with components likeGodotNodeHandle,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 systemread_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 nodeName- The node's name from GodotGroups- 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
PreStartupfor nodes that exist when the scene is first loaded - During
Firstfor 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
- Use
Startupfor initialization - Scene entities are guaranteed to be ready - Use
Updatefor gameplay logic - This is where most of your game code should live - Custom
PreStartupsystems - If you add systems toPreStartup, 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:
- Godot emits a
node_addedsignal - The
SceneTreeWatcher(on the Godot side) receives the signal - It sends a
SceneTreeEventthrough an mpsc channel write_scene_tree_messages(inFirstschedule) reads from the channel and writes to Bevy's event systemread_scene_tree_messages(also inFirstschedule) 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 nodesNode3DMarker- All 3D nodesControlMarker- UI control nodesCanvasItemMarker- Canvas items
Visual Nodes
Sprite2DMarker/Sprite3DMarkerAnimatedSprite2DMarker/AnimatedSprite3DMarkerMeshInstance2DMarker/MeshInstance3DMarker
Physics Bodies
RigidBody2DMarker/RigidBody3DMarkerCharacterBody2DMarker/CharacterBody3DMarkerStaticBody2DMarker/StaticBody3DMarker
Areas and Collision
Area2DMarker/Area3DMarkerCollisionShape2DMarker/CollisionShape3DMarkerCollisionPolygon2DMarker/CollisionPolygon3DMarker
Audio Players
AudioStreamPlayerMarkerAudioStreamPlayer2DMarkerAudioStreamPlayer3DMarker
UI Elements
LabelMarkerButtonMarkerLineEditMarkerTextEditMarkerPanelMarker
Cameras and Lighting
Camera2DMarker/Camera3DMarkerDirectionalLight3DMarkerSpotLight3DMarker
Animation and Timing
AnimationPlayerMarkerAnimationTreeMarkerTimerMarker
Path Nodes
Path2DMarker/Path3DMarkerPathFollow2DMarker/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:
- Reduced Iteration: Only process entities you care about
- 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 onNone - Better ECS Optimization: Bevy can optimize queries with markers
- Cache Efficiency: Process similar entities together
Automatic Application
You don't need to add marker components manually. The library automatically:
- Detects the Godot node type during scene tree traversal
- Adds the appropriate marker component(s) to the entity
- Includes all parent type markers in the inheritance hierarchy
- 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 onNonein systems that run every frame: a node handle can outlive its Godot node by up to a frame -- GDScript (or Bevy) canfree()a node while its entity still exists, until the scene-tree removal drain despawns it.Nonemeans "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 usestry_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)]/BevyComponentscomponents 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
BevyBundlemacro 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; }
speedbecomes a#[export] speed: f32on the generatedPlayer2Dclass (default 250.0).- When the node enters the tree,
Speed(speed_value)is inserted. as = f32is required here — the macro cannot seeSpeed'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
| Placement | Key | Required? | Meaning |
|---|---|---|---|
| struct | base = GodotBase | no (default: Node) | Godot class to extend |
| struct | class_name = Name | no (default: <Struct>BevyComponent) | Generated class name |
| struct | require(…) | no | Companion component (see forms above) |
| field | export | yes | Marks the field as a generated Godot export |
| field | as = T | no | Godot export type |
| field | default = expr | no | Editor default (via #[init(val = …)]); a pure-Bevy spawn(T) uses the struct's own Default — make them agree if you rely on spawn(T). |
| field | with = fn | no | Godot-value → field-value conversion |
require(prop: Comp, …) | as = T | yes | Export type for the generated property |
require(prop: Comp, …) | default = expr | no | Export default |
require(prop: Comp, …) | with = fn | no | Conversion before constructing the component |
| generated primary or companion field | description = "..." | no | Property docs, registered with register-docs |
| generated primary or companion field | hint = NAME | no | Bare Godot PropertyHint variant |
| generated primary or companion field | hint_string = expr | no | Hint 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
| Placement | Key | Required? | Meaning |
|---|---|---|---|
| struct | require(Marker) | no | Insert Marker::default() |
| struct | require(Comp { bevy_field: godot_field, … }) | no | Build struct component from existing exports |
| field | component = Comp | yes | Bevy component type (Comp(value)) |
| field | with = fn | no | Godot-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 BevyComponent; the macro generates the Godot class.BevyComponents(Godot-first) — you write theGodotClassyourself; 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(...)]:
| Key | Meaning |
|---|---|
export | (required) Marks the field as a generated Godot export. |
as = T | Godot export type (defaults to the field's Rust type). |
default = expr | Editor 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 = fn | Converts 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:
| Form | Meaning |
|---|---|
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
GodotNodealso registers required components,commands.spawn(Player)in a test or a headless context insertsSpeed,JumpVelocity, andGravitywith 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:
| Key | Meaning |
|---|---|
component = Comp | (required) The Bevy component to insert — Comp(value). |
with = fn | Converts 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:
| Form | Meaning |
|---|---|
require(Marker) | Insert Marker::default(). |
require(Comp { bevy_field: godot_field, … }) | Build Comp from existing export fields. |
Which derive to use
GodotNode | BevyComponents | |
|---|---|---|
| Who writes the Godot class | Macro | You |
base / class_name | #[gdbevy(base = …, class_name = …)] | #[class(base = …)] in gdext |
| Required-components (pure Bevy spawn) | Yes | No |
Custom init / #[godot_api] | No | Yes — 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:
- Define a
NodeTreeViewfor a scene - Spawn the scene via
GodotScene - 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”)AnimatedSprite2DVisibleOnScreenNotifier2D
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'swith_signal_connectionbuilder method.NodeTreeView's generated path constants.godot_bevy::interop::<GODOT_NODE_TYPE>Signalstypes 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:
- Disabled - No syncing, no transform components created
- OneWay - ECS → Godot only (default)
- 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
- Learn about Sync Modes in detail
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.
The two-way-sync-demo lets GDScript move a quad on x while Bevy moves it on y. Its README explains how to run it.
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, inPreUpdate) - ❌ 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/zindependently). - 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,AnimationPlayerin 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_processreader 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 syncedTransform-- the fresh Godot value isn't merged untilFixedFirstruns. Read this-frame's Godot value inFixedUpdateonward or in theUpdatesuffix, 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) andPreUpdate(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
- Choose mode early - Switching modes mid-project can be complex
- Default to OneWay - Unless you specifically need other modes
- Benchmark your game - Measure actual performance impact
- 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_godotonly: Skips reading Godot transforms, ideal for UI elements and ECS-driven entitiesgodot_to_bevyonly: 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 inFixedLast— the physics rate, which is what Godot's physics interpolation expectsgodot_to_bevy(Godot → Bevy read) runs inPreUpdate- 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
ActionInputevents 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 inUpdateandFixedUpdate, and reuses Godot's controller remapping. - Bevy-native
Query<&Gamepad>-- the defaultbevy_gamepadfeature pulls in Bevy's gilrs backend, which populates theGamepadentity directly from the OS. No godot-bevy code involved; use it exactly as in any Bevy app. - Raw events + Godot device id -- the
GamepadButtonInput/GamepadAxisInputmessages 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
| Feature | Bevy Input | godot-bevy Input |
|---|---|---|
| Setup complexity | None | Moderate |
| Cross-platform | Limited | Full |
| User remapping | No | Yes |
| Touch support | No | Yes |
| State queries | Easy | Manual tracking |
| Performance | Fastest | Fast |
| Godot integration | None | Full |
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_pressedistruein every one of those NFixedUpdatecalls. - 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,
FixedUpdatenever sees it -- by the next frame the edge is already cleared. This is the same caveat stock Bevy has with rawButtonInputinFixedUpdate.
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_pressedlags one tick. Godot stamps physics action edges with a+1frame offset -- the same lag asis_action_just_pressedin GDScript's_physics_process. Intentional; matches GDScript. - Action set is cached on first poll. Actions added to the
InputMapafter startup aren't picked up. axis/vectoruse per-action InputMap deadzones.vector()does not replicate Godot's circularget_vectordeadzone -- 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:
| old | new |
|---|---|
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
- Multiple Signal Events
- Passing Context (Node, Entity, Arguments)
- Connecting to Non-Entity Objects
- Deferred Connections
- Attaching signals to Godot scenes
- Signal Name Constants
Quick Start
- Define a Bevy event for your signal:
#![allow(unused)] fn main() { use bevy::prelude::*; use godot_bevy::prelude::*; #[derive(Event, Debug, Clone)] struct StartGameRequested; }
- Register the signals plugin for your event type:
#![allow(unused)] fn main() { fn build_app(app: &mut App) { app.add_plugins(GodotSignalsPlugin::<StartGameRequested>::default()); } }
- Connect a Godot signal and map it to your event:
#![allow(unused)] fn main() { fn connect_button( buttons: Query<&GodotNodeHandle, With<Button>>, signals: GodotSignals<StartGameRequested>, ) { for handle in &buttons { signals.connect( *handle, "pressed", None, |_args, _node_handle, _ent| Some(StartGameRequested), ); } } }
- React to the event with an observer:
#![allow(unused)] fn main() { fn setup(app: &mut App) { app.add_observer(on_start_game); } fn on_start_game( _trigger: On<StartGameRequested>, mut next_state: ResMut<NextState<GameState>>, ) { next_state.set(GameState::Playing); } }
Observers fire immediately when the signal is received, giving you reactive, push-based event handling rather than polling each frame.
Multiple Signal Events
Use one plugin per event type. You can map the same Godot signal to multiple typed events if you like:
#![allow(unused)] fn main() { #[derive(Event, Debug, Clone)] struct ToggleFullscreen; #[derive(Event, Debug, Clone)] struct QuitRequested { source: GodotNodeHandle } fn setup(app: &mut App) { app.add_plugins(GodotSignalsPlugin::<ToggleFullscreen>::default()) .add_plugins(GodotSignalsPlugin::<QuitRequested>::default()) .add_observer(on_toggle_fullscreen) .add_observer(on_quit); } fn connect_menu( menu: Query<(&GodotNodeHandle, &MenuTag)>, toggle: GodotSignals<ToggleFullscreen>, quit: GodotSignals<QuitRequested>, ) { for (button, tag) in &menu { match tag { MenuTag::Fullscreen => { toggle.connect( *button, "pressed", None, |_a, _node_handle, _e| Some(ToggleFullscreen), ); } MenuTag::Quit => { quit.connect( *button, "pressed", None, |_a, node_handle, _e| Some(QuitRequested { source: node_handle }), ); } } } } fn on_toggle_fullscreen(_trigger: On<ToggleFullscreen>, mut godot: GodotAccess) { // Toggle fullscreen } fn on_quit(_trigger: On<QuitRequested>) { // Quit the game } }
Passing Context (Node, Entity, Arguments)
The mapper closure receives:
args: &[Variant]: raw Godot arguments (clone if you need detailed parsing)node_handle: GodotNodeHandle: emitting node handle (use it later withGodotAccess)entity: Option<Entity>: Bevy entity if you passedSome(entity)toconnect
Important: the mapper runs inside the Godot signal callback. Do not call Godot APIs in the mapper; resolve the node_handle in an observer or system with GodotAccess on the main thread. Connections are queued and applied on the main thread; connections made during a frame take effect on the next frame. If you need same-frame connection, use connect_immediate with a GodotAccess parameter. See Thread Safety and Godot APIs.
Example including the entity in the event:
#![allow(unused)] fn main() { #[derive(Event, Debug, Clone, Copy)] struct AreaExited { entity: Entity } fn connect_area( q: Query<(Entity, &GodotNodeHandle), With<Area2D>>, signals: GodotSignals<AreaExited>, ) { for (entity, area) in &q { signals.connect( *area, "body_exited", Some(entity), |_a, _node_handle, e| Some(AreaExited { entity: e.unwrap() }), ); } } fn on_area_exited(trigger: On<AreaExited>, mut commands: Commands) { let entity = trigger.event().entity; commands.entity(entity).despawn(); } }
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, andentity:args: &[Variant]: raw Godot arguments (clone if you need detailed parsing).node_handle: GodotNodeHandle: emitting node handle.entity: Option<Entity>: Bevy entity the GodotScene component is attached to (Always Some).
- The closure returns an optional Bevy Event, or None to not send the event.
- The closure receives three arguments:
impl Command for SpawnPickup {
fn apply(self, world: &mut World) -> () {
let assets = world.get_resource::<PickupAssets>().cloned();
let mut pickup = world.spawn_empty();
pickup
.insert(Name::new("Pickup"))
.insert(Transform::from_xyz(200.0, 200.0, 0.0));
// Only insert GodotScene if Godot engine is running; useful when running tests without Godot.
if let Some(assets) = assets {
pickup.insert(
GodotScene::from_handle(assets.scene.clone())
// Schedule the "area_entered" signal on the Area2D child
// to be connected to PickupAreaEntered event
.with_signal_connection(
"Area2D",
"area_entered",
|_args, _node_handle, _entity| {
// Pickup "area_entered" signal mapped
Some(PickupAreaEntered)
},
),
);
}
}
}
For physics signals (collisions), use the collisions plugin/events instead of raw signals when possible.
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:
| Struct | Common Signals |
|---|---|
BaseButtonSignals | PRESSED, BUTTON_UP, BUTTON_DOWN, TOGGLED |
Area2DSignals | BODY_ENTERED, BODY_EXITED, AREA_ENTERED, AREA_EXITED |
Area3DSignals | BODY_ENTERED, BODY_EXITED, AREA_ENTERED, AREA_EXITED |
SceneTreeSignals | SCENE_CHANGED, TREE_CHANGED, NODE_ADDED, NODE_REMOVED |
AnimationPlayerSignals | ANIMATION_FINISHED, ANIMATION_STARTED, ANIMATION_CHANGED |
TimerSignals | TIMEOUT |
VisibleOnScreenNotifier2DSignals | SCREEN_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:
FirstPreUpdate(TwoWay: reads Godot → ECS transforms only on 0-physics-step frames)StateTransitionFixedMain(zero or more times per render frame)FixedFirst(TwoWay: reads Godot → ECS every physics step)FixedPreUpdateFixedUpdate(your physics logic)FixedPostUpdateFixedLast(writes ECS → Godot transforms at physics rate)
The prefix (First → StateTransition) 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:
UpdatePostUpdateLast
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
| Schedule | Rate | Use case |
|---|---|---|
Visual schedules (Update, etc.) | Display refresh (60–144 Hz) | Rendering, UI, general logic |
FixedUpdate / FixedMain | Godot's physics rate (default 60 Hz) | Physics, deterministic simulation |
Note: Bevy's default
FixedUpdaterate (64 Hz) is not used. godot-bevy drivesFixedMaindirectly from_physics_process, so the rate is always Godot's physics rate — whatever is set in Project Settings → Physics → Common → Physics Ticks Per Second.
Reading schedule counters
To inspect the cadence, count calls to First, Update, and FixedUpdate, then read the counters in Last. First counts the prefix and Update counts the suffix. Both advance once per render frame. PreUpdate, PostUpdate, and Last also run once per render frame.
The counter increments for one render frame depend on how many physics steps it contains:
| Physics steps | First | Update | FixedUpdate |
|---|---|---|---|
| 0 | +1 | +1 | 0 |
| 1 | +1 | +1 | +1 |
| 2 | +1 | +1 | +2 |
At a higher display rate than physics rate, some render frames have no fixed step. A slower display can have several fixed steps in one frame. Equal counters at 60 FPS and 60 physics ticks per second do not mean the schedules share a callback.
Use Update for UI and visual-rate game logic. Use FixedUpdate for physics logic and read Res<Time>::delta_secs() there for Godot's physics delta. Schedule counters describe cadence; they do not measure rendering performance. The integration suite checks the ordering and once-per-render-frame prefix in real_frame_tests.rs.
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
- Visual frames vary widely (30–144+ FPS)
- FixedUpdate runs at a constant rate driven by Godot's physics clock
- Transform syncing from ECS → Godot happens in
FixedLast; Godot → ECS happens every physics step inFixedFirst(TwoWay), and inPreUpdateon 0-step frames
Note: Scene tree entities are initialized during
PreStartup, before anyStartupsystems run. You can safely query Godot scene entities inStartupsystems. 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
Inputsingleton - 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.
Recommended Architecture
The most efficient approach is to minimize main thread systems by using an event-driven architecture:
- Multi-threaded systems handle game logic and emit events
- 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, underdependenciesadd 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, underfeaturesadd atrace_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 theConnectbutton 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-cfgworkaround 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 theGODOT_EDITOR_TRACY_PORT=7867environment variable. You override the game instance Tracy port as usual withTRACY_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
- Open your project in Godot
- Look for the "Bevy" tab in the left dock (next to Scene/Import)
- Run your game
- 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:
- Verify the Godot node was in the scene tree when the entity was created
- 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
-
Android NDK - The Native Development Kit provides the compilers and tools needed to build native code for Android. Download from Android NDK Downloads
-
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
- Go to Project → Export...
- Select Android preset
- Under Architectures, ensure arm64-v8a is selected
Note: The Rust target
aarch64-linux-androidcorresponds to Android'sarm64-v8aarchitecture
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'sFirst, visible toFixedUpdateandUpdatethe 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'spressed, anArea2D'sbody_entered→GodotSignalsPlugin+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 fields | add_godot_event::<T>("name", |v| ...) decoder; GDScript fires send_event("name", payload) |
drain_from_node's source: GodotNodeHandle | pass {"source": self} in the payload; the mapper extracts it, the observer resolves node → Entity via NodeEntityIndex |
GodotMailboxPlugin<T, Marker> per message type | no plugin; add_godot_event per GDScript name |
MessageReader<T> batch loop | an observer accumulates into a Resource, an ordered system drains it (below) |
| O(entities) FFI scan every fixed step | one 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
- Behavior: Shutdown runs on destruction
- Dependencies: Bevy 0.19 and godot 0.5
- New: Event bridge
- Breaking: Integration tests live in the game crate
- New: GodotNode on tuple structs
- New: Inspector metadata on generated exports
- New: AttachableComponent
- Known limitations: Web builds
- Macro redesign --
#[gdbevy(...)]unified grammar (breaking change) - Native schedule ordering
- ButtonInput now works in FixedUpdate
- TwoWay transform sync -- now per-axis co-authorship
- Reparenting preserves ECS component state
- CollisionEnded now fires when a colliding node is freed
- CollisionStarted now fires for Area spawn-into-overlap
- Warning for contact_monitor-less RigidBodies
- GodotActions: Update readers need .after(GodotInputSet)
- RunFixedMainLoop removed from MainScheduleOrder
- PhysicsUpdate and related APIs removed
- FixedUpdate now runs at Godot's physics rate
- Delta time: use Res<Time> everywhere
- Engine.time_scale honored automatically
- SceneTree.paused now runs Update systems (process_mode = ALWAYS)
- Physics interpolation support
- Dependencies: gdext bumped to 0.5.5
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 alongsideComponent; the macro generates the Godot class.BevyComponents-- Godot-first. Derive alongsideGodotClass; you own the class.
There are no deprecation aliases -- the old names are gone and will fail to compile.
Godot-first: BevyBundle → BevyComponents
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
BevyBundlederive withBevyComponents. - 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 withrequire(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 newdefault = expralso runs inside a required-component constructor (called during pure Bevyspawn), where Godot may not be running. Use a static value:default = 980.0instead of theProjectSettingslookup.
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
BevyBundlederives withBevyComponents. -
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/synckeys.
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>, andRemovedComponentsfromPreUpdatemutations are visible inFixedUpdatewithin the same render frame.OnEnter/OnExitstate transitions (fired byStateTransition) are visible toFixedUpdatein the same frame.- Systems that do clock-sensitive Godot FFI (e.g. reading physics state) and were previously safe in
First/PreUpdatemay now run under the physics clock. Move such work toUpdateor 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_pressedistruein all NFixedUpdatecalls 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), ); }
Breaking: PhysicsUpdate and related APIs removed
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:
| Old | New equivalent |
|---|---|
PrePhysicsUpdate | FixedFirst or FixedPreUpdate |
PhysicsUpdate | FixedUpdate |
| (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
PhysicsUpdatewithFixedUpdate. -
Replace
PrePhysicsUpdatewithFixedFirstorFixedPreUpdate. -
Replace
Res<PhysicsDelta>+physics_delta.delta_secondswithRes<Time>+time.delta_secs(). -
Remove any
SystemDeltaTimerusage; use standard BevyTimer/Stopwatchor accumulatetime.delta_secs(). -
Remove hardcoded
1.0 / 60.0timestep constants; readtime.delta_secs()instead. -
Enable physics interpolation in
project.godotif your game has moving objects. -
Call
reset_physics_interpolation()on nodes that teleport. -
If using
avianphysics, passFixedUpdateinstead ofPhysicsUpdatetoPhysicsPlugins::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:
- Typed signals now use observers
- Collisions API redesigned
- Main-thread access is now explicit via GodotAccess
- GodotNodeHandle is ID-only; GodotNodeId removed
- Legacy untyped signals removed
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 API | New 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 API | New API |
|---|---|
Query<&Collisions> | Collisions system param |
collisions.colliding() | collisions.colliding_with(entity) |
collisions.recent_collisions() | MessageReader<CollisionStarted> |
CollisionMessage | CollisionStarted / 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:
SceneTreeRefis also aNonSendSystemParam. If a system already takesSceneTreeRefand does not call Godot APIs, you do not need an extraGodotAccessparameter.- If you only have an
InstanceId, useGodotAccess::try_get_instance_idorget_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:
GodotSignalsPluginGodotSignalmessageconnect_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>withGodotSignalsPlugin<T>. -
Replace
TypedGodotSignals<T>withGodotSignals<T>. -
Replace
connect_map()withconnect(). -
Replace
MessageReader<T>signal handlers with observers usingOn<T>. -
Replace
app.add_systems(Update, handler)withapp.add_observer(handler)for signal handlers. -
Replace
TypedDeferredSignalConnections<T>withDeferredSignalConnections<T>. -
Replace
Query<&Collisions>withCollisionssystem param. -
Replace
collisions.colliding()withcollisions.colliding_with(entity). -
Replace
collisions.recent_collisions()withMessageReader<CollisionStarted>or observer. -
Replace
#[main_thread_system]withGodotAccessparameters where you call Godot APIs. -
Replace
GodotNodeIdwithGodotNodeHandle. -
Replace
handle.getorhandle.try_getwithgodot.getorgodot.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:
- Upgrade to Bevy 0.17
- Custom scene tree relationships
- NodeTreeView::from_node now returns a Result type
connect_mapCallback Now ReturnsOption<T>
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
EventwithMessage.
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/Childrenare no longer used for Godot scene tree hierarchy. scene_tree_add_child_relationshipwas removed.- New
scene_tree_auto_despawn_childrenconfiguration option.
Migration Checklist
-
Replace
ChildOf/Childrenqueries withGodotChildOf/GodotChildren. -
Remove
scene_tree_add_child_relationshipusage. -
Use
scene_tree_auto_despawn_childrenif 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 letorunwrapto handle theResulttype returned byNodeTreeView::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_mapto returnSome(event)(orNoneto suppress event). -
Update all
TypedDeferredSignalConnections::<...>::with_connectionmappers for typed deferred signals to returnOption<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)
- Assets Plugin Moved to Optional (Breaking Change)
- Gamepad Support Now Optional
- Scene Tree Plugin Configuration Simplified
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::Transform2Dandgodot_bevy::prelude::Transform3Dwere removed
Migration Checklist
-
Transform components changed: Replaced
godot_bevy::prelude::Transform2Dandgodot_bevy::prelude::Transform3Dwithbevy::transform::components::Transform. The APIs from the former must be mapped to the latter:-
Remove the now extra
as_bevy()andas_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()andas_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.
-
Remove the now extra
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
GodotCorePluginsdirectly (withoutGodotDefaultPlugins) - You load Godot resources using
Handle<GodotResource>orAssetServer - You use
GodotAudioPluginorGodotPackedScenePlugin(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
GodotCorePluginsno longer includesGodotAssetsPluginGodotAssetsPluginis now inGodotDefaultPluginsGodotAudioPluginandGodotPackedScenePluginrequireGodotAssetsPluginto function
Migration Checklist
- Using GodotDefaultPlugins: No action needed
-
Using GodotCorePlugins + asset loading: Add
app.add_plugins(GodotAssetsPlugin) -
Using GodotAudioPlugin: Ensure
GodotAssetsPluginis included -
Using GodotPackedScenePlugin: Ensure
GodotAssetsPluginis 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>andEventReader<GamepadAxisInput> - ✅ All keyboard, mouse, and touch input
What Requires the Feature
- ❌ Bevy's standard gamepad API (
ButtonInput<GamepadButton>,Axis<GamepadAxis>) - ❌
GilrsPluginfunctionality - ❌ 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)
- GodotSignals Resource (Breaking Change)
- Multithreaded Bevy and #[main_thread_system] (New Feature)
- BevyBundle Enhanced Property Mapping (New Feature)
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); } }
Option 2: Recommended - Add Only What You Need
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 inGodotCorePlugins)GodotSceneTreeEventsPlugin- Monitor scene tree changes without creating entitiesGodotSceneTreeMirroringPlugin- 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 systemsGodotAudioPlugin- Add if you want to play sounds and music from Bevy systemsGodotSignalsPlugin- Add if you want to respond to Godot signals (button clicks, etc.) in Bevy systemsGodotCollisionsPlugin- Add if you want to detect collisions and physics events in Bevy systemsGodotInputEventPlugin- Add if you want to handle input from Godot in Bevy systemsBevyInputBridgePlugin- Add if you prefer Bevy's input API (auto-includesGodotInputEventPlugin)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:
GodotSceneTreeMirroringPluginautomatically includesGodotSceneTreeEventsPluginBevyInputBridgePluginautomatically includesGodotInputEventPlugin
Benefits of the New System
- Smaller binaries - Only compile what you use
- Better performance - Skip unused systems
- Clearer dependencies - Explicit about what your game needs
- Future-proof - Easy to add new optional features
Migration Checklist
-
Quick fix: Add
app.add_plugins(GodotDefaultPlugins)to yourbuild_appfunction -
Optimization: Replace
GodotDefaultPluginswith 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
- Function signature changed:
connect_godot_signalno longer requiresSceneTreeRefparameter - New SystemParam required: Add
GodotSignalsparameter to systems that connect signals - Recommended API change: Use
signals.connect()instead of directconnect_godot_signal()calls
Migration Checklist
-
Add
GodotSignalsparameter to systems that connect signals -
Replace
connect_godot_signal(&mut handle, signal_name, &mut scene_tree)withsignals.connect(&mut handle, signal_name) -
Remove unused
SceneTreeRefparameters 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:
- Add
GodotSignalsparameter to systems that connect signals - Replace
connect_godot_signal(&mut handle, signal, &mut scene_tree)withsignals.connect(&mut handle, signal) - Remove unused
SceneTreeRefparameters
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:
- Multithreaded Bevy enabled: Systems can now run in parallel by default
- New
#[main_thread_system]attribute: Mark systems that use Godot APIs - 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
SceneTreeRefor 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
- Better Performance: ECS systems can now utilize multiple CPU cores
- Explicit Threading: Clear distinction between main-thread and multi-thread systems
- Safety: Prevents accidental concurrent access to Godot APIs
- 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:
- Struct Component Mapping: Map multiple Godot properties to fields in a struct component
- Transform Functions: Apply transformation functions to convert values during mapping
- 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)
- BevyBundle Autosync Simplification
- Transform Sync Modes (Breaking Change)
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(); } } } }
After (v0.7.0 recommended approach)
#![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
- Performance: Only iterate over entities you care about
- Safety: No more
Option<>handling or potential panics - Clarity: Query signatures clearly show what node types you expect
- 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:
- Rare usage: The system runs infrequently and performance isn't critical
- Dynamic typing: You genuinely need to handle unknown node types at runtime
- 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:
- Wrong marker: Make sure you're using the right marker for your query
- Node freed: The Godot node was freed but the entity still exists
- 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:
- Check node types: Verify your scene has the expected node types
- Check marker names: Ensure you're using the correct marker component
- 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:
- Replace broad
Query<&mut GodotNodeHandle>with specific marker queries - Replace
try_get()calls withget()when using markers - 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
- Remove
autosync=true: This parameter no longer exists and will cause compilation errors - Remove manual sync systems: If you were manually adding bundle sync systems, remove them
- Timing change: Bundle components are now available in
Startupsystems (was previously only available inUpdate)
Benefits of This Change
- Simplified API: No need to remember to set
autosync=true - Better timing: Bundle components are available earlier in the frame lifecycle
- Unified behavior: Both initial scene loading and dynamic node addition work the same way
- No missed registrations: Impossible to forget to register a bundle system
Migration Checklist
-
Remove
autosync=trueandautosync=falsefrom all#[bevy_bundle()]attributes - Remove any manually registered bundle sync systems from your app
-
Test that bundle components are available in
Startupsystems (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:
- New
TransformSyncModesystem: Transform syncing is now configurable viaGodotTransformConfig - Default changed from two-way to one-way: Previously, transforms were synced bidirectionally by default. Now the default is one-way (ECS → Godot only)
- 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
-
TransformSyncMode::OneWay(NEW DEFAULT)- ECS transform changes update Godot nodes
- Godot transform changes are NOT reflected in ECS
- Best for pure ECS architectures
-
TransformSyncMode::TwoWay(v0.6.x default behavior)- Full bidirectional sync between ECS and Godot
- Required for Godot animations affecting ECS
- Higher performance overhead
-
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.