Game Development

Bevy Rust Beginner Tutorial: Build Flappy Bird with ECS Game Structure

Build a Flappy Bird prototype in Bevy by setting up a project, spawning entities, wiring systems, and organizing gameplay with components, events, and a plugin.

9 min read
A Flappy Bird game scene shows a black bird flying among green pipes over a scrolling sky and hills background.
A Flappy Bird game is running with the bird, pipes, and scrolling background visible. — footage: chris biscardi

Start by scaffolding a new Bevy project, add a camera and player sprite, then implement gravity, jumping, bounds checks, pipes as a plugin, collision and scoring, and finish with background and polish. Bevy’s ECS structure organizes entities with components and systems for clear separation of game logic.

Start the Bevy project and run it

prerequisites

  • Have Rust and Cargo installed.
  • Install Bevy CLI (optional but recommended for scaffolding and running Bevy projects, especially for web deployments).
  • Clone the prepared starter repository or scaffold a new project with the Bevy CLI.
  • Add Bevy as a dependency if using Cargo directly.

A dark screen shows a single centered command line reading bevy new -t rust-adventure/flappy-bird --branch bevy-0.18-starter flappy-bird.

A command line for creating a Bevy starter project is shown on a dark background. — chris biscardi

To begin, scaffold a new project using either the Bevy CLI or Cargo. The Bevy CLI streamlines project setup and eases web deployment. If you use Cargo, remember to add Bevy as a dependency. Once your project is created, run it using bevy run (if using Bevy CLI) or cargo run. The starter project includes initial assets and Bevy 0.18 as the dependency. Confirm the app runs before adding any gameplay features.

Spawn the first camera and sprite

steps

  1. In your main file, add a startup system to spawn a camera entity. Cameras define what’s rendered and must be present.
  2. Use commands.spawn to create a camera entity and another entity with a sprite and transform components. This sprite is your initial placeholder player.
  3. Position the sprite using the transform’s x, y, and z values. Z controls render layer order, with higher z stacking above lower.

Example:

use bevy::prelude::*;

fn setup(mut commands: Commands) {
    // Spawn camera
    commands.spawn(Camera2dBundle::default());
    // Spawn player placeholder sprite
    commands.spawn(SpriteBundle {
        sprite: Sprite {
            color: Color::WHITE,
            ..Default::default()
        },
        transform: Transform::from_xyz(0.0, 0.0, 1.0),
        ..Default::default()
    });
}

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

Set the canvas size and keep the view stable

A code editor view shows a Camera2d OrthographicProjection configuration with scaling_mode set to AutoMax and max_width 480, max_height 270.

A code editor shows OrthographicProjection settings with AutoMax scaling and a 480 by 270 canvas size. — chris biscardi

Add the player bird and make it fall and jump

steps

  1. Load the bird image via the asset server and assign it to the sprite for your player entity.
  2. Create three components: Player, Gravity, and Velocity. Assign them to the player entity so it always has all needed data.
  3. Implement a system for gravity: subtract gravity from velocity every fixed update tick, then move the player by its velocity.
  4. Add an input system: on mouse button press, set player velocity to an upward value for jumping. Use the update schedule for input.

Example component definitions:

use bevy::prelude::*;

#[derive(Component)]
struct Player;

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

#[derive(Component, Default)]
struct Velocity(f32);

fn spawn_bird(mut commands: Commands, asset_server: Res<AssetServer>) {
    let bird_texture = asset_server.load("bevy_bird.png");

    commands.spawn((
        SpriteBundle {
            texture: bird_texture,
            ..Default::default()
        },
        Player,
        Gravity(1000.0),
        Velocity::default(),
    ));
}

// Gravity and movement system for fixed update
fn gravity_system(
    mut query: Query<(&mut Velocity, &Gravity, &mut Transform), With<Player>>,
    time: Res<Time>,
) {
    for (mut velocity, gravity, mut transform) in &mut query {
        velocity.0 -= gravity.0 * time.delta_seconds();
        transform.translation.y += velocity.0 * time.delta_seconds();
    }
}

// Input controls for jump
fn controls_system(
    mut query: Query<&mut Velocity, With<Player>>,
    mouse: Res<ButtonInput<MouseButton>>,
) {
    if mouse.just_pressed(MouseButton::Left) || mouse.just_pressed(MouseButton::Right) {
        if let Ok(mut velocity) = query.get_single_mut() {
            velocity.0 = 400.0;
        }
    }
}

A dark game window shows a small white bird sprite near the center and the text

A dark game window shows a small white bird sprite near the center with text at the bottom left. — chris biscardi

Handle bounds and respawn on game over

A terminal window shows repeated INFO log lines for flappy_bird check_in_bounds, and a separate game window area is blank.

A console prints repeated INFO check_in_bounds messages beside a blank flappy-bird game window. — chris biscardi

Build the pipe plugin and move pipes on a timer

file tree

**Minimal folder structure for pipes plugin:**

```
flappy_bird/
├── src/
│   ├── main.rs
│   └── lib.rs
├── assets/
│   ├── bevy_bird.png
│   ├── pipe.png
│   └── background.png
└── Cargo.toml
```
- Shared constants (canvas size, pipe size) are moved to `lib.rs`.
- Define a **PipePlugin** and add it from `main.rs`.
- Use a timer condition in the plugin to spawn pipes repeatedly, moving them left in fixed update.

A code slide shows App::new() with add_plugins(DefaultPlugins) and add_plugins(PipePlugin) in src/main.rs.

Code in src/main.rs adds DefaultPlugins and PipePlugin to the app. — chris biscardi

Example plugin structure:

// lib.rs
use bevy::prelude::*;

pub const PIPE_SPEED: f32 = 120.0;

pub struct PipePlugin;

impl Plugin for PipePlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(FixedUpdate, (spawn_pipes, shift_pipes_left));
    }
}

Make pipes into a hierarchy and despawn them

steps

  1. Spawn a root pipe entity with child sprites for pipe top, gap (points gate), and pipe bottom.
  2. Move the pipes leftwards by updating just the root’s transform.
  3. Despawn the root pipe entity (and all its children) when it leaves the play area using the despawn_recursive method.

Example hierarchy:

fn spawn_pipes(mut commands: Commands, asset_server: Res<AssetServer>) {
    let pipe_texture = asset_server.load("pipe.png");

    commands.spawn((
        TransformBundle::default(),
        Pipe,
        VisibilityBundle::default(),
        ChildrenBundle(vec![
            // Top pipe sprite
            SpriteBundle {
                texture: pipe_texture.clone(),
                // additional transforms
                ..Default::default()
            },
            // Points gate/gap sprite
            SpriteBundle {
                texture: pipe_texture.clone(),
                // additional transforms
                ..Default::default()
            },
            // Bottom pipe sprite
            SpriteBundle {
                texture: pipe_texture.clone(),
                // additional transforms
                ..Default::default()
            },
        ]),
    ));
}

fn despawn_pipes(
    mut commands: Commands,
    query: Query<(Entity, &Transform), With<Pipe>>,
) {
    for (entity, transform) in query.iter() {
        if transform.translation.x < -240.0 {
            commands.entity(entity).despawn_recursive();
        }
    }
}

Add collisions, gizmos, and score

before after

Before

Wrong: Score and collision logic run every frame and text updates constantly.

// Score update every frame (inefficient)
fn update_score_text(
    mut query: Query<&mut Text, With<ScoreText>>,
    score: Res<Score>,
) {
    for mut text in &mut query {
        text.value = format!("{}", score.0);
    }
}
After

Correct: Score updates only when the resource changes, and collision triggers score events with observers.

// Score update system runs only if score changes
fn update_score_text(
    mut query: Query<&mut Text, With<ScoreText>>,
    score: Res<Score>,
) {
    if score.is_changed() {
        for mut text in &mut query {
            text.value = format!("{}", score.0);
        }
    }
}

// Collision with gap triggers event, observer updates score
fn collision_system(
    mut commands: Commands,
    query: Query<&Transform, With<Player>>,
    gap_query: Query<(Entity, &Transform), With<PointsGate>>,
) {
    for player_transform in &query {
        for (gap_entity, gap_transform) in &gap_query {
            if is_collision(player_transform, gap_transform) {
                commands.entity(gap_entity).despawn();
                commands.trigger(ScoreEvent);
            }
        }
    }
}

Finish the Flappy Bird scene with background, polish, and motion

A scrolling parallax background is created with a custom material and WGSL shader, added via a plugin and mesh. Hide score gap and turn off gizmo visuals for the release build. Use sine wave functions to offset pipes vertically and rotate the bird to match its movement direction.

  • The bird sprite is tinted for visibility and rotates based on its velocity.
  • Background uses a mesh and custom material/shader for scrolling.
  • Pipes spawn at varying heights driven by a sine function.
  • Collision and score are fully enabled. Gizmo visuals and debug elements are hidden for the final release.

Also worth knowing

  • Match your Bevy version lockfile: If debug rectangle offsets appear wrong, copy the lockfile from the finished example to fix misalignment due to engine changes.
  • Create the background WGSL file manually: The shader file for the scrolling background isn’t auto-generated and needs to be placed in the assets folder.
  • Bevy CLI helps with scaffolding and running: Using the CLI is recommended for rapid project setup and easier web deployment.
  • ECS structure and events matter: The workshop’s explanation of required components and event handling is especially useful if upgrading across Bevy versions.

FAQ

Bevy Flappy Bird workshop

You can use the prepared starter project or scaffold a new one. The project structure uses Bevy’s ECS: entities hold data, systems handle game logic, and components define behavior.

how to make a Flappy Bird game in Bevy

Start with a camera and a player bird that falls due to gravity and jumps with input. Add pipe obstacles using a plugin, implement collision and scoring, and finish by adding background and polish.

Bevy beginner game tutorial

This guide uses simple entities for game objects, with components to store data and systems to manage movement, collision, and scoring.

Bevy ECS game structure tutorial

Bevy's ECS structure organizes the game around entities (the bird, pipes), components (gravity, velocity), and systems (movement, respawn, collision). Plugins encapsulate distinct features like pipes.

Rust game engine tutorial with Bevy

Your game starts in main, with app setup separated into plugins and systems. Gameplay logic and rendering are organized through ECS scheduling for clear and maintainable game structure.


Official reference: The Rust Programming Language.

Sources

Screenshots on this page are frames from the videos below, used to illustrate the steps they describe. All rights remain with their creators.

Checked against: