Bevy Traditional Roguelike Quick-Start - 1. Drawing the Player Character

Traditional roguelikes are an ancient genre of games which earned the peak of their fame in the 20th century. They are the ancestors of modern indie roguelikes beloved by many such as Hades, The Binding of Isaac or Risk of Rain. What a "true roguelike" is has been the driving force of multiple Internet flamewars, but it almost always revolves around this list:

  • The game takes place on a grid, like Chess.
  • The game is turn-based: the player moves, then monsters do.
  • The environment is randomized with procedural generation.
  • When the player dies, the game restarts from scratch.

Traditional roguelikes, despite their age, still live today. Major releases with a still active community today include:

  • Caves of Qud
  • Dungeon Crawl Stone Soup
  • Cataclysm Dark Days Ahead
  • Nethack

There are multiple reasons why I think this genre is well suited for a beginner-level Bevy Quickstart guide:

  • Traditionally terrible minimalistic graphics. No great artistic expectations will be forced on you!
  • Infinitely extensible design that lends itself very easily to imagining new abilities, foes and challenges!
  • An Event-centric, turn-based architecture to show off the power of Bevy's ECS.
  • Fun! For nerds.

This tutorial assumes:

  • That you have never touched Bevy before.
  • That you have some beginner Rust experience and know what "borrow checking" is. If you do not, I recommend going through the first half of the rustlings suite of interactive exercises to get you up to speed.
    • No concurrency or dynamic dispatch Rust technowizardry will be required. Only the basics.

The nature of ECS has been covered earlier in the Quick Start guide. Here is a metaphorical reminder:

  • Entities - The actors on the stage.
  • Components - The costumes and characters worn by the actors.
  • Systems - The script of the play.

Setting the Stage - The App

Writing cargo new bevy-quick-start creates a Rust version of the evergreen Hello World program. It feels quite distant from anything resembling a game you can play in a contained window, except perhaps a prehistoric text adventure. Let's fix that by replacing the code in fn main:

/// The entry point into the game, where everything begins.
fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .run();
}

It would also be wise to import Bevy in a Bevy project. Place this on the very first line of main.rs:

use bevy::prelude::*;

The App is the primary container of the game - the theatre in which the actors will play. To ensure it starts off with all the basic Bevy features, the DefaultPlugins plugin must be tacked onto it.

Running this code with cargo run will result in an empty, boring window. Progress!

The First Denizen - The Player

The player needs an avatar through which they will interact with the game world. In a traditional roguelike, this player will be a Creature - just like the foes and friends they will meet - susceptible to motion, health and other restrictions that come from having a physical body.

In fact, our game will likely be populated by a lot of these Creatures. Let us define what that is:

/// Common components relating to spawning a new Creature.
#[derive(Bundle)]
struct Creature {
    sprite: Sprite,
}

Right now, a Creature doesn't have much more beyond a sprite, which is its graphical representation on screen.

And let us spawn the player:

/// Spawn the player character.
fn spawn_player(
    // Bevy's Commands add, modify or remove Entities.
    mut commands: Commands,
    // A builtin Bevy resource that manages textures and other assets.
    asset_server: Res<AssetServer>,
) {
    // The spawn command summons a new Entity with the components specified within.
    commands.spawn(Creature {
        sprite: Sprite {
            // What does the player look like?
            image: asset_server.load("otter.png"),
            // Everything else should be default (for example, the player should be Visible)
            ..default()
        },
    });
}

At any moment while using Bevy, should one need to:

  • Spawn a new Entity
  • Delete an Entity
  • Attach a new Component to an Entity
  • Remove a Component from an Entity

A mutable Commands argument will likely be required. This is the case here, as a new Entity is being spawned.

Since we are also pulling a visual asset from a file into the game, the AssetServer is similarly required. It is a Resource, shown by the wrapping Res<_>, which is a Bevy type to be discovered later in this tutorial.

We will also need to run this new function - or, in Bevy terms, System - by attaching it to the ̀App:

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, spawn_player) // NEW!
        .run();
}

Startup systems run once when booting up the app, then are never ran again.

The player is thus spawned with the texture "otter.png" at the position (0.0, 0.0, 0.0). Note the Z-coordinate - in a 2D game, it is still useful, as it determines which Entities get to be drawn on top of each other. More on that later.

Using cargo run on this will result in an error:

ERROR bevy_asset::server: Path not found: your/path/here/bevy-quick-start/assets/otter.png

Of course, we need to provide the image. In your game's root directory (where src and Cargo.toml currently exist), create a new directory named assets. If the App is the theatre, this is the costume storage - containing all the image data that Entities can take up as their visual sprite representations.

Then, place any image of your choosing within it, renaming it so its filename matches the one used in the ̀texture field in your code.

Are you ready? cargo run! This will result in the exciting sight of... absolutely nothing.

Bird's Eye View - The Camera

In Bevy, spawning entities left and right isn't very interesting if we are incapable of viewing them. We need to give ourselves eyes to see - a Camera:

/// The camera, allowing Entities to be seen through the App window.
fn setup_camera(mut commands: Commands) {
    commands.spawn((
        Camera2d::default(),
        Transform::from_xyz(0., 0., 0.),
    ));
}

Quite similar to spawning the player - after all, a Camera is just another Entity, just like anything in a Bevy game. The only difference is in its Components, which include Camera. Somewhere, deep in Bevy source code, there is a System fetching any Entity that contains Camera and doing some magic to make it display the screen's contents.

We also need to welcome this new actor onto the stage by tying it to the App:

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, spawn_player)
        .add_systems(Startup, setup_camera) // NEW!
        .run();
}

Running ̀cargo run will now - assuming you had as much good taste as I did to pick a cute otter as my example image - display the player "character"!

A Bevy app with a picture of an otter in the centre.

Bundling Them Up - The Spritesheet

Unfortunately, we cannot build our epic roguelike dungeon out of otters. There will be different Creatures - foes, friends, or even walls - the latter behaving like other beings, letting them have health and be shoved around. They will be represented by different glyph-like sprites, and throwing around 100+ .png files in the ̀assets directory is not my definition of careful organization.

This is where the Spritesheet comes in - one image containing all game sprites next to each other, with a special Atlas to dictate which part of this image will be cropped out to represent each Creature.

#[derive(Resource)]
struct SpriteSheetAtlas {
    handle: Handle<TextureAtlasLayout>,
}

This marks the creation of our first Resource. A Resource, in Bevy, is basically a global mutable variable. You can imagine it in the same way that you need to spend "wood resources" to build houses in a construction game - the wood is likely represented by a little icon on the side of the screen with a number next to it. It doesn't belong to a certain Entity - it belongs to the game as a whole.

In this case, this Resource is an Atlas, mapping the spritesheet to divide it in tidy 16x16 squares. It will be accessible each time a new Creature is made, to assign it a specific region of that spritesheet.

This Resource must be initialized:

/// An initialization of the sprite sheet atlas, ran from `init_resource`.
impl FromWorld for SpriteSheetAtlas {
    fn from_world(world: &mut World) -> Self {
        // The spritesheet is composed of 16x16 squares.
        // There are 8 sprite columns, spread across 1 row.
        // There is no padding between the cells (None) and no offset (None)
        let layout = TextureAtlasLayout::from_grid(UVec2::splat(16), 160, 2, None, None);
        // Grab the active atlases stored by Bevy.
        let mut texture_atlases = world
            .get_resource_mut::<Assets<TextureAtlasLayout>>()
            .unwrap();
        // Add the new Atlas in Bevy's atlases and store it in the Resource.
        Self {
            handle: texture_atlases.add(layout),
        }
    }
}

Any Resource which implements FromWorld will, upon running init_resource, run the code contained in the impl block to create a new instance of it.

The TextureAtlasLayout specifies the crop layout. Each sprite is 16x16 (UVec2::splat(16) is a shortform of UVec2::new(16, 16)), there are 160 sprite columns and 2 rows, and there is no padding nor offset (None, None).

This is stored into Bevy's atlases list, and is saved into the Resource for future usage.

At last, this spritesheet must be properly welcomed through the App:

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .init_resource::<SpriteSheetAtlas>() // NEW!
        .add_systems(Startup, setup_camera)
        .add_systems(Startup, spawn_player)
        .run();
}

Now that we have our Atlas, we need to add it to new Creatures. Not only do they have a sprite (the spritesheet), they also have a select crop of that spritesheet (the Atlas) to represent them:

fn spawn_player(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    atlas_layout: Res<SpriteSheetAtlas>, // NEW!
) {
    commands.spawn(
        Creature {
            sprite: Sprite {
                // CHANGED to spritesheet.png.
                image: asset_server.load("spritesheet.png"),
                // NEW! 
                // Custom size, for 64x64 pixel tiles.
                custom_size: Some(Vec2::new(64., 64.)),
                // Our atlas.
                texture_atlas: Some(TextureAtlas {
                    layout: atlas_layout.handle.clone(),
                    index: 0,
                }),
                // End NEW.
                ..default()
            },
        },
    );
}

Running cargo run again will display the player glyph, cropped appropriately! But...

A Bevy app with a blurry image of the player character glyph in the centre.

Hmm, did I forget my glasses?

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest())) // CHANGED!
        .init_resource::<SpriteSheetAtlas>()
        .add_systems(Startup, setup_camera)
        .add_systems(Startup, spawn_player)
        .run();
}

Much better. Activating default_nearest in Bevy options like this helps render pixel art in pixel-perfect mode.

Enter cargo run again to finish the first part of this tutorial!

A Bevy app with a clean image of the player character glyph in the centre.

My Day at the Rust Conference - because let's grab all the most hardcore introverts in the world and put them in the same room, why not

Being a part of the technology community means developing relationships with wizards thousands of kilometres away. It is a simultaneously lonely and connected feeling.

And then, sometimes, all those threads converge in a single point. One becomes able to look in the eye and smile at all those distant mentors, friends and co-collaborators we shared our work with.

That place, for me, today, is RustConf.

I'm looking forward to tomorrow, another full day of learning and sharing. But for the souls who couldn't make it, here is a diary of the day's experiences - and, for those who did make it, well, a different perspective from their own may be appreciated.

Artificial Incompetence

No, to Hell with "chronological order". This is the penultimate talk I listened to today, about the crate cargo-mutants. I'm bringing it up first because I thought the technology was really cool. Plus, it's something readers of the blog can probably play with right now!

This is not a new concept. However, this Rust-specific implementation matches harmoniously with the language's robustness.

Running cargo mutants in your command line will immediately summon an imaginary software developer intern who will proceed to butcher your testing suite code and implement horrible bugs. This is expressed by:

  • Replacing operators (such as && with ||, or += with -=)
  • Replacing functions with a return type with an arbitrary variation of that return type.

Example of the latter: get_cute_otters() returns a -> Option<usize>. cargo-mutants will transform calls of this function like this:

- assert_eq!(get_cute_otters(), Some(3));
+ assert_eq!(Some(0), Some(3));
+ // or
+ assert_eq!(None, Some(3));

cargo-mutants will then raise a warning for any mutation attempt that did not break tests and cause them to fail. After all, if randomly slamming the keyboard on your test suite is labeled as "perfectly fine" by cargo test, it may be time for some detective work.

In my opinion, this does not seem like cargo clippy where one should aim for absolutely 0 warnings. After all, replacing make_sure_this_returns_true_otherwise_existence_will_be_erased() with true should keep the tests passing. Rather, it's an invitation to review potential sources of error and look at your implementation with fresh eyes.

Remember, the developer who wrote the code is usually the one also writing the tests. That means absolutely nothing is stopping them from having a blind spot and making the same mistake twice.

Between Sky And Sea

There is this notion in the technology world that user interface designers are the tip of the iceberg - and the part of development the uninitiated are most acquainted with - while the Atlantean civilization beyond mortal understanding at the bottom of the sea (also known as "kernel and compiler developers") stays hidden from view.

There is another world, one which seems to grow year after year. The network architects, the database connectors, the cloud computing specialists - the ones who recite eldritch formulas like "Mongo", "Postgres" or "Kubernetes" and cast the Summon Money spell. (Unfortunately, Summon Greater Money requires making a pact with an archfiend, such as Microgle or Goosoft).

Two of the talks I attended today were about the connection between a massive database and a user-facing customer application:

  • A MongoDB driver to asynchronously carry data from client to server
  • A web interactive map that tracks public transit, called Catenary and which needs to communicate with city APIs that potentially all use conflicting data formats

I was quite awed by what I witnessed. However:

  • Interestingly, while most talks were "here is how you can use this Rust tool/feature in your own project", these two stood out from the lot by being "here is this cool thing we built using Rust". Because the audience of RustConf was composed of Rust developers (I hope), I imagine presenting a project like this would require fighting a little harder to keep the audience's interest.
  • The talks had a "walk me through your call stack" feel, especially the MongoDB one. This is a really deep dive - and therefore technically interesting to anyone with specialized experience wishing to do something like this on their own. But, for those more familiar with different realms of computing, I felt a bit like constantly journeying deeper in a thick forest, and asking "Where did I come from"?

A word of appreciation to the Catenary panelist for having bilingual (English/French) slides in his presentation - I felt honoured by this nod to the culture of my home city (Montréal).

Interlude

Dear people who code during a coding conference: please show mercy. Please indicate which sacrifices are required to satiate your eldritch thirst. Your zeal is beyond human comprehension.

All That Work Because I'm Too Lazy To Press One Key

I truly got to appreciate rustfmt last summer when contributing to the Rust central repository for my Makefile rewrite project. The CI job known as tidy isn't too enthusiastic about my lines of code spanning the entire circumference of Earth in their length.

Today, I got to witness just how many cogs start turning when I mindlessly type ":w" (yes, not Ctrl-S, I know, I'm so 1337) and see all the little words satisfyingly snap into place. First, the talk mentioned that mere strings are not sufficient to direct proper formatting. rustfmt needs to generate its own IR (intermediate representation) - a concept normally reserved to compilers - and have a rough understanding of the code's logic to then output back a fully formatted string sequence corresponding exactly to the parsed, messy string.

There is zero room for error - a formatter changing the way code is interpreted by the compiler, even in an extremely subtle way, would be a sad way to crash the latest Blazingly Fast™ NASA satellite written by an excited Rustacean intern.

Because of this parity requirement, some dilemmas arise:

struct Otter {
  eyes: Vec<Eye>,
  tail: Tail, // Don't worry, the Tail field can be removed later.
  cute: bool,
}

Let's say that this comment is too long and must be moved to the line under or above. But how can you choose? The formatter doesn't understand English. (Unless your compiler is simultaneously a chatbot - opinions on the genius and/or lunacy of such a technology will not be the subject of this post). Compilers don't have to take comments into account, formatters do.

Next, there are macros. They appear in their non-extended state to the formatter, while the compiler sees them in their fully unrolled state. With this mismatch, formatting in such a way that won't cause a difference in the way the compiler parses your code is another challenge.

I really appreciated this talk. It shed light on how complex some of the quality-of-life tools we use can be.

Interlude

Yes. Let's have two conference rooms. One larger than the Sun with hundreds of chairs, and the other requiring a microscope to properly see. And let's put all the more technical panels in the small room. It will fill right up in milliseconds and the nerds will be sent to listen to the other talk.

Hmph. Sorry. Venting over.

Merge And Release, On Stage

I used to think there was an inverse correlation between technical skill and the ability to teach it. All seconds spent learning how to teach are spent not learning the material, are they not?

After the presentation on rkyv, I acknowledge there may be exceptions to this rule.

First, I must admit it took me an unreasonable amount of time until I realized it's called rkyv because it sounds like "archive" when you pronounce it. Before this revelation came unto me, I constantly mispelled it as "rykv" or "rkvy".

Now that this is out of the way, I loved this presentation. I simply must honour the charisma in running cargo publish to ship 0.8 to crates.io live on stage, a version that the panelist worked on for the last 2.5 years.

rkyv is a zero-copy deserialization framework for Rust. A deserialization crate without the deserialization part. A rawer, more performant version of Serde, if you will. Not that the archives it outputs are human-readable, unlike the latter.

The concept itself is extremely simple at its core. Take data (like a struct), shove it in a [u8] (slice of bytes), and unpack later. This sounds so simple I wondered why it wasn't more widespread, until I was presented how uncanny some of the edge cases can get:

  • Absolute pointers like *const cannot be arbitrarily shoved in a slice, as they contain a memory address. Wrap them up nicely in a Box, perhaps? Now, you must deal with machine-specific endianness, because some computers read left-to-right and others right-to-left.

Small tangent: grabbing Arabic numbers from the Arabs and not keeping the right-to-left reading direction (specifically for numbers) was a mistake. Let's say I present to you the number 5XXX(an n number of further X digits). What's that? Can't see the last digits? I'm hiding them with the palm of my metaphorical hand, tough luck. With big-endian (left-to-right), all you know is that it starts with 5, but you're not even sure if that's a 5000 or a 500 000. With little-endian (right-to-left), you know that the number ends with 5 and is therefore divisible by 5. More data collected per digit!

Anyhow. The live-published on stage version, 0.8, fixes this irregularity by enforcing endianness and wrapping of absolute pointers automatically. Go try it in your projects! Perhaps you'll be happy with the performance gains.

I loved the final optimistic note of this talk. The developer promised to release in one year... back in 2022. Then, he was reminded of the often forgotten truth in software development that "80% done is actually 20% done" and got stuck for a while.

But. During all that time, rkyv stayed. It didn't regress, it didn't rot. It just waited for the right time. And that time came, today.

That makes me feel a lot better about my own abandoned on-hiatus projects.

Conclusion

It's not over. I'll be back tomorrow for more fun, more networking, more crab pinching with the hand to fire up the audience. I'll be giving a small "community talk" about my Rustc test suite rewrite project at 14:45 (2:45 PM) in the tiny room connected to the main ballroom. I'd be delighted to see familiar (or new!) faces.

This has been an amazing day.

Reddit: u/oneirical

Discord: oneirical