Skip to main content
  1. Programming Languages/
  2. Rust Engineering: Fearless Concurrency & Systems Programming/

Building Data-Driven Games: A Comprehensive Bevy Engine Tutorial

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect. Bridging the gap between theoretical CS and production-grade engineering for 300+ deep-dive guides.

It is end of 2025, and the landscape of game development has shifted. While industry giants continue to rely on established C++ workflows, Rust has carved out a massive niche for itself—not just as a systems language, but as a premier choice for reliable, high-performance game development. At the forefront of this revolution is Bevy, a data-driven game engine built in Rust, for Rust.

If you are coming from an Object-Oriented background (like Unity’s MonoBehaviour or Unreal’s Actor model), Bevy requires a fundamental rewiring of how you approach game logic. It utilizes an Entity Component System (ECS) architecture strictly. There are no “classes” defining your player or enemies; there is only data, and systems that operate on that data in parallel.

In this tutorial, we aren’t just printing “Hello World.” We are going to build a functional 2D architectural skeleton for a game, handling movement, input, and component management. We will explore why ECS is superior for performance and how to leverage Rust’s type system to prevent runtime errors common in game dev.

Prerequisites
#

Before we dive into the ECS architecture, ensure your development environment is ready. We assume you are a mid-to-senior developer, so we will keep this brief.

  1. Rust Toolchain: Ensure you are running the latest stable Rust version (1.85+ is recommended for 2025 features).

  2. IDE: VS Code with rust-analyzer or JetBrains RustRover.

  3. OS Dependencies:

    • Windows/macOS: Usually requires no extra setup.
    • Linux: You will need ALSA and Udev development packages.
    # Ubuntu/Debian
    sudo apt-get install g++ pkg-config libx11-dev libasound2-dev libudev-dev

The ECS Paradigm Shift
#

Before writing code, we must visualize the architecture. In a traditional engine, a Player class holds its own data (health, position) and its own logic (move(), take_damage()).

In Bevy ECS:

  1. Entities: Unique IDs (integers). They mean nothing on their own.
  2. Components: Plain Old Data (POD) structs attached to entities (e.g., Position, Velocity).
  3. Systems: Functions that run every frame, query for specific components, and mutate data.

Here is how the data flow looks in a typical Bevy frame:

flowchart TD subgraph Data Layer C1[Component: Transform] C2[Component: Velocity] C3[Component: Input] end subgraph System Layer S1(Input System) S2(Movement System) S3(Rendering System) end subgraph Loop INPUT[Player Input] --> S1 S1 -- Writes --> C2 C2 -- Reads --> S2 S2 -- Writes --> C1 C1 -- Reads --> S3 S3 --> SCREEN[Draw to Screen] end style S1 fill:#f9f,stroke:#333,stroke-width:2px style S2 fill:#bbf,stroke:#333,stroke-width:2px style S3 fill:#dfd,stroke:#333,stroke-width:2px style Data Layer fill:#eee,stroke:#333,stroke-dasharray: 5 5

This separation allows Bevy to automatically parallelize systems that don’t access the same data, utilizing every core of your CPU without you writing a single line of threading code.


Step 1: Project Setup and Optimization
#

Create a new binary project.

cargo new bevy_roguelite
cd bevy_roguelite

Edit your Cargo.toml. Bevy is a massive dependency. To keep compile times sane during development, we need to enable dynamic linking.

[package]
name = "bevy_roguelite"
version = "0.1.0"
edition = "2024" # Rust 2024 edition is standard now

[dependencies]
bevy = { version = "0.15", features = ["dynamic_linking"] }

# Optimization for release builds
[profile.dev]
opt-level = 1

[profile.dev.package."*"]
opt-level = 3

[profile.release]
lto = true
codegen-units = 1

Pro Tip: The dynamic_linking feature is vital for development speed but must be disabled when you build for release distribution.


Step 2: The Application Boilerplate
#

Bevy apps are built using the Builder pattern. We need to add DefaultPlugins (which includes the renderer, windowing, asset loading, etc.) and run the app.

Create src/main.rs:

use bevy::prelude::*;

fn main() {
    App::new()
        // Essential core plugins (Window, Renderer, Input, etc.)
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Rust DevPro: Bevy Tutorial".into(),
                resolution: (1280.0, 720.0).into(),
                ..default()
            }),
            ..default()
        }))
        // Add our setup system to the Startup schedule
        .add_systems(Startup, setup_camera)
        .run();
}

fn setup_camera(mut commands: Commands) {
    // Spawn a 2D camera
    commands.spawn(Camera2dBundle::default());
}

Run this with cargo run. You should see a gray window. This is your canvas.


Step 3: Components and Bundles
#

Let’s define our Player. In Bevy, we create “Marker Components” (empty structs) to tag entities, and data components to hold state.

// Marker component to identify the player
#[derive(Component)]
struct Player;

// Component to handle movement speed
#[derive(Component)]
struct MovementStats {
    speed: f32,
}

Now, let’s spawn the player sprite. We use Commands to spawn entities. Bevy provides SpriteBundle which is a collection of standard components (Transform, GlobalTransform, Visibility, Image handle, etc.) needed to render a sprite.

Update your src/main.rs:

// Add this to main(): .add_systems(Startup, spawn_player)

fn spawn_player(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn((
        // The visual representation
        SpriteBundle {
            transform: Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::splat(2.0)),
            texture: asset_server.load("sprites/player.png"), // Ensure you have assets/sprites/player.png
            ..default()
        },
        // Our custom components
        Player,
        MovementStats { speed: 300.0 },
    ));
}

Note: Create an assets/sprites folder and drop a small PNG image named player.png inside.


Step 4: The Logic System (Input & Movement)
#

This is the heart of ECS. We need a system that reads Input, reads Time (for frame-rate independence), reads MovementStats, and writes to Transform.

Notice the query signature: Query<(&mut Transform, &MovementStats), With<Player>>. This is SQL-like logic. “Give me mutable access to Transform and read-only access to Stats for every entity that also has a Player tag.”

// Add this to main(): .add_systems(Update, player_movement)

fn player_movement(
    // Resource: Global keyboard input
    keyboard_input: Res<ButtonInput<KeyCode>>, 
    // Resource: Time delta
    time: Res<Time>,
    // Query: mutable transform, readonly stats, filter by Player
    mut query: Query<(&mut Transform, &MovementStats), With<Player>>,
) {
    // 1. Calculate direction vector
    let mut direction = Vec3::ZERO;

    if keyboard_input.pressed(KeyCode::KeyW) || keyboard_input.pressed(KeyCode::ArrowUp) {
        direction.y += 1.0;
    }
    if keyboard_input.pressed(KeyCode::KeyS) || keyboard_input.pressed(KeyCode::ArrowDown) {
        direction.y -= 1.0;
    }
    if keyboard_input.pressed(KeyCode::KeyA) || keyboard_input.pressed(KeyCode::ArrowLeft) {
        direction.x -= 1.0;
    }
    if keyboard_input.pressed(KeyCode::KeyD) || keyboard_input.pressed(KeyCode::ArrowRight) {
        direction.x += 1.0;
    }

    // 2. Normalize to prevent faster diagonal movement
    if direction.length_squared() > 0.0 {
        direction = direction.normalize();
    }

    // 3. Apply movement to all matching entities (usually just one Player)
    for (mut transform, stats) in &mut query {
        transform.translation += direction * stats.speed * time.delta_seconds();
    }
}

Why delta_seconds()?
#

If you simply add speed to position, your game speed becomes tied to the framerate. A user running at 144Hz would move twice as fast as a user at 72Hz. Multiplying by time.delta_seconds() ensures movement is defined in units per second, not units per frame.


Comparison: Bevy vs. Traditional OOP Engines
#

For developers migrating from Unity or Godot, here is a breakdown of how concepts map to Bevy.

Feature Unity / Godot (OOP) Bevy (ECS)
Object Definition Classes/Prefabs inheriting MonoBehaviour or Node. Entities composed of loose Component structs.
Logic Location Inside the object class (Update() method). In global Systems that iterate over data.
Data Storage Scattered on the heap (objects are pointers). Contiguous arrays (Archetypes) for CPU cache efficiency.
Multithreading Difficult, requires manual thread management or Jobs. Automatic. Systems run in parallel by default.
State Management Managers/Singletons. Resource types (Global unique data).

Step 5: Refactoring with Plugins
#

As your project grows, main.rs will become cluttered. Bevy encourages modularity via Plugin. Let’s wrap our player logic into a reusable plugin.

Create src/player.rs:

use bevy::prelude::*;

pub struct PlayerPlugin;

impl Plugin for PlayerPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(Startup, spawn_player)
           .add_systems(Update, player_movement);
    }
}

#[derive(Component)]
struct Player;

#[derive(Component)]
struct MovementStats { speed: f32 }

fn spawn_player(mut commands: Commands, asset_server: Res<AssetServer>) {
    // ... same code as before ...
}

fn player_movement(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    time: Res<Time>,
    mut query: Query<(&mut Transform, &MovementStats), With<Player>>,
) {
    // ... same code as before ...
}

Now main.rs becomes clean and declarative:

mod player;
use player::PlayerPlugin;
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(PlayerPlugin) // Modular logic
        .add_systems(Startup, setup_camera)
        .run();
}
// ... setup_camera ...

Performance Best Practices & Pitfalls
#

Even in a high-performance engine like Bevy, you can write slow code. Here are key insights for 2025.

1. Archetype Fragmentation
#

Bevy stores entities with the same combination of components in “Archetypes” (tables).

  • Good: Spawning an entity with (A, B, C).
  • Bad: Spawning with (A, B), then adding C in the next frame, then removing B. Adding/removing components forces Bevy to move the entity from one table to another (memory copy). Try to spawn entities with all the components they need upfront using Bundles.

2. Query Filtering
#

Don’t iterate over everything if you don’t have to.

  • Use With<T> and Without<T>: Query<&Transform, With<Enemy>> is much faster than iterating all Transforms and checking an if enemy flag.
  • Use Changed<T>:
    // Only updates if the transform actually changed this frame
    fn print_moved_player(query: Query<&Transform, (With<Player>, Changed<Transform>)>) {
        for t in &query {
            println!("Player moved to {:?}", t.translation);
        }
    }

3. System Order Ambiguity
#

If System A writes to Health and System B reads Health, who runs first? Bevy runs them in parallel if possible, which is a race condition risk (though Rust’s borrow checker prevents data races, logical races can occur). Explicitly order them:

app.add_systems(Update, (
    apply_damage,
    update_health_bar
).chain()); // Forces sequential execution

Conclusion
#

You have successfully built the foundation of a data-driven game in Rust. We moved away from the “Object” mentality and embraced the “Data” mentality. By using Bevy, you gain the safety of Rust and the performance of a parallelized ECS architecture.

The ecosystem in 2025 is rich. From here, you should look into:

  • Bevy Rapier / Avian: For 2D/3D physics integration.
  • Bevy Hanabi: For GPU particle effects.
  • Leafwing Input Manager: For complex input handling (key remapping, chords).

ECS takes time to get used to, but once it clicks, you will find it difficult to go back to the tangled webs of object-oriented game code.

Happy coding, and see you in the next frame!


The Architect’s Pulse: Engineering Intelligence

As a CTO with 21+ years of experience, I deconstruct the complexities of high-performance backends. Join our technical circle to receive weekly strategic drills on JVM internals, Go concurrency, and cloud-native resilience. No fluff, just pure architectural execution.