Filtering Entities in Bevy with With and Without
Learn how to combine inclusion and exclusion criteria in Bevy ECS queries using With<T> and Without<T> to keep systems clean and avoid manual loops.
08 Jul 2026, 07:23 UTC

Problem: selecting only visible, alive enemies
In a 2‑D top‑down shooter you often need to apply damage to enemies that are currently on screen and not already marked as dead. If you query for all Enemy entities and then manually check Visible and !Dead inside the system, you end up with extra boilerplate and a higher chance of forgetting a condition.
Thesis: declarative filters keep queries readable
Bevy’s ECS lets you express inclusion and exclusion directly in the query type using With and Without. The resulting system signature reads like a sentence: “give me entities that have Enemy and Visible but do not have Dead.” This keeps the system body focused on the actual work.
How With and Without work
With requires that the entity possesses component T. Without requires that the entity does not have component T. You can combine any number of them in a tuple, and the order does not matter. The query will only iterate over entities that satisfy all predicates.
For example, Query, With, Without)> matches entities that have both Enemy and Visible components and lack the Dead component.
Worked example: damage timer system
use bevy::prelude::*;
#[derive(Component)]
struct Enemy;
#[derive(Component)]
struct Visible;
#[derive(Component)]
struct Dead;
#[derive(Component)]
struct DamageTimer(Timer);
fn damage_visible_enemies(
mut commands: Commands,
q: Query, With, Without)>,
) {
for entity in q.iter() {
// Insert a timer that will trigger damage after 0.5 seconds
commands.entity(entity).insert(DamageTimer(Timer::from_seconds(0.5, TimerMode::Once)));
}
}
fn setup(mut commands: Commands) {
commands.spawn((Enemy, Visible));
commands.spawn((Enemy, Visible, Dead)); // should be ignored
commands.spawn((Enemy,)); // missing Visible -> ignored
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_startup_system(setup)
.add_system(damage_visible_enemies)
.run();
}
When you run this program, only the first spawned entity (Enemy + Visible) receives a DamageTimer. The second entity is skipped because it has Dead, and the third is skipped because it lacks Visible.
Trade‑off: compile‑time complexity vs. flexibility
Each additional filter adds a type parameter to the query tuple. As the number of filters grows, the compiler must instantiate more monomorphized versions, which can increase build times and make system signatures harder to read. For filters that change frequently at runtime (e.g., a player‑selected list of enemy types), it may be clearer to keep a broad query and use an if statement inside the system to test the dynamic condition.
If you notice longer compile times, consider:
- Grouping related flags into a single component (e.g.,
EnemyStatewith an enum) to reduce tuple length. - Using
QueryFiltertraits only when the filter set is stable.
Actionable closing: verify the filter works
- Create a new Bevy project:
cargo new bevy_demo --bin. - Add Bevy to
Cargo.tomlwith thedynamicfeature:bevy = { version = "0.12", features = ["dynamic"] }. - Paste the example code into
src/main.rs. - Run
cargo run. You should see the window open without panic; the system will execute each frame. - To confirm the filter, modify the spawn calls: remove
Visiblefrom the first entity or addDeadto it, then rerun. The system will no longer insertDamageTimerfor that entity. - Optionally run
cargo check --verboseto ensure no type‑mismatch warnings appear, indicating the query resolved correctly.
By expressing inclusion and exclusion declaratively, you keep your systems focused on behavior rather than bookkeeping, making the code easier to maintain as your game grows.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.