Using Bevy's FixedTimestep Plugin for Deterministic Game Logic
Learn how Bevy's FixedTimestep plugin decouples game logic from variable render frames, see a minimal counter example, and understand the trade‑offs to use it effectively.
23 Mar 2026, 02:26 UTC

The problem: variable frame rates break predictable logic
When you write a game, you often want physics, AI, or networking to advance in lock‑step ticks, independent of how fast the renderer can draw frames. If you tie those systems directly to the Update schedule, a slow frame will cause the logic to lag, while a very fast frame may cause it to run multiple times in a row, leading to jitter or tunneling.
Bevy solves this with the FixedTimestep plugin, which creates a separate schedule that ticks at a constant rate you choose.
How FixedTimestep works
The plugin adds a schedule named FixedUpdate. Internally it keeps an accumulator that adds the real time elapsed each frame. When the accumulator reaches or exceeds the configured step size (e.g., 1.0/60.0 seconds for 60 Hz), the plugin runs all systems in FixedUpdate once, subtracts the step size from the accumulator, and repeats until the accumulator is too small for another step. Any leftover time is carried over to the next frame.
Because the accumulator smooths out variable frame times, the logic sees a steady delta time, while the renderer can still run as fast or as slow as the hardware allows.
Worked example: a steady counter
Below is a minimal Bevy project that demonstrates the plugin. You can copy it into a fresh cargo new bevy_fixed_demo --bin folder and add bevy = "0.13" to Cargo.toml.
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
// Add the FixedTimestep plugin with a 60 Hz tick rate
.add_plugin(FixedTimestepPlugin::from_seconds(1.0 / 60.0))
.insert_resource(Counter { value: 0 })
.add_system(update_counter.in_schedule(FixedUpdate))
.add_system(print_stats.in_schedule(Update))
.run();
}
#[derive(Resource, Default)]
struct Counter {
value: u32,
}
fn update_counter(mut counter: ResMut) {
counter.value += 1;
}
fn print_stats(time: Res, counter: Res) {
// Print once per second to avoid flooding the console
if time.time_since_startup().as_secs_f32() % 1.0 < 0.016 {
info!("fixed ticks: {}, fps: {:.1}", counter.value, 1.0 / time.delta_seconds());
}
}
Run the binary with cargo run. You should see the log line showing the fixed tick count increasing steadily, while the reported FPS fluctuates with your monitor’s refresh rate or window load. The counter advances at roughly 60 per second regardless of those fluctuations.
Trade‑offs and limitations
- CPU spikes under heavy load: If the render frame rate drops far below the fixed step (e.g., 10 fps while targeting 60 Hz), the accumulator may trigger several
FixedUpdatesteps in a single frame, causing a temporary CPU spike. Developers often cap the maximum number of steps per frame viaFixedTimestepPlugin::from_seconds(step).with_max_ticks(max)to avoid this. - State sharing between schedules: Resources accessed from both
UpdateandFixedUpdatemust be synchronized. The simplest approach is to keep separate resources for each schedule or useMutex/Atomic*types when shared mutable state is unavoidable. - Determinism only for tick‑based logic: Systems that depend on sub‑step interpolation (e.g., smooth rendering of physics) still need to read the accumulator’s leftover time to interpolate between fixed steps.
These caveats are documented in the Bevy guide and can be mitigated with the patterns above.
Actionable closing
If your game needs reliable simulation ticks—whether for physics, networking, or turn‑based logic—add the FixedTimestep plugin early in your App builder. Choose a step size that matches your desired simulation frequency, monitor the accumulator’s behavior under low‑fps scenarios, and keep mutable state separated between schedules. With those steps in place, you’ll get deterministic logic without tying it to the vagaries of frame‑rate.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.