Parallel Gameplay Systems in Bevy 0.14: ECS Query Design for Automatic Scheduling
Design Bevy 0.14+ gameplay systems that run in parallel automatically by structuring components and queries for archetype disjointness. Covers component granularity, query filters, SystemSet ordering, schedule verification, and recovery from conflicts.
25 Sept 2026, 15:23 UTC

Desired Outcome
Build a gameplay system—such as physics integration, AI steering, or particle updates—that reads and writes specific component sets and runs in parallel with other non-conflicting systems, without manual thread management. Bevy's scheduler analyzes each system's SystemParam access (read vs. write) per archetype and builds a dependency graph; systems with disjoint archetype access execute concurrently on worker threads automatically.
Prerequisites
- Bevy 0.14+ project with
bevy_ecscrate (add viacargo add bevy@0.14 --features default) - Familiarity with
Component,Resource,Query, andSystemParam - Components implement
Component(derive) and are registered viaApp::add_plugins(DefaultPlugins)orMinimalPlugins - Rust 1.75+ toolchain
Focused Procedure
1. Define Fine-Grained Components
Prefer small, single-purpose components over large aggregates. This maximizes archetype disjointness and thus parallelism.
use bevy::prelude::*;
#[derive(Component)]
struct Position(Vec3);
#[derive(Component)]
struct Velocity(Vec3);
#[derive(Component)]
struct Mass(f32);
#[derive(Component)]
struct Health(f32);
#[derive(Component)]
struct Color(Color);2. Write Systems with Narrow Queries
Each system should request only the components it actually reads or writes. Use With/Without filters to constrain archetype matching.
fn physics_system(mut query: Query<(&mut Position, &Velocity), With>, time: Res) {
for (mut pos, vel) in query.iter_mut() {
pos.0 += vel.0 * time.delta_secs();
}
}
fn health_color_system(mut query: Query<(&mut Color, &Health), Changed>) {
for (mut color, health) in query.iter_mut() {
color.0 = Color::rgb(1.0, health.0.max(0.0), 0.0);
}
}3. Register Systems in the Schedule
Add systems to FixedUpdate for deterministic timestep or Update for variable frame rate. Bevy 0.14+ uses SystemSet for ordering.
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(FixedUpdate, (
physics_system,
health_color_system,
))
.run();
}4. Introduce Ordering Only When Necessary
If two systems must run in sequence (e.g., physics then collision resolution), use SystemSet with .before() or .after(). Avoid implicit ordering assumptions—parallel system order is non-deterministic.
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
struct PhysicsSet;
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
struct CollisionSet;
app.add_systems(FixedUpdate, (
physics_system.in_set(PhysicsSet),
collision_system.in_set(CollisionSet),
).chain()); // PhysicsSet runs before CollisionSetExpected Checks
Schedule Graph Inspection
Run with trace logging to observe the scheduler's dependency graph:
RUST_LOG=bevy_ecs=trace cargo run 2>&1 | grep -E "Parallel|Sequential|Conflict"Look for Parallel edges between your systems. Conflict errors indicate overlapping mutable access—refactor queries or add ordering.
Runtime Verification
- Add
bevy_inspector_eguiand confirm component changes per frame in the inspector. - Profile with
cargo flamegraphortracyto see worker thread utilization. Parallel systems should show concurrent spans across threads. - Write a criterion benchmark comparing throughput against a single-threaded baseline.
Recovery Options
Schedule Conflicts
If the scheduler reports a conflict:
- Split queries: separate
Query<&mut A>andQuery<&mut B>into different systems if they don't logically belong together. - Use
Withoutfilters to exclude entities the other system touches. - Introduce explicit
SystemSetordering with.before()/.after().
System Panics
Bevy's default panic handler logs the error and continues the frame. For deterministic rollback (e.g., networked games), implement a custom RunCriteria or use bevy_replicon/bevy_quinnet for state synchronization.
Resource Contention
ResMut forces exclusive access across all systems using that resource. In hot paths, prefer Res or Res (read-only). If mutable resource access is unavoidable, consider splitting the resource or using a Local parameter for per-system state.
Practical Engineering Decisions
| Decision | Rationale | Trade-off |
|---|---|---|
| Fine-grained components | Maximizes archetype disjointness → more parallelism | More query boilerplate; more archetypes → memory overhead |
Narrow queries (Query<(&mut A, &B), With>) | Reduces archetype edges; avoids false conflicts | Requires discipline; easy to over-fetch |
Read-only Res over ResMut | Read-only resources don't serialize systems | Mutable globals (e.g., ResMut) become bottlenecks |
ParamSet for mutually exclusive access | Allows multiple mutable queries in one system | Serializes inner queries; use only when truly exclusive |
Changed filters sparingly | Adds archetype edges; overuse reduces parallelism | Convenient for dirty-tracking; profile before committing |
Limitations & Verification
- Scheduler determinism is per-frame; parallel system order varies between frames. Never rely on side-effect ordering between parallel systems.
- Hot-reloading (
bevy_asset) reloads assets only, not Rust system logic. FixedUpdateruns at a fixed Hz (default 64 Hz); the accumulator may execute multiple steps per frame.- Verify parallelism empirically: run the trace log check above, then profile under load. A minimal demo with two disjoint systems (physics + color) should show
Paralleledges; adding a third system mutating bothPositionandHealthwill force sequentialization.