LabHub

Blog

Bevy Game Engine — A Hands-On Rust ECS Tour of Modern Game Development (Deep Dive, 2026)

한국어English日本語

Prologue — Why Rust and ECS Fit Game Development

The history of game engines compresses into two lines.

  1. The inheritance era (1995–2015) — Unity's MonoBehaviour, Unreal's UObject, Godot's Node. Every game object descended from a class hierarchy, and "GameObject is a Component" built deep trees. C++ and C# fit naturally.
  2. The data-oriented era (2015–) — Naughty Dog, Insomniac, and Epic's Mass system in Unreal 5 conceded that "a cache miss is a frame drop." Data-Oriented Design rose, and Entity-Component-System emerged as the textbook answer.

ECS in one paragraph.

No inheritance tree. Player extends Character extends Pawn vanishes. A "player" is an entity that happens to carry Player, Position, Velocity, and Health components.

Layer Rust on top of ECS and two old weaknesses dissolve at once. First, concurrency. The borrow checker proves at compile time that two systems cannot mutate the same component simultaneously, and Bevy's scheduler exploits that to parallelize automatically. Second, stability. Game code historically bruises against pointer, null, and out-of-bounds bugs — Rust removes the whole category.

Now the honest part. In May 2026, Unity still leads on mobile, indie, and VR. Unreal leads AAA and cinematics. Godot owns a strong slice of indie 2D. Bevy is the open-source star but counts shipped commercial titles on two hands. So this piece will not chant "Year of Bevy." It will answer instead — what does Bevy do well, how far has it come, where is it still stuck?

The plan.

  1. Understanding the ECS paradigm — how it differs from MonoBehaviour
  2. Bevy's anatomy — App, Schedule, Plugin
  3. The plugin architecture — everything is a plugin
  4. Rendering through wgpu — browser, desktop, and mobile from one codebase
  5. Your first Bevy game in thirty minutes — a circle that moves with arrow keys
  6. Honest comparison against Godot, Unity, Unreal, Macroquad
  7. Games actually shipped on Bevy — Foresight, Tunnet, and the truth about Tiny Glade
  8. Where Bevy still is not ready

The keeper sentence: "An engine is a tool, not a faith. But ECS is a paradigm before it is a tool, and Bevy is the most honest implementation of that paradigm — on top of Rust."


1. The ECS Paradigm — How It Differs From Unity and Unreal

1.1 The inheritance model in practice

An enemy in Unity usually looks like this.

// Unity / MonoBehaviour style
public class Enemy : MonoBehaviour {
    public int health = 100;
    public float speed = 2.0f;
    private Transform target;

    void Start() { target = GameObject.FindWithTag("Player").transform; }
    void Update() {
        transform.position = Vector3.MoveTowards(
            transform.position, target.position, speed * Time.deltaTime);
        if (health <= 0) Destroy(gameObject);
    }
}

Enemy inherits from MonoBehaviour, holds state (health, speed), and the behavior (Update) all in one class. A hundred enemies mean Update is dispatched a hundred times — virtual calls, cache misses, and branch mispredictions accumulate.

Unreal's AActor + UCharacterMovementComponent combination is structurally identical: a deep inheritance chain like AEnemy : public ACharacter : public APawn : public AActor, and each component holds a pointer back to its actor.

1.2 The ECS model in practice

The same enemy in Bevy.

// Bevy / ECS style
use bevy::prelude::*;

#[derive(Component)]
struct Enemy;

#[derive(Component)]
struct Health(i32);

#[derive(Component)]
struct Speed(f32);

#[derive(Component)]
struct Target(Entity);

fn move_enemies(
    time: Res<Time>,
    mut enemies: Query<(&mut Transform, &Speed, &Target), With<Enemy>>,
    targets: Query<&Transform, Without<Enemy>>,
) {
    for (mut tf, speed, target) in &mut enemies {
        if let Ok(target_tf) = targets.get(target.0) {
            let dir = (target_tf.translation - tf.translation).normalize_or_zero();
            tf.translation += dir * speed.0 * time.delta_secs();
        }
    }
}

fn kill_dead_enemies(mut commands: Commands, q: Query<(Entity, &Health), With<Enemy>>) {
    for (e, hp) in &q {
        if hp.0 <= 0 { commands.entity(e).despawn(); }
    }
}

The differences are sharp.

1.3 Archetypes and memory layout

Bevy (and Flecs, Unity DOTS, Unreal Mass) is an archetype-based ECS. It groups entities into chunks by "which components they have."

Archetype A: [Position, Velocity, Sprite] — a thousand entities laid out with Position contiguous, then Velocity contiguous, then Sprite contiguous. Archetype B: [Position, Velocity, Sprite, Health, Enemy] — a separate chunk.

A Query of Position plus Velocity walks chunks from both A and B, and inside each chunk it is just an array walk. No virtual dispatch, almost no cache miss. That is why ECS is fast.

1.4 The honest downsides of ECS

ECS is not a silver bullet.

The keeper sentence: "OOP cages verbs inside nouns. ECS keeps nouns (data) and verbs (systems) separated all the way down."


2. Bevy's Anatomy — App, Schedule, System

2.1 App

Every Bevy program starts at an App. The main function usually looks like this.

use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, spawn_camera)
        .add_systems(Update, (move_player, count_fps).chain())
        .run();
}

The crucial absence is the absence of a manager. There is no class GameManager : MonoBehaviour { void Awake() }. The App is the manager, and the game logic is free functions.

2.2 Schedule — When systems run

Bevy 0.10 retired stages and introduced schedules. As of 0.15 the main ones are.

Ordering between systems is controlled with .before(), .after(), .chain(), and named SystemSet groupings.

app.add_systems(
    Update,
    (
        input_system,
        movement_system.after(input_system),
        collision_system.after(movement_system),
    ),
);

2.3 Component, Resource, Event

Three ways to put data into the ECS world.

#[derive(Resource)]
struct Score(u32);

#[derive(Event)]
struct EnemyKilled { entity: Entity, by: Entity }

fn award_score(mut score: ResMut<Score>, mut events: EventReader<EnemyKilled>) {
    for _ in events.read() {
        score.0 += 100;
    }
}

Res is read-only access, ResMut is mutable. Two systems that both want ResMut of the same resource serialize automatically; a Res reader runs in parallel with another Res reader.

2.4 Query — How a system asks for data

fn move_player(
    time: Res<Time>,
    mut q: Query<(&mut Transform, &Speed), With<Player>>,
) {
    for (mut tf, speed) in &mut q {
        tf.translation.x += speed.0 * time.delta_secs();
    }
}

The Query signature is the system's data manifest. With<Player> is a filter — it does not pull the marker into the iteration, but it requires the entity to carry it. If two systems hold non-overlapping queries, Bevy can run them simultaneously.

The keeper sentence: "Reading Bevy code = the system signature already tells you what that system reads and what it writes."


3. Plugin Architecture — Everything Is a Plugin

Bevy's biggest aesthetic decision is that "the engine itself is a collection of plugins."

Unpack DefaultPlugins and roughly twenty plugins fall out.

Anything you do not want, you can drop.

// Headless server — ECS only, no window or renderer
App::new()
    .add_plugins(MinimalPlugins)
    .add_plugins(LogPlugin::default())
    .add_systems(Update, game_logic)
    .run();

3.1 Writing your own plugin

Bundling your own code into a plugin keeps modules clean.

use bevy::prelude::*;

pub struct PlayerPlugin;

impl Plugin for PlayerPlugin {
    fn build(&self, app: &mut App) {
        app
            .add_event::<PlayerHurt>()
            .add_systems(Startup, spawn_player)
            .add_systems(Update, (
                player_input,
                player_movement.after(player_input),
                player_animation,
            ));
    }
}

#[derive(Event)]
pub struct PlayerHurt { pub amount: i32 }

#[derive(Component)]
pub struct Player;

fn spawn_player(mut commands: Commands) {
    commands.spawn((
        Player,
        Transform::default(),
        // sprite, collider, etc.
    ));
}

fn player_input(/* ... */) {}
fn player_movement(/* ... */) {}
fn player_animation(/* ... */) {}

Then main becomes tiny.

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins((PlayerPlugin, EnemyPlugin, AudioPlugin, UiPlugin))
        .run();
}

3.2 The ecosystem (as of May 2026)

The bevy_assets repository on GitHub curates the canonical list.

The keeper sentence: "A plugin is not just a module — it is Bevy's philosophy. There is no border between engine and game code."


4. wgpu Rendering — One Codebase for Browser, Desktop, and Mobile

Bevy's renderer sits on wgpu, the Rust-side implementation of WebGPU. One codebase targets Vulkan, Metal, DirectX 12, WebGPU, and OpenGL ES.

4.1 What it gets right

4.2 What it does not get right

4.3 Custom shaders

Write WGSL, then implement the Material trait to register it as a Bevy material.

use bevy::{
    prelude::*,
    render::render_resource::{AsBindGroup, ShaderRef},
    sprite::Material2d,
};

#[derive(Asset, TypePath, AsBindGroup, Clone, Debug)]
struct WaveMaterial {
    #[uniform(0)] time: f32,
    #[uniform(0)] color: LinearRgba,
}

impl Material2d for WaveMaterial {
    fn fragment_shader() -> ShaderRef { "shaders/wave.wgsl".into() }
}

And the shader file.

struct Uniforms {
    time: f32,
    color: vec4<f32>,
};

@group(2) @binding(0) var<uniform> u: Uniforms;

@fragment
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
    let wave = sin(uv.x * 10.0 + u.time) * 0.5 + 0.5;
    return vec4<f32>(u.color.rgb * wave, 1.0);
}

The keeper sentence: "wgpu gave Bevy a 'one codebase, every platform' renderer almost for free — though 'every' does not yet mean 'optimal'."


5. Your First Bevy Game in Thirty Minutes — A Circle That Moves

Time to actually write code. "A circle on screen that moves with arrow keys." Done in thirty minutes.

5.1 Project setup

# If you do not have Rust, install via rustup
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# New project
cargo new moving_circle && cd moving_circle

Open Cargo.toml.

[package]
name = "moving_circle"
version = "0.1.0"
edition = "2021"

[dependencies]
bevy = "0.15"

# Faster dev compile — recommended
[profile.dev]
opt-level = 1

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

5.2 First code — empty window

src/main.rs.

use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .run();
}

fn setup(mut commands: Commands) {
    commands.spawn(Camera2d);
}
cargo run

A black window appears. The first build takes two to three minutes (dependency compile). Incremental builds afterward take seconds.

5.3 Draw a circle

use bevy::prelude::*;

#[derive(Component)]
struct Player;

#[derive(Component)]
struct Speed(f32);

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
) {
    commands.spawn(Camera2d);

    commands.spawn((
        Player,
        Speed(300.0),
        Mesh2d(meshes.add(Circle::new(40.0))),
        MeshMaterial2d(materials.add(Color::srgb(0.2, 0.7, 1.0))),
        Transform::from_xyz(0.0, 0.0, 0.0),
    ));
}

cargo run again. A blue circle sits in the middle of the window.

The shape of the code.

5.4 Move with input

Add a system to the Update schedule.

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .add_systems(Update, move_player)
        .run();
}

fn move_player(
    time: Res<Time>,
    keyboard: Res<ButtonInput<KeyCode>>,
    mut q: Query<(&mut Transform, &Speed), With<Player>>,
) {
    let dt = time.delta_secs();
    for (mut tf, speed) in &mut q {
        let mut dir = Vec2::ZERO;
        if keyboard.pressed(KeyCode::ArrowLeft)  { dir.x -= 1.0; }
        if keyboard.pressed(KeyCode::ArrowRight) { dir.x += 1.0; }
        if keyboard.pressed(KeyCode::ArrowUp)    { dir.y += 1.0; }
        if keyboard.pressed(KeyCode::ArrowDown)  { dir.y -= 1.0; }
        if dir != Vec2::ZERO {
            tf.translation += (dir.normalize() * speed.0 * dt).extend(0.0);
        }
    }
}

cargo run. Press arrow keys — the circle moves. Done.

The key patterns.

5.5 Add an enemy

#[derive(Component)]
struct Enemy;

fn setup(/* ... */) {
    // same as before

    commands.spawn((
        Enemy,
        Speed(150.0),
        Mesh2d(meshes.add(Circle::new(30.0))),
        MeshMaterial2d(materials.add(Color::srgb(1.0, 0.3, 0.3))),
        Transform::from_xyz(200.0, 200.0, 0.0),
    ));
}

fn chase_player(
    time: Res<Time>,
    player_q: Query<&Transform, With<Player>>,
    mut enemy_q: Query<(&mut Transform, &Speed), (With<Enemy>, Without<Player>)>,
) {
    let Ok(player_tf) = player_q.single() else { return };
    let dt = time.delta_secs();
    for (mut tf, speed) in &mut enemy_q {
        let dir = (player_tf.translation - tf.translation).normalize_or_zero();
        tf.translation += dir * speed.0 * dt;
    }
}

// in main: .add_systems(Update, (move_player, chase_player))

The red enemy chases the player. ECS shines here — no new class hierarchy, only a new component and a new system, and a new behavior appears.

The keeper sentence: "Reaching 'a circle that moves with arrow keys' in thirty minutes is the real first-impression test for an engine. Bevy passes."


6. Honest Comparison Against Godot, Unity, Unreal, Macroquad

An engine is not a religion. To the table.

6.1 One-line summary

6.2 Side-by-side

Language and paradigm

Editor

Compile speed

Runtime performance

Mobile/console builds

Open-source posture

6.3 When to choose what

The keeper sentence: "Bevy is not a 'Unity replacement.' It is a different tool — correct only when you want Rust, ECS, open source, and modularity at the same time."


7. Games Actually Shipped on Bevy — What Is True

This is where honesty matters. Lists of "games made with Bevy" circulating on the internet sometimes lie.

7.1 Commercial games genuinely built on Bevy

7.2 The common misunderstanding — Tiny Glade

Pounce Light's Tiny Glade topped Steam Popular New Releases shortly after its September 2024 launch and became a celebrated indie simulator. It does not use Bevy — the studio built its own Rust engine. Some Rust ecosystem libraries (wgpu and others) are reused, but it is wrong to file Tiny Glade under "Bevy games." The developers have stated this in multiple interviews and GDC talks.

Ignore posts that claim "Tiny Glade was made with Bevy." The Bevy community welcomed Tiny Glade's success as evidence that "Rust games can ship," but the engine is distinct.

7.3 Non-game uses

The ECS plus wgpu combination is also used outside games.

The fact that the engine can be imported like a library is a real differentiator — Unity and Unreal cannot do this in practice.

The keeper sentence: "Bevy does not lie about making 'a Tiny Glade.' It tells the truth — it shipped Foresight, Jarl, and Tunnet, and it is used in data visualization and simulation as well."


8. Where Bevy Is Not Ready — Honest Limits

"Year of Bevy" has been declared in 2024, in 2025, and again in 2026. Let us split what is actually mature from what remains rough.

8.1 What matured

8.2 What is still rough

8.3 The road from 0.15 to 1.0

The official Bevy roadmap (as of May 2026) holds these items before 1.0.

  1. BSN landing and a minimum viable official editor.
  2. A stable ABI for dynamic plugin loading.
  3. A grounded story for hot reload.
  4. Desktop polish — multiple windows, high-DPI, drag-and-drop.
  5. Visual scripting (likely as a plugin, if at all).

Do not ask when 1.0 ships. The answer is always "when it is ready." But 0.15 is stable enough to start a small indie game today.

The keeper sentence: "Do not wait for Bevy 1.0. Build the game 0.15 lets you build now. Migrate at 1.0."


Epilogue — Guidance for People Starting Out

Thirty-minute hands-on checklist

Seven Bevy anti-patterns

  1. Carrying a Vec<Entity> through systems and doing lookups every frame — a well-shaped query removes the need.
  2. Modeling everything as a component — state machines and small enums often belong inside a single component.
  3. Using Commands everywhere for mutation — fine, but the effect lands next frame. Use an exclusive system with &mut World when you need immediate effect.
  4. Unwrapping Query::single() with a panic — prefer let Ok(...) = q.single() else { return };.
  5. Event overflow — if no EventReader runs in a frame, the events vanish. Two systems reading the same event need consistent ordering plus an add_event registration.
  6. Putting render systems on Update — render systems are mostly internal. Your code should only write data.
  7. Ignoring 0.x migrations — each minor bump costs four to twelve hours. Bake a migration buffer into the schedule until 1.0 lands.

What is next

The keeper sentence: "Rust plus ECS is the most honest answer to the game-engine-paradigm question. Bevy is the quickest path to writing that answer with your own hands."


References

Comments

No comments yet.

Sign in to leave a comment