A few months ago, I started playing with Bevy, which has been significantly more enjoyable than most of my other attempts at making games. After some considerable hacking, I had a simple rocket ship game working, where you could fly around space and “pick up” randomly spawned satellites. Eventually, the idea for the game kind of grew and grew until I realized that I wanted a win condition and a lose condition.
This seemed simple enough until I realized: I had only built a game loop, but had no way to even display a simple menu! After some quick research, I realized that while I had something working, I wasn’t really using Bevy in an idiomatic way, and this was hampering my forward progress.
Part of why I had missed this was that the tutorials I found tended to focus on very specific parts of Bevy. To be fair, this makes sense – even the best tutorial can only cover so much at once, but I found I was missing how to write full games. (Ideally, someone would write a book laying out how to build a game from start to finish in Bevy, but considering it’s current stage of development, that’s quite a big ask.)
Once I realized I should probably re-architect this thing from the ground up, I decided to try to write down what I’m doing both to hopefully improve my memory and possibly to benefit someone else.
So, today, I’m going to figure how to put together a menu. I’m primarily going to be referring to the game_menu example in the Bevy repo.
For the sake of completeness, I’ll give a quick rundown of how I’m setting up the project.
Cargo.toml
[package]
name = "rocket_ship_game"
version = "0.1.0"
edition = "2021"
[dependencies]
bevy = "0.8.1"
early_returns = "0.4.0"
rand = "0.8.5"
.cargo/config.toml
# Enable only 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
[profile.release]
lto = "thin"
[target.wasm32-unknown-unknown]
runner = "wasm-server-runner"
I think most of this is pretty straight forward – we’ll come back to the WASM lines later.
First a quick note on file structure – I’m a big fan of many files. I’m not sure if this is because of my love of Vim or not, but I find it easier to jump between files than to jump up and down in the same file when looking back and forth between code. As such, I ended up with files/mods for the start-up splash screen, the menu, credits, and the game state, but it would be equally valid to roll them all up into a single file. Regardless, the first mod I want to talk about is game_state
.
Bevy provides a simple but power state machine called State
, which lets you easily both keep track of the current state of your game and move between states. For example, my GameState
enum looks like this:
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub enum GameState {
Splash,
Menu,
Credits,
Game,
Died,
Won,
Quit
}
Obviously, this is a work in progress, but already it helps me clarify what the flow of the application should look like. However, the real usefulness of using State
is driving and handling transitions between states. For example, in main
, when the App
is constructed, the state is set to Splash
, and then in the Plugin
impl for SplashPlugin
, the way to handle state changes Splash
are specified:
impl Plugin for SplashPlugin {
fn build(&self, app: &mut App) {
app.add_system_set(SystemSet::on_enter(GameState::Splash).with_system(splash_setup))
.add_system_set(SystemSet::on_update(GameState::Splash).with_system(countdown))
.add_system_set(SystemSet::on_exit(GameState::Splash).with_system(despawn_splash));
}
}
So, when the application enters the Splash
state, it calls splash_setup
, which loads/displays an image and starts a countdown timer. Then, when there’s a change to the timer, countdown
is invoked, which will set the GameState
state to Menu
when it’s done. Setting the state to Menu
has 2 effects: first, it invokes despawn_splash
via on_exit
and second, it invokes menu_setup
, which is the system specified for on_enter
for the Menu
game state.
The splash screen isn’t too fancy – it’s just an image and a timer – but the Menu
does provide something with a little more substance. Let’s take a quick look at the Plugin
impl for MenuPlugin
.
impl Plugin for MenuPlugin {
fn build(&self, app: &mut App) {
app
.add_state(MenuAction::Play)
.add_system_set(SystemSet::on_enter(GameState::Menu).with_system(menu_setup))
.add_system_set(SystemSet::on_enter(MenuAction::Play).with_system(indicate_play))
.add_system_set(SystemSet::on_enter(MenuAction::Credits).with_system(indicate_credits))
.add_system_set(SystemSet::on_enter(MenuAction::Quit).with_system(indicate_quit))
.add_system_set(SystemSet::on_exit(GameState::Menu).with_system(game_state::cleanup_component::<OnMainMenuScreen>))
.add_system(keyboard_update);
}
}
One important thing to note here is that we’re adding the MenuAction::Play
state – this is necessary to do here or there won’t be a resource with the MenuAction state available for systems to use – which will cause a panic and make your application shut down! Additionally, on_enter
is used to move the menu cursor to the correct placement. (So, when entering MenuState::Play
, indicate_play
is invoked, which moves the cursor to indicate that the Play
option is selected.)
In addition to setting the systems to use for entering/exiting the states above, we add the keyboard_update
system, which handles keyboard input and will move the cursor/change state as appropriate or change to the indicated GameState when the user presses Enter
.
In addition to the state changes noted above, the MenuPlugin
spawns TextBundles
to and a SpriteBundle
– but it uses a parent-child relationship to make it easier to clean up the components when exiting the state with cleanup_component
. This function is relatively simple, but very effective:
pub fn cleanup_component<T: Component>(entities_to_despawn: Query<Entity, With<T>>, mut commands: Commands) {
for entity in entities_to_despawn.iter() {
commands.entity(entity).despawn_recursive();
}
}
By using a parent-child relationship when spawning the TextBundle
s and SpriteBundle
on the menu screen, we can also despawn them all by despawning the parent entity recursively. Let’s take a quick look at how the child spawning is done (this is a little long for a code snippet, but I think it’s useful to see the whole thing):
fn menu_setup(
mut menu_action: ResMut<State<MenuAction>>,
mut commands: Commands,
asset_server: Res<AssetServer>,
) {
if let Err(err) = menu_action.set(MenuAction::Play) {
info!("{err:?}");
}
let font = asset_server.load("fonts/FiraSans-Bold.ttf");
let rocket = asset_server.load("menu_rocket.png");
commands
.spawn_bundle(SpatialBundle {
visibility: Visibility::visible(),
computed: Default::default(),
transform: Default::default(),
global_transform: Default::default()
})
.insert(OnMainMenuScreen)
.with_children(|parent| {
spawn_child_text_bundle("Play Game", 150., Play, parent, font.clone());
spawn_child_text_bundle("Credits", 0., Credits, parent, font.clone());
spawn_child_text_bundle("Quit", -150., Quit, parent, font.clone());
parent.spawn_bundle(SpriteBundle {
transform: Transform {
translation: Vec3::new(-200., 125., 0.),
..default()
},
texture: rocket.clone(),
..default()
}).insert(Cursor);
});
if let Err(err) = menu_action.set(MenuAction::Play) {
info!("{err:?}");
}
}
Basically, we spawn a SpatialBundle
, use insert
to add a component to the entity, and then use with_children
to spawn any arbitrary number of child bundles. There is one critical thing to note: Unless the parent and all children bundles are the same type, you probably want to use SpatialBundle
to ensure they get displayed as expected. For more information, see this section of the migration guide from 0.7 to 0.8, this section in the unofficial Bevy Cheat Book, and the official docs for SpatialBundle.
One of the really cool things that Rust and Bevy allow is building games with WASM targets, which allow games to be run basically anywhere. Unfortunately, I struggled a lot getting this working, so I wanted to add quick note about how I finally got the game to compile and run.
First, make sure you have the following line in your .cargo/config.toml
file:
[target.wasm32-unknown-unknown]
runner = "wasm-server-runner"
Next, create a directory for the WASM artifacts. You’ll need to copy your Bevy assets directory into this directory and also create an HTML file to load the game. My directory looks like:
wasm/
|- assets/
|- target/
|- index.html
And my index.html
file is just:
<html>
<script type="module">
import init from './target/rocket_ship_game.js'
init()
</script>
</html>
Finally, I have a Makefile
with three WASM targets:
prep-wasm:
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli
cargo install basic-http-server
build-wasm:
cargo build --release --target wasm32-unknown-unknown
wasm-bindgen --out-name rocket_ship_game \
--out-dir wasm/target \
--target web target/wasm32-unknown-unknown/release/rocket_ship_game.wasm
cp -r assets wasm/assets
run-wasm:
basic-http-server wasm
Now, I can run make prep-wasm
once to install dependencies. Then, to build the WASM target and start a simple server, I can use:
make build-wasm run-wasm
Which outputs:
preston@pop-os:~/Projects/my_games/rocket_ship_game$ make build-wasm run-wasm
cargo build --release --target wasm32-unknown-unknown
Compiling rocket_ship_game v0.1.0 (/home/preston/Projects/my_games/rocket_ship_game)
Finished release [optimized] target(s) in 39.55s
wasm-bindgen --out-name rocket_ship_game \
--out-dir wasm/target \
--target web target/wasm32-unknown-unknown/release/rocket_ship_game.wasm
cp -r assets wasm/assets
basic-http-server wasm
[INFO ] basic-http-server 0.8.1
[INFO ] addr: http://127.0.0.1:4000
[INFO ] root dir: wasm
[INFO ] extensions: false
And navigating to the URL, I find the “game” running in my browser.
To see the project that corresponds to this write-up, please see the project GitLab page, specifically the tag game_part_1 tag.
Thank you for reading! I’m hoping to have more updates as I work on re-implementing the game making better use of State
.
If you have any comments/questions/suggestions, the easiest way to contact me is to toot at me on Mastadon.
Back to home