If you've been in the Rust community for long enough, you must have heard of the beloved Bevy game engine.
Bevy is an open-source game engine written in the Rust programming language. It is fast, modular, and easy to use, and it can be used for developing 2D and 3D games.
Bevy uses a data-driven ECS (Entity Component System) architecture, which allows developers to easily manage and manipulate game objects and components. It has a strong focus on performance, thanks to Rust's emphasis on memory safety and low-level control, making it well-suited for building high-performance games that require efficient use of system resources.
In this article, I will show you how to get started with the Bevy game engine, with step-by-step guides and code snippets. It'll be fun, I promise!
To try out the several features of the Bevy game engine, you don't have to write any code. You can run the official examples on the Bevy website, using only a web browser. You can also clone the repository to run these examples locally on your computer:
These instructions assume you're using a Unix-like operating system (Linux, macOS, e.t.c)
git clone https://github.com/bevyengine/bevy
cd bevy
git checkout latest
cargo run --example breakout
You can check the list of available examples on the Bevy repository. Each one of these examples showcases the different features of Bevy.
My favorite example is load_gltf. You can run it with:
cargo run --example load_gltf
Now that you have tried the examples, you might want to make your own game from scratch. In this section, we will set up a simple 3D scene with a plane as the base, and a rotating cube on top of it.
If you're familiar with Rust, then this should be very easy for you, but even if you're not, you can still follow along.
Bevy is a game engine written in Rust. Therefore, you need to set up a Rust project to use it:
cargo new rusty_game
cd rusty_game
One of my favorite things about Bevy is that you simply use it as a dependency, just like any other Rust library.
The easiest way to add it as a dependency is to use cargo add:
cargo add bevy
This will automatically fetch the latest Bevy version and add it to your project's Cargo.toml file. You can also manually add it in case you want to specify the version:
[dependencies]
bevy = "0.10.1" # new
Now that you have Bevy as a dependency, you can access it within your Rust project.
To get started, you need to create a Bevy App. Edit your main.rs file and write the following:
use bevy::prelude::*;
fn main() {
App::new().run();
}
At this point, you can now run the game with:
cargo run
You should notice that nothing happens. That is expected as we have not added any logic to the game yet.
Bevy uses an ECS architecture, which defines how games should be made in a data-driven way. I will not be explaining the interesting details of ECS in this article, hopefully, I will do so in future articles but for the sake of this tutorial, take ECS as thus:
ECS stands for "Entity Component System". With ECS, you define your game objects as Entities, then you attach data to them in the form of Components, and this data can be manipulated using Systems.
Imagine that in your game, you have a player character. This player character is an Entity. Now your player should have a health bar, as in any game. This health property of your player is a component attached to the player. You can then modify or manipulate this component using a system. there is more to ECS than this, but that's all we need to know for now.
For you to have a game, you need a window to put your game, obviously, but the Bevy App we currently have does not create a window.
Bevy uses a plugin system to modularize the game engine such that you only use the features you need. However, there are certain essential features a normal game should have, such as a window. Therefore, Bevy provides a bundle of default plugins that you can apply to your app. Modify your main.rs to add them:
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.run();
}
Now when you run your app again with cargo run
you should see a window.
Well, you put nothing in it. Before we spawn entities, let's talk about systems.
A system is a function.
That's it. That's all it is. A function.
If you recall, I described systems as things we used to modify the components attached to Entities. Well yeah, but you really can do anything with systems. They're just functions.
In Bevy, there are two kinds of systems. Startup systems, and normal systems (I just made that up). A startup system is a system that runs only once when the game starts. Normal systems on the other hand will run every frame (Like 60 times every second).
To illustrate this, let us make a startup system that prints "Hello world".
use bevy::prelude::*;
fn main() {
App::new()
// .add_plugins(DefaultPlugins) // commented intentionally
.add_startup_system(hello_world_system)
.run();
}
fn hello_world_system() {
println!("Hello World!");
}
When you run the above code, you should see "Hello world" printed in the terminal. The hello_world
function is a system, and we registered it as a startup system by using .add_startup_system(hello_world_system)
. I have intentionally commented out the default plugins to avoid launching a window for now.
What if you want to register the hello_world
function as a normal system that runs every frame? You simply change .add_startup_system(hello_world_system)
to .add_system(hello_world_system)
:
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins) // uncommented
.add_system(hello_world_system)
.run();
}
fn hello_world_system() {
println!("Hello World!");
}
Notice the add_plugins
line has been restored. After running it with cargo run
, you should notice that there is a bunch of "Hello World" printed in the terminal. This is because a normal system will run 60 times every second. Why 60 times? Because normal systems run every frame of the game, and the default frame rate for most games is 60 FPS (Frames per second).
Alright! I know you're eager to have something visual show up on your screen. Now that we have a window, we can finally add some entities that would hopefully show up on the screen :)
To do this, we will use a startup system that spawns 4 entities. A Cube, a Plane, a Light, and a Camera. We need a Light to illuminate the scene, we need a Camera because we see through cameras, and of course, the Cube and Plain are the main models in this scene.
For a start, we add a setup function which is just a startup system that spawns these entities when the game launches:
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_startup_system(setup)
.run();
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
// entities will be spawned here
}
Next, we spawn four entities. Don't worry about the code, I'll explain it later :)
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_startup_system(setup)
.run();
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
// plane
commands.spawn(PbrBundle {
mesh: meshes.add(shape::Plane::from_size(5.0).into()),
material: materials.add(Color::rgb(0.3, 0.5, 0.3).into()),
..default()
});
// cube
commands.spawn(PbrBundle {
mesh: meshes.add(Mesh::from(shape::Cube { size: 1.0 })),
material: materials.add(Color::rgb(0.8, 0.7, 0.6).into()),
transform: Transform::from_xyz(0.0, 0.7, 0.0),
..default()
});
// light
commands.spawn(PointLightBundle {
point_light: PointLight {
intensity: 1500.0,
shadows_enabled: true,
..default()
},
transform: Transform::from_xyz(4.0, 8.0, 4.0),
..default()
});
// camera
commands.spawn(Camera3dBundle {
transform: Transform::from_xyz(-2.0, 2.5, 5.0).looking_at(Vec3::ZERO, Vec3::Y),
..default()
});
}
When you run the code, you should see the cube neatly placed on top of the plane like below:
Well done! You have made it to the end...
Yeah right! how did we end up here?
// plane
commands.spawn(PbrBundle {
mesh: meshes.add(shape::Plane::from_size(5.0).into()),
material: materials.add(Color::rgb(0.3, 0.5, 0.3).into()),
..default()
});
In Bevy, to spawn entities we use the spawn
function of commands
which was passed into the setup
system. The spawn function then receives an entity to be spawned, in this case, the PbrBundle
. The PbrBundle basically creates a "renderable" entity, in which you have to specify the mesh, material, and other properties. In this case, we use the ..default()
syntax to fill in all the other properties with their default values.
The Cube, Light, and Camera are quite similar to the Plane. I wish I could go into more detail about how they all work, but the official bevy docs will do a better job.
Woah! We have come a long way! Wasn't that fun?
Now we will do something even more fun --> ROTATION. We can achieve this in many ways, but since we are using an ECS game engine, the best way is to create a rotate
component, add it to the Cube entity, then create a rotation
system that rotates all entities that have a rotate
component.
First, the rotate component:
#[derive(Component)]
struct Rotate;
Using the derive
macro, we create a component that can be attached to entities.
Then create a rotation system:
fn rotation(mut query: Query<&mut Transform, With<Rotate>>, time: Res<Time>) {
for mut transform in &mut query {
transform.rotate_y(time.delta_seconds() / 1.0);
}
}
In the parameters of the above rotation
system, you see With<Rotate>>
. This acts as a filter that says "This system only applies to entities that have a Rotate
component".
We then loop through all the entities and use transform.rotate_y(time.delta_seconds() / 1.0)
to rotate them.
Now register the rotation system:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_startup_system(setup)
.add_system(rotation) // new
.run();
}
Finally, while spawning the cube, we specify that it has a Rotate
component. The change in this code may look a bit tricky. Previously, we passed only a PbrBundle to the spawn
function. Now, we pass a tuple that holds PbrBundle and Rotate like so commands.spawn((PbrBundle {...}, Rotate ))
:
// cube
commands.spawn((
PbrBundle {
mesh: meshes.add(Mesh::from(shape::Cube { size: 1.0 })),
material: materials.add(Color::rgb(0.8, 0.7, 0.6).into()),
transform: Transform::from_xyz(0.0, 0.7, 0.0),
..default()
},
Rotate
));
Putting it all together, the code should look like this:
use bevy::prelude::*;
#[derive(Component)]
struct Rotate;
fn rotation(mut query: Query<&mut Transform, With<Rotate>>, time: Res<Time>) {
for mut transform in &mut query {
transform.rotate_y(time.delta_seconds() / 1.0);
}
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_startup_system(setup)
.add_system(rotation)
.run();
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
// plane
commands.spawn(PbrBundle {
mesh: meshes.add(shape::Plane::from_size(5.0).into()),
material: materials.add(Color::rgb(0.3, 0.5, 0.3).into()),
..default()
});
// cube
commands.spawn((
PbrBundle {
mesh: meshes.add(Mesh::from(shape::Cube { size: 1.0 })),
material: materials.add(Color::rgb(0.8, 0.7, 0.6).into()),
transform: Transform::from_xyz(0.0, 0.7, 0.0),
..default()
},
Rotate,
));
// light
commands.spawn(PointLightBundle {
point_light: PointLight {
intensity: 1500.0,
shadows_enabled: true,
..default()
},
transform: Transform::from_xyz(4.0, 8.0, 4.0),
..default()
});
// camera
commands.spawn(Camera3dBundle {
transform: Transform::from_xyz(-2.0, 2.5, 5.0).looking_at(Vec3::ZERO, Vec3::Y),
..default()
});
}
Run it with:
cargo run
If everything works fine, your cube should be rotating:
Rotating one cube is fun but what about 400 cubes?
The power of ECS is not obvious when working with a few entities. When you have hundreds, thousands, or even millions of entities, and you wish to perform some action on all these entities simultaneously, then you see the true power of the ECS.
In this final section, we will go extreme! and spawn 400 cubes, then we will spin them simultaneously just as we did the single cube earlier.
Now fasten your seatbelts because this might be a bumpy ride!
First, we need a flying camera, because what's the fun of spawning a lot of cubes if you can't inspect them freely?
We could make a flying camera from scratch, but there's a library called bevy_flycam
that can do this for us. Add the following dependency to your Cargo.toml:
[package]
name = "rusty_game"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
bevy = "0.10.1"
bevy_flycam = { git = "https://github.com/sburris0/bevy_flycam" } # new
Then update main.rs to replace the camera we previously set up with the flying camera. To do this, you import the bevy_flycam's PlayerPlugin:
use bevy_flycam::PlayerPlugin;
Remove the camera we set up previously:
// remove this entire block of code
// camera
commands.spawn(Camera3dBundle { // remove me
transform: Transform::from_xyz(-2.0, 2.5, 5.0).looking_at(Vec3::ZERO, Vec3::Y), // remove me
..default() // remove me
}); // remove me
Finally, register the PlayerPlugin from bevy_flycam:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugin(PlayerPlugin) // new
.add_startup_system(setup)
.add_system(rotation)
.run();
}
We can now run the code and control the camera using the WASD keys on the keyboard. Note that you can change the direction of the camera using your mouse.
The next thing we have to do is make the plane larger. If we will be spawning 400 cubes, then we need a plane that can contain them. Update the size of the plain from 5.0 to 50.0 :
// plane
commands.spawn(PbrBundle {
mesh: meshes.add(shape::Plane::from_size(50.0).into()),
material: materials.add(Color::rgb(0.3, 0.5, 0.3).into()),
..default()
});
You can run the code to ensure it is working as expected. You should now have a larger plane, and be able to move the camera around it.
Now the fun part; spawning 400 cubes. For the sake of this tutorial, I have written a utility function that generates the transforms for 400 cubes. I wonβt go into the details of how it works, but you should be able to figure it out:
struct Cube {
x: f32,
z: f32,
}
fn create_cubes() -> Vec<Cube> {
let mut cubes: Vec<Cube> = Vec::new();
let mut z = 0.0;
for _ in 0..10 {
let mut x = 0.0;
for _ in 0..10 {
let cube = Cube { x, z };
cubes.push(cube);
x = x + 2.0;
}
x = -2.0;
for _ in 0..10 {
let cube = Cube { x, z };
cubes.push(cube);
x = x - 2.0;
}
z = z + 2.0;
}
let mut z = -2.0;
for _ in 0..10 {
let mut x = 0.0;
for _ in 0..10 {
let cube = Cube { x, z };
cubes.push(cube);
x = x + 2.0;
}
x = -2.0;
for _ in 0..10 {
let cube = Cube { x, z };
cubes.push(cube);
x = x - 2.0;
}
z = z - 2.0;
}
cubes
}
The above function simply returns an array of cubes which we can then spawn inside the setup function:
for cube in cubes {
commands.spawn((
PbrBundle {
mesh: meshes.add(Mesh::from(shape::Cube { size: 1.0 })),
material: materials.add(Color::rgb(0.8, 0.7, 0.6).into()),
transform: Transform::from_xyz(cube.x, 0.7, cube.z),
..default()
},
Rotate,
));
}
As I explained earlier, all we have to do is apply the Rotate
component to the cubes and they will all rotate because of the rotation system.
The final code should look like this:
use bevy::prelude::*;
use bevy_flycam::PlayerPlugin;
#[derive(Component)]
struct Rotate;
fn rotation(mut query: Query<&mut Transform, With<Rotate>>, time: Res<Time>) {
for mut transform in &mut query {
transform.rotate_y(time.delta_seconds() / 1.0);
}
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugin(PlayerPlugin)
.add_startup_system(setup)
.add_system(rotation)
.run();
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
// plane
commands.spawn(PbrBundle {
mesh: meshes.add(shape::Plane::from_size(50.0).into()),
material: materials.add(Color::rgb(0.3, 0.5, 0.3).into()),
..default()
});
let cubes = create_cubes();
for cube in cubes {
commands.spawn((
PbrBundle {
mesh: meshes.add(Mesh::from(shape::Cube { size: 1.0 })),
material: materials.add(Color::rgb(0.8, 0.7, 0.6).into()),
transform: Transform::from_xyz(cube.x, 0.7, cube.z),
..default()
},
Rotate,
));
}
// light
commands.spawn(PointLightBundle {
point_light: PointLight {
intensity: 1500.0,
shadows_enabled: true,
..default()
},
transform: Transform::from_xyz(4.0, 8.0, 4.0),
..default()
});
}
struct Cube {
x: f32,
z: f32,
}
fn create_cubes() -> Vec<Cube> {
let mut cubes: Vec<Cube> = Vec::new();
let mut z = 0.0;
for _ in 0..10 {
let mut x = 0.0;
for _ in 0..10 {
let cube = Cube { x, z };
cubes.push(cube);
x = x + 2.0;
}
x = -2.0;
for _ in 0..10 {
let cube = Cube { x, z };
cubes.push(cube);
x = x - 2.0;
}
z = z + 2.0;
}
let mut z = -2.0;
for _ in 0..10 {
let mut x = 0.0;
for _ in 0..10 {
let cube = Cube { x, z };
cubes.push(cube);
x = x + 2.0;
}
x = -2.0;
for _ in 0..10 {
let cube = Cube { x, z };
cubes.push(cube);
x = x - 2.0;
}
z = z - 2.0;
}
cubes
}
Now you can run the game and see for yourself, 400 cubes spinning forever!
I'm just kidding, I have a potato PC :)
Thank you for reading! This article is part of a series of articles called "Coding Adventure".
Should i start a "Building with Rust" series where I show you cool projects I'm building with Rust? π¦#rustlang #programming
β 0xHiro / γγ (@hiro_codes) April 8, 2023
Woah!! Much interest! π
— 0xHiro / γγ (@hiro_codes) April 9, 2023
To start my "Building with Rust" series π¦,
I am currently writing a detailed article on why you should learn Rust as a software developer.
It'll be published on my website later today π¦Ύ
Stay tuned π#rustlang #programming https://t.co/oc32apJ5sC pic.twitter.com/MUzm8S5od9
I have decided to rename my "Building with Rust" series to "Coding Adventure" for obvious reasons.
β 0xHiro / γγ (@hiro_codes) April 13, 2023
What programming topic would you like me to cover more frequently?#rustlang #programming #codingadventure
If you liked it, give me a follow on Twitter to get updated on my new articles, and join my Discord server if you would like to see cool open-source projects i am building with an awesome community of software enthusiasts. Do let me know if you would like to see more tutorials such as this. Your feedback is highly appreciated.
Please consider donating, or becoming a sponsor to help me transition into making open-source software, full-time.