"Online, the best way to obtain information is not to ask a question, but to state incorrect information and wait for someone to correct you." - Common Internet Wisdom

It is said that one can judge the nerdiness level of a programming language based off the ratio of games-to-game-engines it has.

Considering that - without looking anything off and purely off the top of my head - I am incapable of naming a single published Rust game, but can name at least 4 Rust game engines, (Bevy, Macroquad, ggez, Fyrox) I'd say we're dealing with a particularly severe case Terminal Technowizardry Syndrome.

Like many brave dungeoneers before me, I have ventured off into the damp caves of Rust game development in a quest to right these wrongs. And just like all those aforementioned adventurers, I am failing epically.

However, instead of being reasonable and recognizing my personal shortcomings, I will now proceed to become the protagonist of the adage "The shoddy craftsman blames their tools."

Saint the slugcat, very frustrated over some puzzle pieces

Background

Bevy is currently the most popular Rust game engine out there. In fact, it is so popular that non-Rust programmers might have actually heard of it, which is a feat of its own. As it is designed by Rust programmers, it is sculpted in the image of the substance which composes it - that means, it is extremely opinionated and devoid of any user interface.

Based on the ECS (Entity, Component, Systems) design style, it adopts a philosophy that banishes feeble concepts such as "node trees" or "inheritance" back into the OOP abyss where they belong.

"I don't think ECS is a new concept... like a lot of current trends it's making the old ways of doing things sound new again. It does do some exciting stuff... but it's ultimately just structs and composition, like how we used to do things - but adapted for the modern Unity Node Tree palette." - Evie, my most highly esteemed and very opinionated mentor

Because looking at the people currently raking in boatloads of money and deciding to do things completely differently is cool, Bevy stretches ECS to the extreme, making even UI elements be ECS entities and forcing all code to run inside Systems.

I have completed 2 small projects with Bevy, both open source:

And, I am currently working on my larger scale forever-project:

I will now proceed to bash what I believe are currently Bevy's greatest shortcomings, with the highly devious plan of getting Internet strangers to go "You absolute buffoon! How can you forget to use [HIGHLY ESSENTIAL BEVY FEATURE I NEVER HEARD OF] to save yourself hours of time? You dunce! You fluff-bloated addlepated simpleton!" Because finding those things myself can be... actually, that just brings me to my first main point.

Bevy Contributors Are Way Too Productive

I was only just finishing up updating my game to match Bevy 0.12's new conventions (December 2023) when they released Bevy 0.13 (February 2024). I must now once again comb through my code to knock out all now-obsolete conventions, such as the TextureAtlasSprites liberally scattered throughout my code.

Bevy needs to be hyped up to get people working on it, I fully understand that. But it is not a complete project by any means. Working with it feels like constantly reaching for a tool in your toolbox which you expect to be there, but instead, your hand simply passes through the case and enters a wormhole reaching into a Draft pull request on their GitHub. Tutorials become full of incorrect and outdated information in a matter of months - and as a result, people are not very motivated to actually make them. A significant portion of Bevy-related knowledge is found on YouTube, which is, in my opinion, NOT a good resource when it comes to fetching a quick answer like "how do I schedule this System to only run in this specific GameState".

Bevy programmers probably already know this, but beyond the official docs.rs Bevy documentation, these two resources are highly valuable and tend to be updated... occasionally.

In addition to this, a large portion of Bevy development is fetching third-party crates to complement your Bevy development experience. For instance, I make extensive use of the Bevy Tweening crate in my own projects. I am fully dependent on their maintainers to keep up the pace with new Bevy releases - if a cosmic ray vaporizes every atom of their bodies in an unfortunate astral accident, I will be forced to update their libraries myself to keep my game running with the latest Bevy version. Bevy Tweening maintainers are very active right now, but that might not be the case for the dozen of external Bevy crates a game developer might see themselves using for a larger scale project.

Bevy Types Expand In Mass Until They Blot Out The Sun

Hmm, yes, dear waiter, I believe I'll be getting a ParamSet<(Query<(&Transform, &mut Species, &mut SoulBreath, &mut AxiomEffects, &mut Animator<Transform>, &mut Position, Has<RealityAnchor>)>, Query<&Position>, Query<&Species>, Query<&SoulBreath>, Query<(&Position, &Transform), With<RealityAnchor>>)> for breakfast, por favor. Keep the change.

Bevy Queries are black magic. Given thousands of entities with multiple components, they will somehow fetch every single one that possess only the ones you want in record time, once every frame. This somehow does not turn my 8 GB of RAM computer into a localized micro-Sun, and is quite efficient. I probably should devote some time to understanding at least 10% of the process of how this is done.

However, this is still Rust, and as the commandments go, Thou shall not access a mutable and an immutable reference to the same object simultaneously. This causes issues.

One of the Systems in The Games Foxes Play is tracking the spells cast by creatures and executing their effects. It looks roughly like this:

/*
 We obtain the entity on which the spell is getting cast, 
 what the function of the spell is, 
 and what info we can get from the creature that casted it.
*/
let (entity, function, mut info) = world_map.targeted_axioms.pop().unwrap(); 
/* 
 We grab a bunch of mutable fields and
 get ready to modify the targeted creature's data. 
*/
if let Ok((transform_source, mut species, mut breath, 
	mut effects, mut anim, mut pos, is_player)) = creatures.p0().get_mut(entity.to_owned()) { 
	match function {
	    Function::Teleport { x, y } => {
	    	//SNIP
	    	// The creature has been teleported, 
	    	// edit the "x" and "y" fields on its Position component.
	    	(pos.x, pos.y) = (x, y); 
	    	//SNIP
	    }
    	Function::ApplyEffect { effect } => {
    		//SNIP
    		 // The creature has received a status effect, 
    		 // edit the "status" field on its AxiomEffects component.
		    effects.status.push(effect);
		    //SNIP
		},
	// LOTS, and I mean LOTS, of other spell effects

As you can see, I require accessing all those fields to manage all these possible spell effects. That is the Query<(&Transform, &mut Species, &mut SoulBreath, &mut AxiomEffects, &mut Animator<Transform>, &mut Position, Has<RealityAnchor>)> part of the giga-type I posted at the summit of this chapter. That is creature.p0().

But, let's say that we want to only grab an entity's Position or Species component. No modifications, no mutability, we simply want to know where a creature is currently located in terms of "x" and "y" coordinates, or what species it belongs to.

Function::Collide { with } => { // "with" is the entity being collided with.
    let coll_species = creatures.p2().get(with).unwrap().clone();
    let coll_pos = creatures.p1().get(with).map(|e| (e.x, e.y)).unwrap();

We cannot reuse creatures.p0() as it is currently in use by get_mut. Its ownership is unavailable, and creating a new immutable reference to Position while it is currently mutably borrowed by creatures.p0() (in order to mutate coordinates to fulfill Teleport) is impossible. We must use Bevy's ParamSet type, designed to handle these conflicts.

Even in a situation where I could re-use creatures, I wouldn't necessarily need all those fields but would still need to call every single one, resulting in tons of unused variables:

let stem_block = if let Ok((entity, _queue, _species, _effects,
	_breath, pos, _is_player)) = creatures.get(*segment) {

And that is the source of this extreme bloat. This entire spell handling system thus has the following function fields:

fn dispense_functions(
    mut creatures: ParamSet<(
        Query<(&Transform, &mut Species, &mut SoulBreath, &mut AxiomEffects, 
        	&mut Animator<Transform>, &mut Position, Has<RealityAnchor>)>,
        Query<&Position>,
        Query<&Species>,
        Query<&SoulBreath>,
        Query<(&Position, &Transform), With<RealityAnchor>>,
    )>,
    mut plant: Query<&mut Plant>,
    faction: Query<&Faction>,
    check_wound: Query<Entity, With<Wounded>>,
    mut next_state: ResMut<NextState<TurnState>>,
    mut world_map: ResMut<WorldMap>,
    mut souls: Query<(&mut Animator<Transform>, &Transform, 
    	&mut TextureAtlasSprite, &mut Soul), Without<Position>>,
    ui_center: Res<CenterOfWheel>,
    time: Res<SoulRotationTimer>,
    mut events: EventWriter<LogMessage>,
    mut zoom: ResMut<ZoomInEffect>,
    mut commands: Commands,
    mut current_crea_display: ResMut<CurrentEntityInUI>,
    texture_atlas_handle: Res<SpriteSheetHandle>,

){

Rust's Clippy (an assistant that tries to improve your code) is NOT a fan. Not only is this massively bulky, this forces me to keep my code contained inside this single system. It is very hard to outsource work to other functions, because passing around data is a nightmare. I need to unpack components into relevant fields, call the function, get an output, and then mutate the fields with that output, back inside the giga-system.

For example, if I want to pass around Position to do some calculations and mutate it, I can't just have a function that accepts &mut Position. The actual Bevy type is Mut<'_, Position>. It needs to be turned into fields "x" and "y". Only then can I pass those numbers to a "calculate_pathfinding" function, which returns numbers, and allows me to edit Bevy Components back inside the System.

As a result, that dispense_functions System is currently at 594 lines of code, and will keep growing until it swallows the universe.

I find it quite peculiar how Bevy demos focus very highly on presentation and little on gameplay. Art is obviously a huge part of game development, but reading changelogs show there is a bias towards talking about the latest shader and high performance wgpu computing improvements.

Spawning a transparent 3D glass sphere in Bevy that distorts your vision when you gaze through is now well-supported. But games in the vein of "Dwarf Fortress" or "Zachtronics" with a large quantity of interactions feel much, much harder to do in Bevy due to how much data needs to be passed around.

Bevy Is Not "Rusteic"

"Getting it to compile will be difficult. But when it does compile, it will usually do everything you expected it to." - Rust Cultists

I am proud to say I have never once used a classical runtime debugger with breakpoints in Rust. I never felt like I needed one. The compiler (and its trusty watchman Rust-Analyzer) judge my every move, like they are a team of senior engineers with decades of experience hovering over my shoulders and slowly drooling on the floor, waiting for the moment I make a mistake to bark out at me.

"THAT VARIABLE DOESN'T LIVE LONG ENOUGH!!"

As a former JavaScript plebeian who has only been semi-recently illuminated by the suspiciously pastel pink, white and blue radiance of Rust developers, NOT having to sit in my web console debugger for hours pushing some lovingly crafted [object Object] or undefined is a blessing.

But Bevy, despite being built in Rust, does not offer such guarantees, and I find myself constantly reaching for those dbg! macros I thought I could mostly forget.

  1. There is the lack of warnings. In the previous chapter, I presented some particularly bloated Queries - well, I occasionally find one with some unneeded Without filters. The compiler, of course, is blind to such things.

  2. There is also the grave matter of System Scheduling. You see, without a precise order being given to them, Bevy systems run in complete chaos, and there is no guarantee that they will always continue their execution in the same order. It took me a little while to figure this out, and I now carefully specify game states to ensure Systems are running when they should:

impl Plugin for TurnPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(Update, calculate_actions
        	.run_if(in_state(TurnState::CalculatingResponse)));
        app.add_systems(Update, execute_turn
        	.run_if(in_state(TurnState::ExecutingTurn)));
        app.add_systems(Update, dispense_functions
        	.run_if(in_state(TurnState::DispensingFunctions)));
        app.add_systems(Update, unpack_animations
        	.run_if(in_state(TurnState::UnpackingAnimation)));
        app.add_systems(Update, fade_effects);
        app.insert_resource(TurnCount{turns: 0});
    }
}

However, unscheduled systems can be very stealthy in how they induce bugs. A single oversight can mean your game works correctly 99% of the time, then suddenly has unexpected behaviour in a very specific situation because a System outsped another when it was least predictable.

  1. Another feature is the extremely bloated standard "Bevy library". Yes, Bevy is divided into multiple sub-crates (like "bevy-ui"), but most developers will simply import the whole thing. This makes compilation times quite severe (my 2016 glorified calculator laptop is incapable of even finishing the compilation process). It is possible to activate "dynamic linking" for a mild speedup:

bevy = { version = "0.13", features = ["dynamic_linking"]}

but, you'll need to REMEMBER to remove this when releasing your game, or you'll need to bundle some obscure module libbevy_dylib alongside your game to ensure it works at all. Fun.

Yes, it's just 8-12 seconds, but when trying to design an UI and reloading every so often to see how it looks, it adds up. My Rust mentor is developing her own Rust game in SDL, and compiling that is more in the realm of a single second.

There is an ocean of features, structs and functions in Bevy you won't ever use if you are merely making a 2D game. It is meant to be a general purpose game engine and a competitor to the likes of Unity, and it shows.

Conclusion

Bevy is an amazing piece of technology that isn't ready yet.

I often end up feeling like I don't actually "own" every part of my game when using Bevy, which is what makes Rust so fun for me. When using Generic JavaScript Framework #28375, I don't feel like a programmer, I feel like a clueless child who has found black boxes of ancient alien technology and a tube of superglue to stitch them together. This is naturally a requirement in some giga-enterprise codebase - you can't understand the whole thing top to bottom - but when it comes to a personal gamedev project, I'd prefer having at least an idea of what is going on behind the scenes.

When reading Bevy changelogs, I constantly think "if only that feature was around when I was developing THAT system in my game!"

Bevy will be a great thing eventually that will challenge the statu quo of the gamedev sphere.

But, in my opinion, that time has not yet come.

If you enjoyed this writeup, feel free to contact me! I am currently looking for:

  • Summer internships
  • Full time positions after April 2025
  • People to join my Northsec team - no large proficiency in security required, but the drive to learn about technology is mandatory

Discord: oneirical

Email: julien-robert@videotron.ca