Special Delivery - A game about a stork written in Rust and Bevy!
TL;DR: Bevy is an amazing game engine, if you are OK with some manual assembly and a rapidly-evolving ecosystem. You can play my game on itch.io now or watch a gameplay video:
Ludum Dare is a well-known game jam where individual developers participate in the "compo" - making a game from scratch in 48 hours over the course of a weekend. I have participated thrice and finished twice when I was younger (video / timelapse) and more foolish (video).
When LD#53 rolled around and the theme "Delivery" was announced, I hadn't prepared whatsoever, so I shrugged it off with "too bad, maybe I'll participate again next time" and went on with my day.
27 hours into the compo, while taking a shower, the idea hit me that combining Flappy Bird mechanics with a stork delivering babies by chucking them about would be a fun project feasible for a single hobbyist developer. Damn. Now I want to join.
Since I have a day job and did not want to work way too hard to catch up for the rest of the weekend, I decided to work with a more relaxed pace outside of the Ludum Dare compo and relaxing the rules even further by using CC0 music. It would be a nice chance to see how game development using the Bevy engine has improved over time. It turns out, it's pretty great already! It took me a while longer to polish up the game and write this blog post (now, LD #55 is around the corner), but here it is now.
Design
Flappy Bird is one of the most cloned games ever, so I wanted to innovate somewhat. Apart from introducing baby chucking as a risk-reward mechanic, I wanted to see what procedural level generation could add.
Procedural Level Generation
As pipes from the sky and ground get stale pretty quickly, I wanted to explore how procedural generation could add to the experience. The idea was to add various level "shards" that have defined "entrance" and "exit" regions. The game can extend the world to the right by randomly picking a shard and Y offset so that all "exit" regions of the current shard are matched with an "entrance" region in the newly-added shard.
I originally played around with assigning levels to various categories such as "challenges" and "linkers", so that rules could be placed on what shard could follow after another, but found that there was no benefit from the additional restriction.
Enhanced One-Button Controls
I tried to keep the game essentially a single-button game, but wanted to enhance the controls somewhat. Holding down the button will not just trigger a single upwards flap, but a smoother, more continuous climb. After holding for a few seconds, the trajectory transitions into a slow downwards glide.
Implementation
Special Delivery was written bevy 0.11, using these additional crates from the Bevy ecosystem:
- bevy_asset_loader (for loading assets before game start),
- bevy_ecs_ldtk (for LDtk map support),
- bevy_kira_audio (for game audio),
- bevy-parallax (for parallax background art),
- bevy_rapier2d (for physics) and
- leafwing-input-manager (for input mappings)
- For debugging, bevy_editor_pls can be compiled in by enabling a feature.
For drawing sprites and tiles from scratch, I used aseprite. Maps were built in LDtk. Sound effects were generated with jsfxr. Since I don't know how to compose music, I used some tracks by Alex McCulloch, Komiku, Memoraphile and Fluffclipse (CC0-licensed via OpenGameArt and Loyalty Freak).
Learnings
I will try to summarize some things that I learned from the perspective of an intermediate Rust developer and gamedev hobbyist about things that I realized once the documentation ends.
Bevy is already fantastic to work with, mostly
It's clear why the Rust gamedev community is so strongly converging on Bevy - it has great performance, an elegant API, a large plugin ecosystem, and it's easy to organize code in a clean-ish way through the use of plugins. The developer experience with Visual Studio Code and rust-analyzer, combined with fast compiles thanks to dynamic linking and live reloading allows for fast iteration.
I was very happy with the ECS architecture and quickly managed to get basic controls working, including integration with the Rapier physics engine. Loading maps from LDtk was mostly straightforward as well, thanks to bevy_ecs_ldtk, and there was even some example code for how to generate colliders from LDtk map data.
One caveat to that is that the documentation found in the unofficial Bevy cheatbook is a crucial resource (on top of the official crate docs) to figure out how to put things together, but that as of writing, much of the more advanced topics of the cheatbook are still written for the last 0.9 release of Bevy. Combining the cheatbook with the official migration guide and a bit of trial and error allowed me to get most things to work, though.
While the audio part of Bevy has seen lots of recent improvement, I still didn't manage to use it for my game as I could not figure out how to detect if a song had ended (so the next song in the playlist can be started). While this feature will be available in the next Bevy release, I ended up disabling the official audio engine and using bevy_kira_audio
instead, where it is possible to do this by getting the PlaybackState from the state() method on AudioChannel objects. Note that the Res<Audio>
used in system queries is just shorthand for Res<AudioChannel<MainTrack>>>
, and that when deploying to the web, there is a delay between going from the Queued
state to the Playing
state.
Compiling to WebAssembly has matured a lot
The last time that I was trying out Rust gamedev libraries such as quicksilver and crayon, compiling for the web was something that only had experimental support and there were lots of situations where things would only work after twiddling with various feature flags and workarounds. In Bevy 0.10, I was stunned how smoothly everything worked out-of-the-box, not just with the base engine, but also remained mostly smooth after adding other crates from the Bevy ecosystem (one small gotcha was that bevy_ecs_ldtk would only render in the web after enabling the atlas
feature).
Even after having written a full game with art, music, and sound, the Cargo.toml
dependencies for the project has still stayed surprisingly straightforward:
[package]
name = "delivery"
version = "0.1.0"
edition = "2021"
[workspace]
members = ["mobile"]
[[bin]]
name = "delivery"
[dependencies]
bevy_asset_loader = { version = "0.17.0", features = ["2d"] }
bevy_ecs_ldtk = { version = "0.8.0", features = ["atlas"] }
bevy_editor_pls = { version = "0.5.0", optional = true }
bevy_kira_audio = "0.17.0"
bevy-parallax = "0.6.1"
bevy_rapier2d = "0.22.0"
leafwing-input-manager = "0.10.0"
rand = "0.8.5"
[dependencies.bevy]
version = "0.11.3"
#git = "https://github.com/bevyengine/bevy.git"
default_features = false
features = ["bevy_text", "png", "bevy_winit", "webgl2", "x11"]
[features]
debug = ["bevy_editor_pls"]
reload = ["bevy/filesystem_watcher"]
# Enable 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
I should have started using bevy_asset_loader much earlier
Bevy's way of returning Handle<T>
types when loading assets seems slightly clunky at first, but starts to make a lot of sense when compiling for the web, where assets will not be loaded instantaneously, but rather trickle in bit by bit as HTTP requests complete. In order to prevent objects popping into view as assets become available, using an asset loader Β is needed. The bevy_asset_loader crate uses Bevy's State system to begin in a loading state and only transition to rendering the game world once assets are fully loaded.
#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, States)]
pub enum GameState {
#[default]
Loading,
InGame,
}
app.add_state::<GameState>()
.add_loading_state(
LoadingState::new(GameState::Loading)
.continue_to_state(GameState::InGame),
)
// instead of startup systems, use this
.add_system(initialize_stuff.in_schedule(OnEnter(GameState::InGame)))
// update only while in InGame state
.add_system(update_stuff.in_set(OnUpdate(GameState::InGame)))
Migrating the code to game states after the fact was somewhat challenging for my game, as I had written systems that were assuming there to be exactly one copy of certain objects (such as the main camera, the bird entity, etc.). These systems made liberal use of Query::single() and Query::single_mut(). As the documentation states, these methods will panic at runtime if there isn't exactly one entity returned by the query, which caused a lot of crashes when moving between game states. Just like many Rust examples use Option::unwrap() liberally, and it is a bad idea to use it in production, the panicking methods on Query should be avoided. I ended up replacing the accesses in these systems with Query::get_single() and Query::get_single_mut(), and being deliberate about scheduling systems only for the InGame
game state.
In retrospect, the hassle of dealing with panicking systems could have been prevented when using bevy_asset_loader right from the start (building systems up cleanly from the get-go and avoiding Query::single()
and Query::single_mut
). Moving to the AssetCollection system used by the loader system also greatly cleaned up my asset loading code. Because of this, I would recommend to use bevy_asset_loader right from the start in future projects.
While this is documented well in bevy_asset_loader, I also want to stress that any sort of asset that is loaded as a Handle<T>
can be put into an AssetCollection
, including but not limited to Image
, TextureAtlas
, AudioSource
and LdtkAsset
. Especially the last one seems to cover not just the map, but all connected tiles and entities, so a lot of automated loading gets implemented for almost free!
Reflection and bevy_editor_pls
bevy_editor_pls is great for debugging the scene graph and system queries. By default, all custom Resource
s and Component
s will not have their data show up in the editor UI, but this is easily fixable by deriving Reflect and registering the types in the app:
#[reflect(Default)]
pub enum CargoDetachBehavior {
#[default]
Remove,
Fling,
}
#[derive(Component, Default, Debug, Reflect)]
#[reflect(Component)]
pub struct CargoAttachment {
pub offset: Vec2,
pub cargo_detach_behaviour: CargoDetachBehavior,
}
// during set-up, make sure to register the types
app.register_type::<CargoAttachment>();
Aseprite+LDtk let me create acceptable programmer art
While I am somewhat comfortable with writing code, drawing is something I have much less experience with. After trying vector graphics in the past, I wanted to try my hand at pixel graphics for a tile-based game this time around.
Low-resolution graphics are kind to a bad artist like me since there aren't too many pixels to mess up, allowing frequent iteration until it looks acceptable. The palettes coming with aseprite (I used AAP-Splendor128) let me create a more cohesive-looking style, and its pixel art-focused tools, the tile editing and tiling preview features got me a long way quickly in creating OK-looking seamless tiling and animated sprites. Here is my official endorsement: Aseprite is worth the money, even if you have no idea what you are doing :)
After having used Tiled in the past, I wanted to give LDtk a go. While there are a lot fewer features than Tiled at this point, the user interface of LDtk is particularly intuitive, and the concept of a world as a group of levels worked excellently with the procedural world generation I was planning for Special Delivery - just combining level shards horizontally to form an infinitely scrolling world.
I designed my tileset with auto-layers in mind, but ran into some issues setting up rules via the assistant. There seemed to be multiple overlapping tiles chosen for some of the squares of my map, leading to visual artifacts, so I ended up hand-creating the tiling rules for my layers.
Git LFS, GitHub Actions and Auto-Publishing to Itch.io
The Bevy GitHub CI Template is a fantastic place to get started with cross-platform builds via GitHub Actions, but beware of some gotchas: When developing in a private project on GitHub, where CI minutes are limited, be aware of minute multipliers! Compiling on MacOS will take 10x the number of CI minutes, and on Windows will take 2x the number of minutes of a Linux build. I decided to comment out everything but WebAssembly builds while still releasing frequently, and might re-enable other builds for a 1.0 release.
You can get less than 10 of these full builds per month on a free plan!
Later during development, when adding music to my game, I ran into a puzzler of a bug: Music was playing fine locally as a native binary and as wasm32-unknown-unknown
builds playing in the browser (using wasm-server-runner), but after the game was deployed on itch.io, the symphony crate used for decoding sound files would complain that the sound files were invalid. It turned out that when storing binary assets using Git LFS, checking out files from LFS needs to be explicitly enabled in the GitHub checkout action by enabling the lfs: true
option. Instead of trying to load the actual music files, the CI build contained the hash references to files stored in LFS.
Next Steps
I'm now happy enough with the game to release an initial public version, but there is always more that could be done. My main thoughts around how to progressively increase difficulty the longer the game progresses, perhaps by a difficulty ranking of invididual shards to bias the procedural generation towards harder shards or challenging combinations.