This guide will walk you through the creation of your first game using the Bevy Game Engine, a highly modular data-driven game engine written in the Rust Programming Language. If you're new to Rust, don't worry! Along with an introduction to Bevy, this guide offers a gentle primer on the language through practical examples. However, for a deeper dive into Rust itself, consider supplementing this guide with the following resources.
For help with Bevy, you may also want to reference https://bevyengine.org/learn/ and The Unofficial Bevy Cheat Book.
Before we dive into the details of Bevy, we need to cover some basic Rust features.
Rust has a variety of primitive types, compound types, and built-in data types. A non-exhaustive list is included below.
Type | Description | Example Usage |
---|---|---|
u32 |
An unsigned 32 bit integer | let x: u32 = 1; |
i32 |
A signed 32 bit integer | let x: i32 = -1; |
f32 |
A 32 bit floating point number | let x: f32 = 1.2; |
bool |
Boolean (true or false) | let x = true; (Rust has type inference, so you don't always have to specify the type) |
char |
A character | let x = 'a'; |
Vec<T> |
A dynamically sized array of items of type T |
let x = vec![1,2,3]; |
String |
A dynamically sized string | let x = "asdf".to_owned(); (more about .to_owned() later) |
In order to create more complex types, Rust offers two data abstractions: structs
and enums
.
Structs in Rust are like structs in C (and somewhat like objects in Java). They are made up of fields, each of which has its own data type. For example, a struct representing a student could be defined like this:
struct Student {
name: String,
gpa: f32,
grad_year: u32
}
Structs can also use unnamed fields:
struct Coordinate(i32, i32);
If x
is a Coordinate
, then the field values would be accessed using x.0
and x.1
.
Enums are like enums in Java or union types in C. They represent data that can be any one of a list of varients. For example, the days of the week can be represented like this:
enum Weekday {
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday
}
If a variable has the type Weekday
, then it must be one of the seven varients listed above.
Enum varients can also hold data (this can be specified using a syntax similar to structs). The enum below is an example of this.
enum ComputerPart {
Mouse { bluetooth: bool },
Keyboard {
bluetooth: bool,
layout: String,
},
Monitor(String),
}
Rust is a procedural langauge, not an objest-oriented language. This means that functions don't have to be associated with a data type. For example, the following is valid code.
fn my_function(a: ParamTypeA, b: ParamTypeB) -> ReturnType {
let c = a + b;
return c;
}
If you want to associate a function with a type, you can do so using an implementation (impl
) block.
impl Student {
// this is used in place of a constructor function, but it's technically just
// a normal static function (like in Java). If you've taken CIS 434, you may recognize this
// as the Factory pattern.
fn new(name: String) -> Self { // the Self type just refers to Student
return Self {
name, // this is an abbreviation of `name: name,`
gpa: 3.8,
grad_year: 2025
}
}
// This is a function that can access the data inside Student. It's like
// a normal object method in Java. We'll talk about what `&` means in a second
fn get_name(&self) -> &str {
return &self.name;
}
}
To use these functions, you would use Student::new(...)
for static-like functions and (for some variable x
of the type Student
) x.get_name()
for the object-method-like functions.
Rust is famous for how it handles memory management. Instead of using a garbage collector like most modern languages, it uses a strict set of rules that allow it to clear the memory directly after the owned value goes out of scope. The rules are as follows.
&mut x
) or immutable (ex: &x
). You can have multiple immutable references to a variable at the same time, but you can only have one mutable reference (with no immutable references) at a time.Let's look at an example to illustrate this last rule. The following code produces a compilation error.
fn my_function() {
let x = Student::new("Bob".to_owned());
let alpha = &x; // this creates a immutable reference to x
// this passes the ownership of x to my_other_function
my_other_function(x);
// alpha is still "alive" here, which causes a compile error
}
To fix this, we can either remove alpha
or pass a reference my_other_function
. This reference can be mutable as long as alpha
is created after my_other_function
is called. For example, the following code should compile without errors.
fn my_function() {
let x = Student::new("Bob".to_owned());
// this passes the ownership of the reference &mut x to my_other_function
my_other_function(&mut x);
// &mut x goes out of scope after my_other_function finishes, and is therefore destroyed
// now we can create another reference to x
let alpha = &x;
}
Traits in Rust are like interfaces in Java. They're a collection of functions that can be implemented for any type. Below is a simple example.
trait MyTrait {
fn my_function(a: String) -> String;
}
To implement MyTrait
for Student
, we use the following impl
block.
impl MyTrait for Student {
fn my_function(a: String) -> String {
return a;
}
}
Please note that, to use the functions provided by a trait, the trait must be in scope.
Traits provided by crates (Rust's name for external packages; the bevy
crate is an example) or by Rust's standard library can often be implemented using Rust's macro system, which automatically generates code for you. For example, the Clone
trait, which helps users duplicate owned values or create owned values from references, can be implemented using the derive syntax instead of an impl
block.
#[derive(Clone)]
struct Student {
name: String,
gpa: f32,
grad_year: u32
}
Then the clone()
function, which is provided by the Clone
trait, can be used.
The use
statement can be used to import types, functions, and traits from external crates.
use std::collections::HashMap;
use bevy::prelude::*;
Many crates have a prelude
, which contains a collection of commonly used types, functions, and traits, that can all be imported at once.
The standard library (std
) also has a prelude, but because it's used so often it's automatically included in every rust program. No use
statement required. The standard library prelude includes built-in types like String
and commonly used traits like Clone
.
Now that we've covered the basics of the Rust programming language, we can continue on to the Bevy game engine.
Unlike most game engines, which are object-oriented, Bevy uses the Entity Component System (ECS) paradigm. The ECS paradigm contains three constructs:
Entities are the "things" in the game. The player is represented by an entity; each enemy, projectile, and UI element is an entity. Within Bevy, each entity is represented by an ID with the data type Entity
.
Components are the data structures that are composed together to create entities. For example,
#[derive(Component)]
struct HealthPoints {
health: u32
}
and (yes, structs can be empty in Rust)
#[derive(Component)]
struct Player;
could be combined to create an entity with health points and a Player tag. The entity would look something like the following.
(
Player,
HealthPoints { health: 10 }
)
This is fundamentally different from object-oriented systems. Instead of creating a base class that is derived to create subclasses, small pieces of data represented by components are combined to create entities.
Finally, Systems are the functions that create, query, and mutate those entities. In Bevy, systems query the game engine for entities by specifying special function parameters. For example, the following system queries Bevy for all of entities with the HealthPoints
component.
fn print_health_points(query: Query<&HealthPoints>) {
for hp in &query {
println!("You have {} health points.", hp.health);
}
}
Note that this system is asking for an immutable reference to HealthPoints
. To mutate entities, simply ask for a mutable reference.
To create or destroy entities, a system can ask the game engine for the Commands
data structure.
fn setup(mut commands: Commands) {
commands.spawn((
Player,
HealthPoints { health: 10 },
));
}
Development in Bevy is mostly text-based (unlike Unity, for example, which has a GUI editor). This means that development happens in the text-editor of your choice.
If you have not already, the first step is to download and install rust. This can be done via the rustup script.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Download rustup-init.exe.
Then, create the rust project.
cargo new bevy_workshop
cd bevy_workshop
Following that, add the required crates.
cargo add bevy
Finally, add the following settings to Cargo.toml
in order to optimize bevy during development.
# Enable a small amount of optimization in debug mode
[profile.dev]
opt-level = 1
# Enable high optimizations for dependencies (incl. Bevy), but not for our code:
[profile.dev.package."*"]
opt-level = 3
Replace the contents of src/main.rs
with the following.
use bevy::prelude::*;
fn main() {
App::new().run();
}
To run the project, use
cargo run
Compiling may take a while. Once compiling finishes, you should see Running 'target/debug/bevy_workshop\'
(and nothing afterwards).
alsa
is missing, runsudo apt install libasound2-dev
libudev
is missing, runsudo apt install libudev-dev
Let's get our simple example from the previous section working. Replace the contents of src/main.rs
with the following.
use bevy::prelude::*;
#[derive(Component)]
struct HealthPoints {
health: u32
}
#[derive(Component)]
struct Player;
fn print_health_points(query: Query<&HealthPoints>) {
for hp in &query {
println!("You have {} health points.", hp.health);
}
}
fn setup(mut commands: Commands) {
commands.spawn((
Player,
HealthPoints { health: 10 },
));
}
fn main() {
App::new()
.add_systems(Startup, setup)
.add_systems(Update, print_health_points)
.run();
}
For the remainder of this guide, we will focus on the creation of your first game using the Bevy game engine. If you'd like to skip ahead, you can view the entire game here: https://github.com/CSU-CPT/bevy_workshop. But I'd encourage you to follow through the steps of this guide so you can gain a better understanding of the code and a better appreciation for the development process.
To get the assets, download this zip file and unzip in your project directory. Afterwards, the directory should look like this:
bevy_workshop/
assets/
asteroid.png
bullet.png
ship.png
spaceship.png
src/
main.rs
...
In src/main.rs
, we will start with the following boilerplate code:
use bevy::prelude::*;
fn main() {
App::new().run();
}
Next, to add the GUI window for our game, we need to add the DefaultPlugins
plugin group to our app. We'll also include the ImagePlugin
with the default nearest setting so that our pixelated assets look correct.
fn main() {
App::new()
.add_plugins(
DefaultPlugins.set(ImagePlugin::default_nearest()
)
.run();
}
To change the background color of our window, we'll add the ClearColor
resource to our app. In Bevy, resources are single-use, global data structures managed by the game engine. We'll use these again later.
fn main() {
App::new()
.add_plugins(
DefaultPlugins.set(ImagePlugin::default_nearest()
)
.insert_resource(ClearColor(Color::rgb(0.0, 0.0, 0.0)))
.run();
}
Next, let's add a system called setup
so we can start creating entities. Then, inside the setup
system, let's create a camera entity using the Camera2dBundle
. In Bevy, bundles are collections of components that can be reused to create entities.
fn main() {
App::new()
...
.add_systems(Startup, setup)
.run();
}
fn setup(mut commands: Commands) {
commands.spawn(Camera2dBundle::default());
}
Go ahead and run the app again using the cargo run
command to see what you've created.
First, let's create the Player
component.
#[derive(Component)]
struct Player;
Then add the following to our setup
system. Make sure that your update the parameters of this system so that it asks Bevy for the AssetServer
resources.
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
...
// Spawn the spaceship
commands.spawn((
Player,
SpriteBundle {
texture: asset_server.load("spaceship.png"),
transform: Transform::from_translation(Vec3::new(0.0, 0.0, 0.0))
.with_scale(Vec3::splat(2.)),
..default()
}
));
}
Again, I'd encourage you to run cargo run
. Keep doing this throughout the project so you can see your progress.
Next, let's add the logic for player movement. We'll start by creating a component that keeps track of both direction and speed. This can normally be done using a single vector, but it's helpful to keep track of the speed separately (you'll see why in a moment).
#[derive(Component)]
struct SpriteMovement {
direction: Vec3,
speed: f32,
}
Then let's add the SpriteMovement
component to our spaceship.
// Spawn the spaceship
commands.spawn((
...,
SpriteMovement {
direction: Vec3::splat(0.0),
speed: 100.0,
},
));
Following that, let's add the system that actually moves the sprite. We'll use the Time
resource to keep the speed constant regardless of the frame rate and the normalize_or_zero()
function to keep the speed constant in general.
fn sprite_movement(time: Res<Time>, mut sprite_position: Query<(&SpriteMovement, &mut Transform)>) {
for (movement, mut transform) in &mut sprite_position {
transform.translation +=
movement.direction.normalize_or_zero() * movement.speed * time.delta_seconds();
}
}
Make sure to register this system with the app in the main
function. Use the Update
parameter instead of the Startup
parameter so that the system runs every frame.
fn main() {
App::new()
,,,
.add_systems(
Update,
(
sprite_movement,
)
)
.run();
}
The next step is to update the player's SpriteMovement component based on what keys are pressed. This can be done using another system. We'll use the With<Player>
query type parameter to make sure we only affect the player entity.
fn ship_movement_input(
keyboard_input: Res<Input<KeyCode>>,
mut player: Query<&mut SpriteMovement, With<Player>>,
) {
let mut sprite_movement = player.single_mut();
if keyboard_input.just_pressed(KeyCode::A) || keyboard_input.just_pressed(KeyCode::Left) {
sprite_movement.direction.x = -1.0;
} else if (keyboard_input.just_released(KeyCode::A)
|| keyboard_input.just_released(KeyCode::Left))
&& sprite_movement.direction.x < 0.0
{
sprite_movement.direction.x = 0.0;
}
if keyboard_input.just_pressed(KeyCode::D) || keyboard_input.just_pressed(KeyCode::Right) {
sprite_movement.direction.x = 1.0;
} else if (keyboard_input.just_released(KeyCode::D)
|| keyboard_input.just_released(KeyCode::Right))
&& sprite_movement.direction.x > 0.0
{
sprite_movement.direction.x = 0.0;
}
if keyboard_input.just_pressed(KeyCode::W) || keyboard_input.just_pressed(KeyCode::Up) {
sprite_movement.direction.y = 1.0;
} else if (keyboard_input.just_released(KeyCode::W)
|| keyboard_input.just_released(KeyCode::Up))
&& sprite_movement.direction.y > 0.0
{
sprite_movement.direction.y = 0.0;
}
if keyboard_input.just_pressed(KeyCode::S) || keyboard_input.just_pressed(KeyCode::Down) {
sprite_movement.direction.y = -1.0;
} else if (keyboard_input.just_released(KeyCode::S)
|| keyboard_input.just_released(KeyCode::Down))
&& sprite_movement.direction.y < 0.0
{
sprite_movement.direction.y = 0.0;
}
}
Again, make sure to register this system with the app.
fn main() {
App::new()
,,,
.add_systems(
Update,
(
sprite_movement,
ship_movement_input,
)
)
.run();
}
Now run cargo run
again to test the player's movement.
You may notice that the spaceship can move outside of the screen. Let's fix that.
fn confine_player_to_screen(
mut player: Query<(&mut Transform, &mut SpriteMovement), With<Player>>,
window: Query<&Window>,
) {
let window = window.single();
let half_width = window.resolution.width() / 2.0;
let half_height = window.resolution.height() / 2.0;
let (mut transform, mut movement) = player.single_mut();
if transform.translation.x < -half_width && movement.direction.x < 0.0 {
movement.direction.x = 0.0;
transform.translation.x = -half_width;
} else if transform.translation.x > half_width && movement.direction.x > 0.0 {
movement.direction.x = 0.0;
transform.translation.x = half_width;
}
if transform.translation.y < -half_height && movement.direction.y < 0.0 {
movement.direction.y = 0.0;
transform.translation.y = -half_height;
} else if transform.translation.y > half_height && movement.direction.y > 0.0 {
movement.direction.y = 0.0;
transform.translation.y = half_height;
}
}
Again, register the system with the app. From now on, all systems should be registered using the Update
parameter.
Let's make our spaceship fire bullets when the spacebar is pressed. We'll start by adding a bullet component.
#[derive(Component)]
struct Bullet;
We'll also want to add a cooldown timer mechanism so that you can't fire every frame. To do this, we'll create a cooldown timer component and then add it to the spaceship entity.
The component itself will be built around the Timer
datatype provided by Bevy. We'll also derive the traits Deref
and DerefMut
so that we can access Timer
's methods without using timer.0
all of the time.
#[derive(Component, Deref, DerefMut)]
struct CooldownTimer(Timer);
Don't forget to add CooldownTimer
to the spaceship entity.
fn setup(...) {
// Spawn the spaceship
commands.spawn((
...,
CooldownTimer(Timer::from_seconds(0.2, TimerMode::Once)),
));
}
Finally, let's add the bullet firing system.
fn bullet_firing(
mut cmd: Commands,
mut player: Query<(&Transform, &mut CooldownTimer), With<Player>>,
time: Res<Time>,
keyboard_input: Res<Input<KeyCode>>,
asset_server: Res<AssetServer>,
) {
let (translation, mut timer) = player.single_mut();
timer.tick(time.delta());
let position = translation.translation + Vec3::new(0.0, 30.0, 0.0);
if keyboard_input.pressed(KeyCode::Space) && timer.finished() {
cmd.spawn((
Bullet,
SpriteBundle {
texture: asset_server.load("bullet.png"),
transform: Transform::from_translation(position),
..default()
},
SpriteMovement {
direction: Vec3::Y,
speed: 200.0,
},
));
timer.reset();
}
}
Don't forget to register the system with the app.
Next, let's make some asteroids that spawn just above the top of the screen. We'll start with the asteroid component.
#[derive(Component)]
struct Asteroid;
We'll also add a Timer
to control when the asteroids are spawned. But since this timer won't be associated with any one entity, we'll add it as a resource.
#[derive(Resource, Deref, DerefMut)]
struct AsteroidSpawnTimer(Timer);
Make sure to actually add the resource to the app.
fn main() {
App::new()
,,,
.insert_resource(AsteroidSpawnTimer(Timer::from_seconds(
1.0,
TimerMode::Once,
)))
.add_systems(...)
.run();
}
Now we can add the system the spawns the asteroids.
fn spawn_asteroids(
mut cmd: Commands,
window: Query<&Window>,
time: Res<Time>,
mut timer: ResMut<AsteroidSpawnTimer>,
asset_server: Res<AssetServer>,
) {
timer.tick(time.delta());
if timer.finished() {
let mut rng = rand::thread_rng();
let window = window.single();
let half_width = window.resolution.width() / 2.0;
let half_height = window.resolution.height() / 2.0;
// Spawn an asteroid
cmd.spawn((
Asteroid,
SpriteBundle {
texture: asset_server.load("asteroid.png"),
transform: Transform::from_translation(Vec3::new(
rng.gen_range(-half_width..half_width),
half_height + 100.0, // Add buffer so that objects don't appear on screen from thin air
0.0,
))
.with_scale(Vec3::splat(2.0)),
..default()
},
SpriteMovement {
direction: Vec3::new(0.0, -1.0, 0.0),
speed: 30.0,
},
));
timer.set_duration(Duration::from_millis(rng.gen_range(500..3000)));
timer.reset();
}
}
To make this game a little more interesting, let's make the bullets and the asteroids interact. First, create a collider component.
#[derive(Component)]
struct BallCollider(f32);
Next, add the component to all of the asteroids...
fn spawn_asteroids(...) {
...
cmd.spawn((
...
BallCollider(24.0),
));
}
...and all of the bullets.
fn bullet_firing(...) {
...
cmd.spawn((
...
BallCollider(2.0),
))
}
Finally, we'll add the system that despawns both the bullet and the asteroid when they hit each other.
fn asteroid_bullet_collision(
mut commands: Commands,
bullets: Query<(Entity, &Transform, &BallCollider), With<Bullet>>,
asteroids: Query<(Entity, &Transform, &BallCollider), With<Asteroid>>,
) {
for (bullet_entity, bullet_transform, bullet_collider) in &mut bullets.iter() {
for (asteroid_entity, asteroid_transform, asteroid_collider) in &mut asteroids.iter() {
if bullet_transform
.translation
.distance(asteroid_transform.translation)
< bullet_collider.0 + asteroid_collider.0
{
commands.entity(bullet_entity).despawn();
commands.entity(asteroid_entity).despawn();
}
}
}
}
To help our game run faster, let's clean up the entities that leave the screen. This can be done with a single system.
fn despawn_entities_outside_of_screen(
mut cmd: Commands,
window: Query<&Window>,
query: Query<(Entity, &Transform), Or<(With<Asteroid>, With<Bullet>)>>,
) {
let window = window.single();
// Add buffer so that objects aren't despawned until they are completely off the screen
let half_height = window.resolution.height() / 2.0 + 100.0;
for (entity, transform) in &mut query.iter() {
if transform.translation.y < -half_height || transform.translation.y > half_height {
cmd.entity(entity).despawn();
}
}
}
There's still a lot of improvements that could be made to this game. For example,
These are just a few ideas. I'd encourage you to keep playing around with this if you want to get even more comfortable with the Bevy game engine.
Thanks for reading to the end of this long tutorial, and have fun!