Maciej Główka
Blog Games Contact

← Return to Blog Index

bevy_tut_part_01_code.png
March 18, 2023

Bevy roguelike tutorial / devlog part 1

rust bevy gamedev

Note: this tutorial has been created for a rather old version of Bevy (0.10). While it still can be a useful read for some parts, there is a more 'modern' approach to turn-based mechanics presented in a newer post: https://maciejglowka.com/blog/turn-based-mechanics-with-bevys-one-shot-systems/

I have been fiddling with the Bevy Engine for more or less a year now, working mostly on turn-based game prototypes. To be honest, most of the learning materials available seemed to be suited better for the real-time stuff. So, trying now to start a new small project I have decided to share a bit of the process. I am by no means a Bevy expert, but perhaps those steps will be useful for somebody struggling.

The aim here is to make a small roguelike-ish game - with the usual exploration of randomly generated levels in 2D. I also plan to include some simple deck building mechanics. For now, I am thinking that the player would be able to either move or play a card during the turn. Cards would allow attacks and other special actions. But that might change on the way :)

As for the technical aspects - I work on the current Bevy 0.10 and usually try to keep as few dependencies as possible. That would mean implementing from scratch some functionalities that are possibly available through 3rd party crates (like asset loading) - but that's part of the education :) I often find myself needing only a small part of what is offered by the crates and the minimalist DIY solution is usually enough. However feel free to swap those things with ready-made stuff.

I assume that the reader will already have some Bevy knowledge (I will not go through the very basics)

I work in WSL2 and find it the easiest to run dev builds through a WASM server, so you will see some code (like the panic handler) that allow it. But this can easily be skipped.

You can see the source code for this part in the repo branch here: https://github.com/maciekglowka/hike_deck/tree/part_01

Initial setup

I think it would be best if, already in the first instalment, we create something visible. Therefore this part might get a bit lengthy, as we need to go through some initial setup. First of all I will show some boiler-plate code that just needs to be there - without explaining in too much detail (as it's rather generic). Finally, at the end of this post we should have a visible piece of the map / dungeon :)

Usually I like to keep the most commonly shared code in separate, single-file modules in the root dir. Let's define one for some global constants and basic state enum:


// globals.rs
pub const WINDOW_WIDTH: f32 = 960.;
pub const WINDOW_HEIGHT: f32 = 600.;


// states.rs
use bevy::prelude::*;

#[derive(Clone, Debug, Default, Hash, Eq, States, PartialEq)]
pub enum MainState {
    #[default]
    LoadAssets,
    Game
}

Now let's add some simple asset loader and put it all together into a working project in the main.rs file. The asset loading works more-less like so:


// assets.rs
use bevy::prelude::*;
use bevy::asset::LoadState;

use crate::states::MainState;

pub struct AssetPlugin;

impl Plugin for AssetPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<AssetList>()
            .add_system(check_asset_loading.in_set(OnUpdate(MainState::LoadAssets)));
    }
}

#[derive(Default, Resource)]
pub struct AssetList(pub Vec<HandleUntyped>);

pub fn check_asset_loading(
    asset_server: Res<AssetServer>,
    asset_list: Res<AssetList>,
    mut next_state: ResMut<NextState<MainState>>
) {
    match asset_server.get_group_load_state(
        asset_list.0.iter().map(|a| a.id())
    ) {
        LoadState::Loaded => {
            next_state.set(MainState::Game);
        },
        LoadState::Failed => {
            error!("asset loading error");
        },
        _ => {}
    };
}


// main.rs
use bevy::prelude::*;

mod assets;
mod globals;
mod states;

fn main() {
    #[cfg(target_arch = "wasm32")]
    console_error_panic_hook::set_once();

    App::new()
        .add_plugins(
            DefaultPlugins.set(
                WindowPlugin {
                    primary_window: Some(Window {
                        resolution: (
                            globals::WINDOW_WIDTH,
                            globals::WINDOW_HEIGHT
                        ).into(),
                        ..Default::default()
                    }),
                    ..Default::default()
                }
            ).set(
                ImagePlugin::default_nearest()
            )
        )
        .insert_resource(Msaa::Off)
        .add_state::<states::MainState>()
        .add_plugin(assets::AssetPlugin)
        .run()
}


src/
├── assets.rs
├── globals.rs
├── main.rs
└── states.rs

Now if we hit cargo run the project should compile and run with an empty window. So let's start the proper development :)

Board logic

As we are making a tile-based game we are going to start by defining our board logic. Each board object, including the tiles, obviously has to be positioned within the board's coordinate system. Since the tile-space is discrete (there is no [1.25, 2.1] tile) it would be helpful to store position of those elements in an integer based 2d vector. We can define a simple Rust struct for it, like this:


#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct Vector2Int {
    pub x: i32,
    pub y: i32
}

I've added another top-level module called vectors.rs to make this struct easily available project-wide. I have also put some operator functions, but for brevity won't paste the code here (it is available in the tutorial repo on github https://github.com/maciekglowka/hike_deck/tree/part_01)

For the board itself we are going to create a more complex folder-based module that is going to utilize Bevy's plugin pattern. (I chose the name board over map or level as it is less ambiguous in our roguelike / coding context) Let's start by making a new board folder in the src root. For now it will contain those three files:

Let's see the components part first:


#[derive(Component)]
pub struct Position {
    pub v: Vector2Int
}

#[derive(Component)]
pub struct Tile;

We define a Tile component that is just a marker for now (no fields) and a Position component that would store the actual object's position within the board coordinate system. There is a reason for them being separate. It is not only the tiles that will be positioned on the map. We can reuse the Position component also for the units, items etc. I was hesitating even if the Position component should not be kept in a separate top-level common module rather than in the board. It would help minimizing the need of cross importing the modules. But for now let's keep it this way (in the end it IS a position on the board).

The current board structure will be kept inside of a helper resource that would contain references to all the tiles (as Entities). I usually keep the tiles as a HashMap where the Vec2Int position is the key. It allows for a fast linear-time lookup and gives a lot of flexibility (the board does not have to be rectangular and can grow dynamically). You can also easily check whether a position is valid by running contains_key on the HashMap.


#[derive(Default, Resource)]
pub struct BoardRes {
    pub tiles: HashMap<Vector2Int, Entity>
}

We are going to create now a system that would initialize our board and spawn all the tiles. Next we are going to register the system and the resource within the plugin. Notice that we are using here .in_schedule(OnEnter(MainState::Game)) run condition. It's gonna make the board spawn when we are actually starting the game (and after the assets are loaded).

The board design is definitely not the final one, so I let myself hardcode the size for now. We are going to change that later when we focus more on the board layout. For now it is a dummy allowing us to test tile spawning. units etc.


// board/systems.rs
use bevy::prelude::*;
use std::collections::HashMap;

use crate::vectors::Vector2Int;

use super::CurrentBoard;
use super::components::{Position, Tile};

pub fn spawn_map(
    mut commands: Commands,
    mut current: ResMut<CurrentBoard>
) {
    current.tiles = HashMap::new();
    for x in 0..8 {
        for y in 0..8 {
            let v = Vector2Int::new(x, y);
            let tile = commands.spawn((
                    Position { v },
                    Tile
                ))
                .id();
            current.tiles.insert(v, tile);
        }
    }
}


// board/mod.rs
use bevy::prelude::*;
use std::collections::HashMap;

use crate::states::MainState;
use crate::vectors::Vector2Int;

pub mod components;
mod systems;

pub struct BoardPlugin;

impl Plugin for BoardPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<CurrentBoard>()
            .add_system(systems::spawn_map.in_schedule(OnEnter(MainState::Game)));
    }
}

#[derive(Default, Resource)]
pub struct CurrentBoard {
    pub tiles: HashMap<Vector2Int, Entity>
}

That is our project structure now:


src/
├── assets.rs
├── board
│   ├── components.rs
│   ├── mod.rs
│   └── systems.rs
├── globals.rs
├── main.rs
├── states.rs
└── vectors.rs

Let's not forget to register our new plugin in the main.rs file by adding .add_plugin(board::BoardPlugin) . If we hit cargo run now...nothing new will happen :) We have created only the logic for the board and tiles - nothing gets rendered yet.

Board graphics

The easiest way to solve this, would be to add SpriteBundle when spawning the tiles in the above system. But we are not going to do that. It is a good practice to keep your game logic and graphics separate. Ideally the logic part should know nothing about the existence of the display code. The reason for that is that we can now start with simple font-based graphics, but later in the development process decide to go isometric or 3d or whatever. If we have things cleanly separated we would not need to touch the logic layer of the code at all to make such a change. Only the graphics systems would need to be rewritten.

There are some possibilities of how this can be achieved:

I've used options 1 and 2 so far, and both have their pros and cons.

The first one is the easiest of the three, but it doesn't give a complete, clean separation. It is however easy to track changes in the logic part and there is no risk of having dangling renderers after despawning the primary entity.

The second one gives you all the freedom, but updating graphics is more complex as you need maintain the relation manually. Also removal detection can be tricky in Bevy.

The 3rd one could be a nice compromise, but I've had some difficulties when parenting an entity containing Visibility and Transform components to a purely logical one. (so it's a pass for now)

In my last game I had to use the second option, as I've had some additional relation requirements in the logic (namely the player, npcs etc. where parented to the tiles - which would propagate on the Transforms making animations tricky). This time I do not expect such a thing, so I will take a risk and try the first approach. If it doesn't work out it should be fairly easy to change it later on. (we will keep this need for flexibility in mind).

So let's create yet another module / plugin called graphics where we would keep all our rendering stuff. In it's main mod.rs file we are going to locate some pieces of shared data:


pub const TILE_SIZE: f32 = 32.;

#[derive(Resource)]
struct GraphicsAssets {
    pub sprite_texture: Handle<TextureAtlas>
}

The TILE_SIZE const would define how big the displayed tiles would be - pixelwise. It is important that all the rendering code will use this exact value. Then we can easily change this size later - without breaking anything. We will also define an asset struct that will hold references to the loaded textures.
Now let's use our asset loader to create the texture atlas for the sprites. I am going to use for now a single classic ASCII tile set. This code should go into assets.rs submodule, within the graphics dir:


// graphics/assets.rs
use bevy::prelude::*;

use super::GraphicsAssets;

const ATLAS_PATH: &str = "ascii.png";

pub fn load_assets(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut texture_atlasses: ResMut<Assets<TextureAtlas>>,
    mut asset_list: ResMut<crate::assets::AssetList>
) {
    let texture = asset_server.load(ATLAS_PATH);
    asset_list.0.push(texture.clone_untyped());
    let atlas = TextureAtlas::from_grid(
        texture,
        Vec2::splat(10.),
        16,
        16,
        None,
        None
    );
    let handle = texture_atlasses.add(atlas);
    commands.insert_resource(
        GraphicsAssets { sprite_texture: handle }
    );
}

Now we need to create the system responsible for spawning sprite components. As I expect quite a lot of various systems in the graphics module I am not going to keep them all in a single systems.rs file. Instead let's create a tiles.rs submodule, where we would keep everything related to tiles' display.

For now we are just going to insert a SpriteSheetBundle on each newly created tile. To detect those, we will use a query with an Added<Tile> filter, that should just give us the entities we want. We are going to hard-code a sprite index for now (from the ascii table) and give it some color tint - to distingush from the future board pieces like units.


// graphics/tiles.rs
use bevy::prelude::*;

use crate::board::components::{Position, Tile};
use super::{GraphicsAssets, TILE_SIZE};

pub fn spawn_tile_renderer(
    mut commands: Commands,
    query: Query<(Entity, &Position), Added<Tile>>,
    assets: Res<GraphicsAssets>
) {
    for (entity, position) in query.iter() {
        let mut sprite = TextureAtlasSprite::new(177);
        sprite.custom_size = Some(Vec2::splat(TILE_SIZE));
        sprite.color = Color::OLIVE;
        let v = Vec3::new(
            TILE_SIZE * position.v.x as f32,
            TILE_SIZE * position.v.y as f32,
            0.
        );
        commands.entity(entity)
            .insert(
                SpriteSheetBundle {
                    sprite,
                    texture_atlas: assets.sprite_texture.clone(),
                    transform: Transform::from_translation(v),
                    ..Default::default()
                }
            );
    }
}

Finally, let's register both systems in our plugin:


// graphics/mod.rs
use bevy::prelude::*;

pub const TILE_SIZE: f32 = 32.;

mod assets;
mod tiles;

#[derive(Resource)]
pub struct GraphicsAssets {
    pub sprite_texture: Handle<TextureAtlas>
}

pub struct GraphicsPlugin;

impl Plugin for GraphicsPlugin {
    fn build(&self, app: &mut App) {
        app.add_startup_system(assets::load_assets)
            .add_system(tiles::spawn_tile_renderer);
    }
}

There are two last things left to do:

To keep it simple for now, let's just create a camera.rs file directly in the src/ dir and put out spawning system there (it is not a plugin though).


// camera.rs
use bevy::prelude::*;

use crate::graphics::TILE_SIZE;

pub fn setup(mut commands: Commands) {
    let mut camera = Camera2dBundle::default();
    camera.transform.translation = Vec3::new(
        4. * TILE_SIZE,
        4. * TILE_SIZE,
        camera.transform.translation.z
    );
    commands.spawn(camera);
}

To make sure we see our tiles, I suggest to hard-code the camera position for now at the board centre. Later on we are going to change that and eg. make it follow the player.

Now we just have to include .add_startup_system(camera::setup) in our main file and we are finally ready to see our board tiles! (not too impressive at the moment though ;)

In the next part we are going to focus on populating our board with some units. There should be less boiler-plate code, I hope :)

bevy_tut_part_01.png
← Text-based (JSON, Toml) resources in Bevy engine Bevy roguelike tutorial / devlog part 2 - The player→