Deterministic Game Logic in Bevy: Using Fixed-Timestep Schedules and Command Buffers
Learn how to structure Bevy ECS schedules so that game logic reads a consistent snapshot and applies changes safely, with concrete code, checks, and failure-mode analysis.
18 Dec 2025, 08:27 UTC

Problem: Need deterministic, race-free game logic in Bevy
When building simulations or networked games, each tick must produce the same result given the same input. In Bevy’s ECS, systems run concurrently and can read or write components arbitrarily, which leads to two common issues:
- Systems that read a component after another system has mutated it in the same frame see an inconsistent state.
- Mutable access to the same component from two systems creates a data race that Rust’s type system prevents at compile time, but only if the access patterns are expressed correctly.
The useful takeaway is to enforce a strict ordering: first all read-only observations, then all mutations that are deferred via a command buffer, and finally apply the buffer atomically. This yields a deterministic snapshot without explicit locks.
Requirements
- Fixed timestep – logic must advance in equal time slices (e.g., 1/60 s) so that simulation is frame-rate independent.
- Read-then-write separation – systems that only observe state must run before any system that intends to change state.
- Deterministic mutation – all component insertions, removals, and modifications must be recorded in a command buffer and applied after the observation phase.
- Safety guarantees – Rust’s ownership and Bevy’s schedule type system must prevent illegal read/write overlaps at compile time.
Minimal Design
The smallest arrangement that satisfies the requirements consists of three schedule phases:
FixedUpdate– a schedule driven by theFixedTimestepplugin that runs at a constant rate.- Inside
FixedUpdate:- Observation systems – parameterized with
ResorQuery<&Component>(read-only). - Command systems – parameterized with
ResMut<Commands>(they only push commands).
- Observation systems – parameterized with
ApplyDeferred– a built-in system that runs after allFixedUpdatesystems and executes the command buffer.
Because the schedule executes systems in the order they are added, placing all observation systems before any command system guarantees that every read sees the world as it existed at the start of the tick.
Example: Position logging and Velocity insertion
use bevy::preludesalted::*; // Assumption: Bevy 0.13+ environment
#[derive(Component)]
struct Position(f32, f32);
#[derive(Component)]
struct Velocity(f32, f32);
fn startup(mut commands: Commands) {
commands.spawn((Position(0.0, 0.0),));
commands.spawn((Position(10.0, 5.0),));
}
fn log_positions(query: Query<&Position>) {
for pos in query.iter() {
info!("Position: ({}, {})", pos.0, pos.1);
}
}
fn insert_velocity(mut commands: Commands, query: Query<Entity, With<Position>>) {
for entity in query.iter() {
commands.entity(entity).insert(Velocity(1.0, 0.0));
}
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.insert_resource(Time::::from_seconds(0.016)) // 60Hz
.add_systems(Startup, startup)
.add_systems(FixedUpdate, (
log_positions,
insert_velocity
).chain()) // Chain ensures observation runs before command
.run();
}
Where to run: Execute cargo run in a standard Bevy project (requires Rust 1.70+). No special permissions are needed.
Expected check: The log output for each tick should show the Position values before any Velocity component is present. If you add a system that reads Velocity in the same FixedUpdate stage after insert_velocity, it will observe the old (missing) value, confirming the snapshot semantics.
Trust and Data Boundaries
Bevy enforces boundaries through two mechanisms:
- Rust ownership – A system cannot simultaneously hold
&mut Tand&Tfor the same resource without explicit splitting, which the borrow checker flags. - Schedule type system – When you add a system to a stage, Bevy records its parameter types. If two systems in the same stage declare conflicting access (e.g., both
ResMut<MyResource>), the schedule construction fails at compile time with a clear error.
These checks define the trust boundary: the ECS guarantees that any system you successfully compile will not cause a data race via concurrent mutable access.
Operational Checks
To verify that the design is working as intended during development, perform the following:
- Snapshot consistency – Add a read-only system that logs a hash of all relevant components. Run the app and confirm the hash is identical across ticks when no command systems modify those components.
- Command deferral – Insert a component via a command system, then immediately try to read it in an observation system of the same tick. The read should return the pre-insertion state; only in the next tick will the new component be visible.
- Panic safety test – Replace an observation system with one that calls
panic!()after logging. Run the app and verify that entities spawned before the panic persist, while any commands from the panicking system are not applied.
Failure Modes
- Deferred-read stale data – Because commands are applied after all systems, any system that expects to see a component added in the same tick will see the old value. The fix is to defer the read to the next tick or use the immediate
World::entity_mutAPI with caution. - Partial updates on panic – If a system panics, Bevy does not roll back its world changes. Systems that ran before the panic will have left their effects in the world, while the panicking system’s effects are lost.
- Schedule misordering – Accidentally adding a command system before an observation system in the same stage breaks the snapshot guarantee.
When the Design Would Change
Consider revising this minimal design if any of the following conditions arise:
- Sub-tick resolution needed – If your simulation requires multiple logical steps per rendered frame (e.g., physics sub-stepping), you would nest additional fixed-step schedules.
- Immediate mutation required – Certain algorithms (e.g., collision resolution) may demand immediate writes. You would introduce a second phase where you allow
&mutaccess after a barrier. - High-frequency event handling – If you need to react to input that arrives between fixed steps, keep an
Eventresource and process it in a separate schedule before the fixed-step observation phase.
Limitations and Practical Verification
The approach assumes that your game logic can be cleanly split into pure observation and pure command phases. Systems that need to both read and write the same component within a tick must be redesigned (e.g., split into two systems or use double-buffering). The practical way to confirm that your split is sufficient is to enable bevy::log::Level::DEBUG for the schedule and verify that the schedule graph shows observation then command, with ApplyDeferred as the final step.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.