Introduction to ECS (Entity Component System) in Rust with Bevy

ECS in Bevy separates game objects into entities (IDs), components (data), and systems (logic) for efficient game development.

The inheritance trap

You are building a game. You start with a Player class. It has position, velocity, health, and an inventory. Then you add an Enemy. It has position, velocity, and health. You copy the movement logic. Then you add a Projectile. It has position and velocity. Suddenly you have three classes doing the same math for movement, but the inheritance tree is a mess. You want to add a Flying behavior, but Player doesn't fly. You end up with FlyingPlayer and FlyingEnemy. The codebase is fracturing. Adding a new feature requires touching half your classes.

ECS stops this fracture before it starts. It replaces the class hierarchy with a data-driven architecture where behavior and data are completely separate.

ECS breaks objects into pieces

ECS stands for Entity-Component-System. It flips the script on Object-Oriented Programming. Instead of objects that bundle data and behavior together, ECS separates them completely.

  • Entities are just IDs. Empty boxes. A unique number that identifies a game object. An entity has no data and no logic on its own.
  • Components are pure data. Structs with fields. No methods. No logic. A Position component holds x and y coordinates. A Velocity component holds speed. A Health component holds a number.
  • Systems are functions. They look for entities with specific components and do stuff. A movement system finds every entity with both Position and Velocity and updates the position. A rendering system finds every entity with Position and Sprite and draws it.

Think of a spreadsheet. Entities are row numbers. Components are columns. A system is a macro that scans rows with both "Position" and "Velocity" filled in and updates the position based on velocity. The spreadsheet doesn't care if row 5 is a player or an enemy. It only cares that the cells contain the right data.

Separate data from logic. The compiler enforces the boundary.

Minimal example

Bevy provides ECS out of the box. You define components with a derive macro, write systems as functions, and register them with the app.

use bevy::prelude::*;

// Component is just data. No methods. Derive adds the ECS registration.
#[derive(Component)]
struct Position {
    x: f32,
    y: f32,
}

// Small components benefit from Copy. It avoids references in queries.
#[derive(Component, Copy, Clone)]
struct Velocity {
    x: f32,
    y: f32,
}

// System queries entities with both Position and Velocity.
// &mut Position allows mutation. &Velocity is read-only.
fn move_system(mut query: Query<(&mut Position, &Velocity)>) {
    // iter_mut yields tuples matching the query signature.
    for (mut pos, vel) in query.iter_mut() {
        pos.x += vel.x;
        pos.y += vel.y;
    }
}

fn main() {
    App::new()
        // DefaultPlugins sets up the window, input, and scheduler.
        .add_plugins(DefaultPlugins)
        // Update runs every frame. move_system executes here.
        .add_systems(Update, move_system)
        .run();
}

Queries are the bridge. They define what data a system touches.

How Bevy stores data

ECS isn't just an organizational pattern. It changes how data lives in memory. This matters for performance.

In Object-Oriented Programming, you usually have an Array of Structures. If you have a Vec<Player>, each Player struct contains position, velocity, health, and inventory packed together. When you iterate to update positions, the CPU loads chunks of memory that include health and inventory data you don't need. This wastes cache bandwidth.

ECS uses a Structure of Arrays. Bevy stores all Position components in one contiguous Vec<Position>. All Velocity components in a separate Vec<Velocity>. When move_system runs, it iterates over the position array and the velocity array. The CPU loads only position and velocity data. The cache prefetcher predicts the next address perfectly. The loop runs faster because the data is contiguous.

This is why ECS is popular in games. You can have 10,000 entities, and the movement system only touches the memory it needs. The renderer only touches sprite data. The physics system only touches collider data. Each system gets cache-friendly access to its slice of the world.

Data locality is the hidden performance win. Design components to be small and focused.

Realistic spawning and markers

In a real app, you need to create entities. Bevy uses a Commands buffer to schedule changes. Systems can issue commands without worrying about immediate side effects or borrow conflicts.

use bevy::prelude::*;

#[derive(Component)]
struct Position { x: f32, y: f32 }

#[derive(Component, Copy, Clone)]
struct Velocity { x: f32, y: f32 }

// Marker component. No data. Used to identify entity types in queries.
#[derive(Component)]
struct Player;

fn spawn_player(mut commands: Commands) {
    // spawn takes a tuple of components. Order in tuple doesn't matter.
    commands.spawn((
        Position { x: 0.0, y: 0.0 },
        Velocity { x: 1.0, y: 0.0 },
        Player,
    ));
}

fn move_players(mut query: Query<(&mut Position, &Velocity), With<Player>>) {
    // With<Player> filters to only entities that have the Player marker.
    // This prevents moving enemies or projectiles with this system.
    for (mut pos, vel) in query.iter_mut() {
        pos.x += vel.x;
        pos.y += vel.y;
    }
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        // RunOnce ensures spawn_player runs exactly one time at startup.
        .add_systems(Startup, spawn_player)
        .add_systems(Update, move_players)
        .run();
}

Marker components keep your queries readable. Name your tags clearly.

The With<Player> filter is idiomatic. It tells the query to only return entities that possess the Player component. You can also use Without<Player> to exclude entities. This lets you write systems that apply to "everything with velocity except the player" without duplicating code.

Convention aside: use #[derive(Component, Copy, Clone)] for small data like positions and velocities. Copy components allow queries to take &Velocity by value instead of reference, which simplifies the borrow checker's job and can improve performance.

Pitfalls and borrow checker errors

ECS systems run in parallel. Bevy schedules them automatically based on their queries. If two systems touch the same component, Bevy figures out the order or runs them sequentially. If you try to write conflicting queries inside a single system, the borrow checker catches you.

Suppose you write a system that tries to mutate Position in one query and read Position in another query within the same function.

// This code fails to compile.
fn bad_system(
    mut positions: Query<&mut Position>,
    velocities: Query<&Velocity>,
) {
    // If an entity has both Position and Velocity,
    // positions borrows it mutably, velocities borrows it immutably.
    // E0502: cannot borrow as mutable because it is also borrowed as immutable.
}

The compiler rejects this with E0502 (cannot borrow as mutable because it is also borrowed as immutable). The ECS engine cannot guarantee that the two queries won't touch the same entity. Even if your logic seems safe, the borrow checker sees a potential conflict.

Fix the query. Use filters to prove disjoint sets.

fn good_system(
    mut positions: Query<&mut Position, With<Velocity>>,
    velocities: Query<&Velocity, Without<Position>>,
) {
    // With<Velocity> ensures positions only touches entities with Velocity.
    // Without<Position> ensures velocities only touches entities without Position.
    // The sets are disjoint. The borrow checker is happy.
}

If the borrow checker blocks a query, your system design is ambiguous. Fix the query, not the compiler.

Another common issue is forgetting that systems are functions, not methods. You cannot store state inside a system function. If you need persistent state, use a Resource. Resources are global singletons in the ECS world.

#[derive(Resource)]
struct GameConfig {
    max_speed: f32,
}

fn clamp_velocity(
    config: Res<GameConfig>,
    mut query: Query<&mut Velocity>,
) {
    for mut vel in query.iter_mut() {
        // Access global config via Res.
        if vel.x > config.max_speed {
            vel.x = config.max_speed;
        }
    }
}

Resources let you share data across systems. Use Res<T> for read-only access and ResMut<T> for mutation. Bevy tracks resource dependencies just like component queries.

When to use ECS

ECS changes how you structure code. It is not always the right tool.

Use ECS when you have many objects with shared behaviors but different data combinations. Games, simulations, and UI frameworks often benefit from the flexibility.

Use ECS when performance matters and you need cache-friendly data access. The Structure of Arrays layout reduces cache misses in tight loops over large datasets.

Use OOP when you have a small number of complex objects with tight coupling between data and logic. If your domain is naturally hierarchical and the object count is low, classes may be simpler.

Use Bevy when you want a data-driven game engine with ECS built-in. Bevy handles scheduling, parallelism, and resource management for you.

Use a custom ECS crate when you need minimal overhead or non-game use cases. Crates like hecs or legion provide ECS without the engine baggage.

ECS scales complexity better than inheritance. Start with ECS when the object graph gets messy.

Where to go next