Trying to Make a Simple Bevy Game

Background

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.

Project Config

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.

Let’s build a game!

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.

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.

Spawning and Despawning with Parents

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 TextBundles 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.

Building WASM

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.

Current Code

To see the project that corresponds to this write-up, please see the project GitLab page, specifically the tag game_part_1 tag.

Wrap Up

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