Decoupling Bevy Movement Systems Without Borrow Conflicts
Scope mutable Query access with entity filters so Bevy can schedule movement systems in parallel instead of serializing them.
26 Dec 2025, 22:24 UTC

The problem: two systems want the same Transform
You add a player movement system to your Bevy game. It works fine until you add a second system—maybe camera smoothing or enemy AI—that also mutates Transform. Suddenly the scheduler cannot run the systems at the same time. In debug builds you may even see a panic about conflicting mutable access. The fix is not to merge everything into one giant system; it is to scope each Query so Bevy’s scheduler can prove the systems touch different entities.
Takeaway: use entity filters such as With and Without to divide mutable component access into disjoint sets. When Bevy can see that two systems borrow different archetypes, it schedules them in parallel; when they overlap, it serializes them.
How Query borrows components
A Bevy system declares its dependencies through SystemParam types. Query is the most common one: it asks for read or write access to components on entities that match a filter. Res reads a resource, and Commands buffers entity changes such as spawning or despawning.
Bevy enforces exclusive access per component type. If one system holds &mut Transform, no other system can hold &mut Transform on an overlapping set of entities at the same time. Read-only access (&Transform) can be shared, but mutable access cannot. The scheduler inspects these parameters before the frame starts and builds a dependency graph: overlapping mutable queries create an edge, forcing the systems to run sequentially.
Entity filters decide which archetypes a query touches. With includes only entities with the Player marker component; Without excludes them. Because filters are known at compile time, Bevy can often prove that two queries are disjoint and run them in parallel.
Worked example: split player and enemy movement
The example assumes Bevy 0.14.x. Verify the exact signatures against your installed version before copying this code.
use bevy::prelude::*;
#[derive(Component)]
struct Velocity(Vec3);
#[derive(Component)]
struct Player;
fn move_player(
time: Res,
mut q: Query<(&mut Transform, &Velocity), With>,
) {
let delta = time.delta_seconds();
for (mut transform, velocity) in &mut q {
transform.translation += velocity.0 * delta;
}
}
fn move_enemies(
time: Res,
mut q: Query<(&mut Transform, &Velocity), Without>,
) {
let delta = time.delta_seconds();
for (mut transform, velocity) in &mut q {
transform.translation += velocity.0 * delta;
}
}
fn setup(mut commands: Commands) {
commands.spawn((Player, Velocity(Vec3::new(1.0, 0.0, 0.0)), Transform::default()));
}
Register both systems in the same schedule, for example in Update:
app.add_systems(Update, (move_player, move_enemies));
Run this from your project directory:
cargo build
cargo run
No special permissions are required. cargo build should compile without borrow errors. Because move_player touches only With entities and move_enemies touches only Without entities, Bevy can schedule them in parallel. If you add a second system that mutates Transform on With entities as well, the build still succeeds, but the scheduler adds a dependency edge and runs them one after another.
Risk: do not store a Query or Commands value outside the system function, and do not hold either across an .await point. Bevy borrows are tied to the system’s execution window.
What happens when queries overlap
If two systems both request &mut Transform for the same archetype, Bevy serializes them. This is safe, but it removes the parallelism you gained by splitting systems. In some configurations—especially manual schedule construction or dynamic query access—overlapping mutable borrows can trigger a debug-build panic rather than a quiet serialization.
To verify which behavior you are getting, inspect the schedule graph. Recent Bevy releases include schedule diagnostics that show whether two systems ran in parallel or sequentially in a given frame. Add the diagnostics plugin for your version, run the game, and check the log for the two movement systems. If they appear on the same execution bar, they ran in parallel; if one follows the other, they were serialized.
Trade-off: filters split data, but they also fragment it
Using With and Without is the cheapest way to regain parallelism, but it only works when the systems genuinely operate on different entities. If camera logic and player logic both need to mutate the same Transform on the same entity, no filter will separate them. Your options are to accept sequential execution, split the data into separate components—for example, PlayerTransform and CameraOffset—or recompute the value in a single system.
Commands has its own timing rule: spawns, despawns, and inserts are buffered and applied after the current schedule stage. An entity you spawn inside move_player will not be visible to another system running later in the same stage. Plan your system order around that delay, or split the work across stages.
Check the result and move on
- Add a
Velocitycomponent and aPlayermarker to your project. - Write a movement system using
Query<(&mut Transform, &Velocity), With>. - Run
cargo buildto confirm it compiles, thencargo runto observe movement. - Add a second system that mutates
Transformon the same entities and check the schedule diagnostics. If they run sequentially, you have confirmed the borrow conflict. - Rewrite the second system with
Withoutor a separate component and confirm the scheduler runs them in parallel.
Reverting is straightforward: remove the extra system from your App builder. There is no persistent state to roll back beyond your source code.
Start with disjoint entity filters, verify with the schedule graph, and only introduce shared mutable Transform access when you are ready to pay the serialization cost.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.