bevy-ecs-patterns
Use when writing Bevy (Rust ECS) game code - pin and respect the repo's minor version, plugins/systems/queries/resources, schedules with FixedUpdate and Time, states and run conditions, messages vs ob
SKILL.mdcatalog/agents/game-developer/skills/bevy-ecs-patterns/SKILL.md
GitHubBevy ECS Patterns
Overview
Bevy is a code-first Rust engine built on an ECS: entities are IDs, components are plain structs, systems are ordinary functions whose parameters declare the data they touch, and the scheduler runs them in parallel where it can. Bevy also ships a breaking release every few months — the API you remember may not be the API the repository pins.
Core principle: The repository's pinned Bevy minor is the API. Systems are small functions over queries, simulation runs in FixedUpdate, and every system is testable by building an App and calling update().
Version first
# Cargo.toml — for 0.x crates "0.18" already means >=0.18.0, <0.19.0: the minor is pinned
bevy = "0.18"
Read Cargo.lock for the resolved version and that version's migration guide before writing code. Never bump the minor in a feature task. Changes that bite code written from memory:
| Since | Change |
|---|---|
| 0.15 | Time::delta_seconds() → delta_secs(); required components (#[require(...)]) replace most bundles; Single<...> system parameter |
| 0.16 | Query::single() / get_single() return Result; systems may return Result and use ? |
| 0.17 | Buffered events renamed to messages: #[derive(Message)], MessageWriter::write, MessageReader::read, app.add_message::<T>(); observers use On<T> for entity events |
Plugins, components, systems
use bevy::prelude::*;
#[derive(Component, Default)]
#[require(Transform, Velocity)]
pub struct Player;
#[derive(Component, Default, Deref, DerefMut)]
pub struct Velocity(pub Vec2);
#[derive(Resource)]
pub struct MoveTuning { pub speed: f32 }
#[derive(Message)]
pub struct PlayerHit { pub damage: u32 }
pub struct MovementPlugin;
impl Plugin for MovementPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(MoveTuning { speed: 220.0 })
.insert_resource(Time::<Fixed>::from_hz(60.0))
.add_message::<PlayerHit>()
.add_systems(Update, read_input.run_if(in_state(GameState::Playing)))
.add_systems(FixedUpdate, (apply_velocity, resolve_hits).chain());
}
}
fn read_input(keys: Res<ButtonInput<KeyCode>>, tuning: Res<MoveTuning>, mut player: Single<&mut Velocity, With<Player>>) {
let x = keys.pressed(KeyCode::KeyD) as i8 as f32 - keys.pressed(KeyCode::KeyA) as i8 as f32;
player.0 = Vec2::new(x * tuning.speed, player.0.y);
}
fn apply_velocity(time: Res<Time>, mut q: Query<(&mut Transform, &Velocity)>) {
for (mut t, v) in &mut q {
t.translation += (v.0 * time.delta_secs()).extend(0.0); // in FixedUpdate, Time is the fixed clock
}
}
- One plugin per feature;
main.rsonly adds plugins. - Systems are small and named for what they do; order with
.chain(),.before()/.after()orSystemSets, not by hoping. - Read input in
Update, simulate inFixedUpdate(default 64 Hz; set it explicitly), render-side smoothing inUpdate/PostUpdate(game-loop-and-time). Commandsare deferred: an entity spawned in a system is visible to queries only after the command flush (the next sync point), not later in the same system.- Use change detection (
Changed<T>,Added<T>,Ref<T>::is_changed) instead of recomputing every frame; filter queries (With,Without) so systems touch only what they need. Res<T>reads run in parallel;ResMut<T>serialises every system that touchesT— keep shared mutable resources few.- Tuning values in resources or assets loaded from RON/JSON (data-driven-gameplay-design), not literals in systems.
States and conditions
#[derive(States, Default, Debug, Clone, PartialEq, Eq, Hash)]
pub enum GameState { #[default] Loading, Menu, Playing, Paused }
app.init_state::<GameState>()
.add_systems(OnEnter(GameState::Playing), spawn_level)
.add_systems(OnExit(GameState::Playing), despawn_level);
Entities spawned for a state are cleaned up on exit (a marker component plus a despawn system, or the state-scoped entity helpers of the pinned version).
Testing systems
#[cfg(test)]
mod tests {
use super::*;
use bevy::time::TimeUpdateStrategy;
use std::time::Duration;
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f64(1.0 / 60.0)))
.insert_resource(Time::<Fixed>::from_hz(60.0))
.add_systems(FixedUpdate, apply_velocity);
app.update(); // the first update has zero delta; prime the clock
app
}
#[test]
fn moves_by_speed_times_elapsed_time() {
let mut app = app();
let e = app.world_mut().spawn((Transform::default(), Velocity(Vec2::new(60.0, 0.0)))).id();
for _ in 0..60 { app.update(); }
let x = app.world().get::<Transform>(e).unwrap().translation.x;
assert!((x - 60.0).abs() < 1.5, "x = {x}");
}
}
MinimalPluginsgives time and the schedule runner without a window or GPU — runs anywherecargo testruns.- Control time with
TimeUpdateStrategy::ManualDuration; never sleep. - Send messages in a test through the world (
write_messageon the pinned version,send_eventbefore 0.17) and read results back with queries or a resource. world.run_system_once(my_system)for a one-off system call in a test.- Pure math (damage, steering) as plain functions with ordinary unit tests.
Build hygiene
cargo test && cargo clippy -- -D warningsbefore hand-off (rule game-build-check); Bevy projects commonly allowclippy::too_many_argumentsandclippy::type_complexity— follow the repository's lint config.- Fast iteration:
opt-level = 1for the crate andopt-level = 3for dependencies in[profile.dev]/[profile.dev.package."*"]; thedynamic_linkingfeature only in dev, never in release builds. - First compile takes minutes: give
run_terminala long timeout or run it detached to a log. - Allocation in hot systems: reuse buffers with
Local<Vec<T>>instead of collecting into a newVecevery frame. - Headless runs:
MinimalPlugins+ScheduleRunnerPlugin, orDefaultPluginswith no primary window; screenshots need a GPU and a window (game-visual-self-review).
Common Mistakes
- Code from an older or newer Bevy version (
delta_seconds,EventWriter,get_single) that does not compile on the pinned one. - Movement in
UpdatewithTimeand physics inUpdate— frame-rate dependent. - Expecting a just-spawned entity in the same system's query.
- One giant system per feature with
ResMuton everything, killing parallelism. - Bumping Bevy "while here".
Red Flags
bevy = "*", a>=range, or a version change inCargo.tomlthe task did not ask for.std::thread::sleeporInstant::now()inside systems or tests.- A system test that opens a window.