Skip to main content
โšก Calmops

Rust Game Development Basics: From Zero to Your First Game

Rust Game Development Basics: From Zero to Your First Game

TL;DR: This guide introduces game development in Rust using the Bevy engine. You’ll learn the fundamentals of game loops, entity-component-system (ECS) architecture, rendering, physics, and build a complete 2D game by the end.


Introduction

Rust is becoming an increasingly popular choice for game development due to its memory safety, performance, and modern tooling. While not as mature as C++ or C# for games, Rust offers unique advantages:

  • Memory safety without garbage collection - Predictable frame times
  • Zero-cost abstractions - High-level code compiles to efficient machine code
  • Concurrency - Handle game systems in parallel
  • Cross-platform - Target Windows, macOS, Linux, Web, and more

This article focuses on Bevy, the most popular Rust game engine, and walks you through building your first game.


Setting Up Your Development Environment

Installing Required Tools

# Install Rust if you haven't already
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Install cargo-generate for scaffolding
cargo install cargo-generate

# Install wasm-bindge-cli for WebAssembly builds
cargo install wasm-bindgen-cli

Creating a Bevy Project

# Create a new Bevy project
cargo new my_first_game
cd my_first_game

# Add Bevy as a dependency
cargo add bevy

Update your Cargo.toml with optimized settings:

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

[dependencies]
bevy = "0.13"

# For better compile times in development
[profile.dev]
opt-level = 0

# Optimized for release
[profile.release]
opt-level = "z"  # Optimize for size
lto = true
codegen-units = 1

Understanding the Game Loop

Every game follows a fundamental loop:

use bevy::prelude::*;

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

fn setup(mut commands: Commands) {
    // Called once when the app starts
    commands.spawn(Camera2dBundle::default());
    
    println!("Game started!");
}

fn handle_input(keys: Res<ButtonInput<KeyCode>>, mut player_query: Query<&mut Transform, With<Player>>) {
    // Handle keyboard input every frame
}

fn move_player(mut query: Query<&mut Transform, With<Player>>, time: Res<Time>) {
    // Update player position
}

fn check_collisions() {
    // Check for collisions between entities
}

Entity-Component-System (ECS) Architecture

Bevy uses ECS, a pattern that separates data (components) from behavior (systems):

Components - Data

use bevy::prelude::*;

// Components are data only - no methods
#[derive(Component)]
struct Player {
    speed: f32,
    health: f32,
}

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

#[derive(Component)]
struct Enemy {
    patrol_points: Vec<Vec2>,
    current_target: usize,
}

#[derive(Component)]
struct SpriteSize {
    width: f32,
    height: f32,
}

Systems - Behavior

fn move_player(
    mut player_query: Query<(&mut Transform, &Player)>,
    keys: Res<ButtonInput<KeyCode>>,
    time: Res<Time>,
) {
    for (mut transform, player) in player_query.iter_mut() {
        let mut direction = Vec3::ZERO;
        
        if keys.pressed(KeyCode::ArrowUp) || keys.pressed(KeyCode::KeyW) {
            direction.y += 1.0;
        }
        if keys.pressed(KeyCode::ArrowDown) || keys.pressed(KeyCode::KeyS) {
            direction.y -= 1.0;
        }
        if keys.pressed(KeyCode::ArrowLeft) || keys.pressed(KeyCode::KeyA) {
            direction.x -= 1.0;
        }
        if keys.pressed(KeyCode::ArrowRight) || keys.pressed(KeyCode::KeyD) {
            direction.x += 1.0;
        }
        
        if direction.length() > 0.0 {
            direction = direction.normalize();
            transform.translation += direction * player.speed * time.delta_seconds();
        }
    }
}

Resources - Global State

// Resources are globally accessible singleton data
#[derive(Resource)]
struct GameState {
    score: u32,
    is_paused: bool,
    current_level: u32,
}

impl Default for GameState {
    fn default() -> Self {
        Self {
            score: 0,
            is_paused: false,
            current_level: 1,
        }
    }
}

// Accessing resources in systems
fn update_score(
    mut game_state: ResMut<GameState>,
    mut score_display: Query<&mut Text, With<ScoreText>>,
) {
    for mut text in score_display.iter_mut() {
        text.sections[0].value = format!("Score: {}", game_state.score);
    }
}

Building a Complete 2D Game: Space Shooter

Let’s build a complete space shooter game step by step.

Step 1: Project Setup and Player Ship

use bevy::prelude::*;
use bevy::sprite::{SpriteSheetBundle, TextureAtlasSprite};

const PLAYER_SPEED: f32 = 400.0;
const BULLET_SPEED: f32 = 600.0;
const ENEMY_SPEED: f32 = 100.0;

#[derive(Component)]
struct Player;

#[derive(Component)]
struct Bullet;

#[derive(Component)]
struct Enemy;

#[derive(Component)]
struct FromPlayer;

#[derive(Resource)]
struct GameState {
    score: u32,
    game_over: bool,
}

impl Default for GameState {
    fn default() -> Self {
        Self { score: 0, game_over: false }
    }
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
        .init_resource::<GameState>()
        .add_systems(Startup, setup)
        .add_systems(Update, (
            player_movement,
            player_shooting,
            bullet_movement,
            enemy_spawn,
            enemy_movement,
            bullet_enemy_collision,
            player_enemy_collision,
        ))
        .run();
}

fn setup(mut commands: Commands) {
    commands.spawn(Camera2dBundle::default());
    
    // Spawn player
    commands
        .spawn(SpriteBundle {
            transform: Transform::from_xyz(0.0, -250.0, 0.0),
            sprite: Sprite {
                color: Color::rgb(0.3, 0.5, 0.9),
                custom_size: Some(Vec2::new(50.0, 30.0)),
                ..default()
            },
            ..default()
        })
        .insert(Player)
        .insert(Velocity { x: 0.0, y: 0.0 });
    
    // UI - Score display
    commands.spawn(
        TextBundle::from_section(
            "Score: 0",
            TextStyle {
                font_size: 30.0,
                color: Color::WHITE,
                ..default()
            },
        )
        .with_style(Style {
            position_type: PositionType::Absolute,
            top: Val::Px(10.0),
            left: Val::Px(10.0),
            ..default()
        }),
    );
}

Step 2: Player Movement

fn player_movement(
    mut player_query: Query<&mut Transform, With<Player>>,
    keys: Res<ButtonInput<KeyCode>>,
    time: Res<Time>,
) {
    let mut transform = player_query.single_mut();
    let mut direction = Vec3::ZERO;
    
    if keys.pressed(KeyCode::ArrowLeft) || keys.pressed(KeyCode::KeyA) {
        direction.x -= 1.0;
    }
    if keys.pressed(KeyCode::ArrowRight) || keys.pressed(KeyCode::KeyD) {
        direction.x += 1.0;
    }
    
    if direction.length() > 0.0 {
        direction = direction.normalize();
    }
    
    transform.translation.x += direction.x * PLAYER_SPEED * time.delta_seconds();
    
    // Clamp to screen bounds
    transform.translation.x = transform.translation.x.clamp(-380.0, 380.0);
}

Step 3: Shooting Mechanics

fn player_shooting(
    mut commands: Commands,
    keys: Res<ButtonInput<KeyCode>>,
    player_query: Query<&Transform, With<Player>>,
    time: Res<Time>,
) {
    // Simple cooldown timer
    static LAST_SHOT: std::sync::Mutex<f64> = std::sync::Mutex::new(0.0);
    
    let current_time = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs_f64();
    
    let mut last_shot = LAST_SHOT.lock().unwrap();
    if current_time - *last_shot < 0.2 {
        return;
    }
    
    if keys.pressed(KeyCode::Space) {
        *last_shot = current_time;
        
        let player_transform = player_query.single();
        
        commands.spawn((
            SpriteBundle {
                transform: Transform::from_xyz(
                    player_transform.translation.x,
                    player_transform.translation.y + 20.0,
                    0.0,
                ),
                sprite: Sprite {
                    color: Color::rgb(1.0, 0.8, 0.2),
                    custom_size: Some(Vec2::new(8.0, 20.0)),
                    ..default()
                },
                ..default()
            },
            Bullet,
            FromPlayer,
            Velocity { x: 0.0, y: 1.0 },
        ));
    }
}

fn bullet_movement(
    mut commands: Commands,
    mut bullet_query: Query<(Entity, &Transform, &Velocity), With<Bullet>>,
    time: Res<Time>,
) {
    for (entity, transform, velocity) in bullet_query.iter_mut() {
        transform.translation.y += velocity.y * BULLET_SPEED * time.delta_seconds();
        
        // Despawn if off screen
        if transform.translation.y > 350.0 {
            commands.entity(entity).despawn();
        }
    }
}

Step 4: Enemy Spawning and Movement

use std::time::Duration;

#[derive(Resource)]
struct EnemySpawner {
    timer: Timer,
}

impl Default for EnemySpawner {
    fn default() -> Self {
        Self {
            timer: Timer::new(Duration::from_secs(1), TimerMode::Repeating),
        }
    }
}

fn enemy_spawn(
    mut commands: Commands,
    mut spawner: ResMut<EnemySpawner>,
    time: Res<Time>,
) {
    spawner.timer.tick(time.delta());
    
    if spawner.timer.just_finished() {
        let x = (rand::random::<f32>() - 0.5) * 700.0;
        
        commands.spawn((
            SpriteBundle {
                transform: Transform::from_xyz(x, 350.0, 0.0),
                sprite: Sprite {
                    color: Color::rgb(0.9, 0.3, 0.3),
                    custom_size: Some(Vec2::new(40.0, 40.0)),
                    ..default()
                },
                ..default()
            },
            Enemy,
            Velocity { x: 0.0, y: -1.0 },
        ));
    }
}

fn enemy_movement(
    mut commands: Commands,
    mut enemy_query: Query<(Entity, &mut Transform, &Velocity), With<Enemy>>,
    time: Res<Time>,
) {
    for (entity, mut transform, velocity) in enemy_query.iter_mut() {
        transform.translation.y += velocity.y * ENEMY_SPEED * time.delta_seconds();
        
        if transform.translation.y < -350.0 {
            commands.entity(entity).despawn();
        }
    }
}

Step 5: Collision Detection

fn bullet_enemy_collision(
    mut commands: Commands,
    mut game_state: ResMut<GameState>,
    bullet_query: Query<(Entity, &Transform), (With<Bullet>, With<FromPlayer>)>,
    enemy_query: Query<(Entity, &Transform), With<Enemy>>,
) {
    for (bullet_entity, bullet_transform) in bullet_query.iter() {
        for (enemy_entity, enemy_transform) in enemy_query.iter() {
            let distance = bullet_transform.translation.distance(enemy_transform.translation);
            
            if distance < 35.0 {
                commands.entity(bullet_entity).despawn();
                commands.entity(enemy_entity).despawn();
                game_state.score += 10;
                
                println!("Enemy destroyed! Score: {}", game_state.score);
            }
        }
    }
}

fn player_enemy_collision(
    mut game_state: ResMut<GameState>,
    player_query: Query<&Transform, With<Player>>,
    enemy_query: Query<&Transform, With<Enemy>>,
) {
    let player_transform = player_query.single();
    
    for enemy_transform in enemy_query.iter() {
        let distance = player_transform.translation.distance(enemy_transform.translation);
        
        if distance < 40.0 {
            game_state.game_over = true;
            println!("Game Over!");
        }
    }
}

Adding Sound Effects

Bevy supports audio through the bevy_kira_audio crate:

[dependencies]
bevy_kira_audio = "0.19"
use bevy_kira_audio::AudioPlugin;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(AudioPlugin)
        // ... rest of setup
}

fn play_shoot_sound(audio: Res<bevy_kira_audio::Audio>) {
    // Play sound effect
    audio.play("sounds/shoot.wav".to_owned());
}

Rendering and Graphics

Working with Sprites

fn load_sprites(mut commands: Commands, asset_server: Res<AssetServer>) {
    // Load sprite sheet
    let texture = asset_server.load("sprites/player.png");
    
    commands.spawn(SpriteBundle {
        texture,
        transform: Transform::from_xyz(0.0, 0.0, 0.0),
        ..default()
    });
}

Sprite Animation

#[derive(Component)]
struct AnimationTimer(Timer);

fn animate_sprite(
    mut query: Query<(&mut TextureAtlasSprite, &mut AnimationTimer)>,
    time: Res<Time>,
) {
    for (mut sprite, mut timer) in query.iter_mut() {
        timer.0.tick(time.delta());
        
        if timer.0.just_finished() {
            sprite.index = (sprite.index + 1) % 4;  // 4 frame animation
        }
    }
}

Physics with bevy_rapier

For physics, use the Rapier2D physics engine:

[dependencies]
bevy_rapier2d = "0.25"
use bevy_rapier2d::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(RapierPhysicsPlugin::<2d>::pixels_per_meter(100.0))
        .add_plugins(RapierDebugRenderPlugin::default())
        .run();
}

fn setup_physics(mut commands: Commands) {
    // Add rigid body
    commands
        .spawn(RigidBody::Dynamic)
        .insert(Collider::ball(25.0))
        .insert(Velocity { linvel: Vec2::new(100.0, 0.0), angvel: 0.0 });
    
    // Add static ground
    commands
        .spawn(RigidBody::Fixed)
        .insert(Collider::cuboid(400.0, 10.0))
        .insert(TransformBundle::from(Transform::from_xyz(0.0, -300.0, 0.0)));
}

Best Practices for Rust Game Development

1. Use ECS Properly

// โŒ Don't put game logic in components
struct Player {
    update_position: fn(&mut Self),  // Bad!
}

// โœ… Keep components as pure data
struct Player {
    speed: f32,
    health: f32,
}

// โœ… Put logic in systems
fn update_player(mut query: Query<(&mut Transform, &Player)>) {
    // Game logic here
}

2. Optimize for Iteration Speed

// In Cargo.toml
[profile.dev]
opt-level = 0      # No optimization in dev
debug = true       # Include debug symbols

3. Handle Window Events

fn handle_window_events(
    mut app_exit: EventWriter<AppExit>,
    mut window_query: Query<&mut Window>,
    mut events: EventReader<WindowEvent>,
) {
    for event in events.read() {
        match event {
            WindowEvent::CloseRequested => {
                app_exit.send(AppExit::Success);
            }
            WindowEvent::Resized(size) => {
                // Handle resize
            }
            _ => {}
        }
    }
}

4. Use State Machines for Game States

use bevy::prelude::*;

#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Hash, States)]
enum GameState {
    #[default]
    Menu,
    Playing,
    Paused,
    GameOver,
}

fn main() {
    App::new()
        .add_state::<GameState>()
        .add_systems(OnEnter(GameState::Menu), setup_menu)
        .add_systems(OnEnter(GameState::Playing), start_game)
        .add_systems(OnEnter(GameState::GameOver), show_game_over)
        .run();
}

Publishing Your Game

WebAssembly Build

# Install wasm target
rustup target add wasm32-unknown-unknown

# Build for web
cargo build --release --target wasm32-unknown-unknown

# Convert to JS using wasm-bindgen
wasm-bindgen --out-dir ./out/ --target web ./target/wasm32-unknown-unknown/release/my_first_game.wasm

Steam and Other Platforms

For commercial releases, consider:

  • Steam - Use steamworks crate
  • Itch.io - Host WebAssembly builds
  • Mobile - Use bevy_molten for mobile support

Performance Tips

Optimization Impact
Use FixedUpdate for physics Consistent physics at any framerate
Batch sprite rendering Reduce draw calls
Use Partials in queries Only fetch needed components
Enable LTO in release 10-20% performance gain

Conclusion

Rust game development is accessible and powerful. In this article, you learned:

  1. Game loop fundamentals - How Bevy processes frames
  2. ECS architecture - Separation of data and behavior
  3. Complete game example - A space shooter from scratch
  4. Collision detection - Simple AABB collision
  5. Best practices - Patterns for larger games

Next steps:

  • Add particle effects
  • Implement enemy AI behaviors
  • Add levels and scoring
  • Build for web and mobile

The Rust game ecosystem is growing rapidly. Explore engines like:

  • Bevy - Most popular, ECS-based
  • Macroquad - Simple, immediate mode
  • Fyrox - 3D engine with editor

External Resources


Comments