Using Bevy's Change Detection to Update Only What Actually Changed
Learn how Bevy's Added<T>, Changed<T> and tick‑based filters let you skip unnecessary work in ECS systems, with a concrete health‑bar example and a note on mutable dereference pitfalls.
13 Nov 2025, 16:09 UTC

The problem: wasted work each frame
In a typical game loop you might have a system that copies every Health component to a UI bar each frame. Even if most entities keep the same health, the system still touches every entity, turning an O(N) scan into unnecessary work. Bevy solves this with change detection: systems can ask for only those components that were added or mutated since the last tick.
How Bevy tracks changes
Each component slot carries a change tick – the world tick number when the component was last added or mutably dereferenced. The world tick increments once per update stage. Filters like Added, Changed and With compare the component's tick against the current world tick (or the system's last run tick) to decide whether the entity should be included in the query.
Importantly, the tick updates on any mutable dereference, not only when the value actually changes. Writing *transform = *transform (or any DerefMut access) marks the component as changed, even if the bits are identical. This means you should avoid needless mutable access when you only want to read.
Worked example: health‑bar sync
Suppose we have a simple Health component and a UI resource that holds a bar length for each entity. We want to update the bar only when health actually changes.
use bevy::prelude::*;
#[derive(Component)]
struct Health {
value: f32,
}
#[derive(Resource, Default)]
struct HealthBars {
// entity -> bar length (0.0..1.0)
lengths: std::collections::HashMap,
}
fn setup(mut commands: Commands) {
commands.spawn((Health { value: 100.0 }, Name::new("Player")));
commands.insert_resource(HealthBars::default());
}
fn damage_over_time(mut query: Query<&mut Health>, time: Res) {
for mut health in query.iter_mut() {
// simulate occasional damage
if time.elapsed_seconds().floor() % 5.0 == 0.0 {
health.value -= 10.0;
}
}
}
fn sync_health_bars(
mut bars: ResMut,
query: Query<(&Health, Entity), Changed>,
) {
for (health, entity) in query.iter() {
let length = health.value / 100.0; // assume max 100
bars.lengths.insert(entity, length);
// In a real UI you would now update the bar widget.
info!(?entity, length, "health bar updated");
}
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.insert_resource(Time::::from_seconds(0.016))
.add_startup_system(setup)
.add_system(damage_over_time)
.add_system(sync_health_bars)
.run();
}
The sync_health_bars system uses Query<(&Health, Entity), Changed>. Only entities whose Health component ticked since the last run are visited. If you run the app and watch the log, you will see "health bar updated" messages only when damage is applied.
Demonstrating the mutable‑access pitfall
Add a line inside damage_over_time that does a no‑op mutable write:
for mut health in query.iter_mut() {
*health = *health; // deref mut, but value unchanged
// ... damage logic
}
Now the sync system will fire every frame because the tick updated on the dereference, even though the health value didn’t change. Removing that line restores the expected behavior. This illustrates why you should keep mutable access to a minimum when you only need to read.
Trade‑offs and limitations
Change detection adds a small per‑component overhead (storing a tick) and relies on the world tick advancing regularly. Systems that run extremely infrequently (e.g., gated by a custom run condition) could miss changes if the tick wraps between runs. Bevy handles wrap‑around internally, but it’s still wise to avoid relying on change detection for systems that might skip many ticks.
For resources, ResMut marks the resource changed on any mutable access. If you intend to write a resource without triggering change detection (e.g., clearing a buffer), use resource.bypass_change_detection() or check resource.is_changed() before acting.
Practical verification
To confirm that your change‑filtered query works as expected:
- Run the example app above.
- Observe the log: updates appear only when health changes.
- Add the no‑op mutable write line and verify that updates now appear every frame.
- Remove the line and verify the original behavior returns.
These steps require only a standard Bevy project; no special permissions are needed beyond the ability to run cargo run in the project directory.
Actionable closing
When you next write a Bevy system that copies data each frame, ask: "Do I really need to touch every entity?" If the answer is no, replace a broad query with Changed (or Added/Removed for lifecycle events) and let the ECS skip the unchanged entities. Keep an eye on unnecessary mutable dereferences, and you’ll gain measurable frame‑time savings without sacrificing correctness.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.