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
steamworkscrate - Itch.io - Host WebAssembly builds
- Mobile - Use
bevy_moltenfor 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:
- Game loop fundamentals - How Bevy processes frames
- ECS architecture - Separation of data and behavior
- Complete game example - A space shooter from scratch
- Collision detection - Simple AABB collision
- 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
Related Articles
- Rust for Systems Programming
- Advanced Async/Await Patterns in Rust
- Building CLI Tools with Rust
- Concurrency in Rust
Comments