First Impressions of Bevy (Rust game engine)

Oct 1, 2025
13 min read

This post will be done using NixOS. From my experience, most Rust crates work out of the box in NIx, but there are some audio libraries that I had to create a flake.nix for the project to compile.

Repo

Get the git repo here

Why Try Bevy?

Bevy 0.17.1 released recently as of October 2025, and I suppose curiosity got the best of me. Though I have used most of the popular game engines out there, I've always opted to use more of a homegrown engine to create games. As much as I enjoy going lower level, the appeal of function calls like meshes.add(Circle::new(50.0)) definitely helps game developers not get bogged down into the irrelevant implementations that might not matter much to them.

Another important note is that Bevy has no GUI that drives the core engine code. If you ever used a game engine like Unity or Unreal Engine, many of the functionality is hidden away in GUI buttons. An "Import Asset" button in those game engines might consists of going through the file system, parsing the contents, serializing it, caching it, interpret it, etc. in a single easy to understand button. Though Bevy might do this in a single function, I'm curious to see how intuitive the API will be.

There are some really impressive games, such as Tiny Glade, made using Bevy. Though I suspect that certain graphic algorithms like Marching Cubes isn't supported on Bevy right out of the box. But nonetheless, Bevy is more than a capable engine to develop games with.

Despite that, a detailed article from LogLog Games discussed in length about the short comings of using Rust for game development. Rust prides itself on having correct, safe code. This often goes against the "quick and dirty" method of writing code that game developers might favor.

In game development, having the agility to quicky change the behavior of the game quickly is a huge benefit. Many game frameworks utilize scripting language for the actual game code so the virtual machine can figure out the details rather than a possibly long compiler doing it. Some engines even favor GUI tools to drive various states of a game quickly, as well. In other words, I am more concerned about getting a game to feel good, without having to do refactoring jus to satisfy the borrow checker.

Another pain point I suspect I'll face is compilation speeds. Rust has a slow compiler, and that has driven projects away from Rust into other languages. Even Bevy's own documentation suggests to enable some minor optimizations in order to not have a completely slow compiles.

That said, I am excited to try Bevy.

Learning Sources

I always prefer written documentation for learning, so I will be using the following as primary source:


Solo Pong

Creating a bare minimum game is always a good way to get familiar with some framework, so I will borrow an example I learned while I was at university.

Pong is a good demonstrating of using inputs, shapes, collisions and game states tightly packaged in a tiny easy to understand game. In other words, we won't get too fancy with anything yet.

Here is a small sketch of what I am envisioning. ![[Frame 3.png]]

Creating an App

The App seems standard in terms of providing the entry point for a Bevy program. This snippet code does not do anything yet, but it provides a foundation.

use bevy::prelude::*;

fn main() {
    App::new()
	    .run();
}

Systems

Bevy uses Entity Component System(ECS), which is a data centric way to represent the behavior. It is perhaps more natural to represent some game object, such as a hero character, in an object component structure. In other words, each object contains some sort of component that defines itself. ECS are a great tool to prevent cache misses.

For example, a hero object will likely contain a physics component so that it can react property to the environment. When we have many objects in our game scene, the cache will not be utilized as efficiently in an object component system. The cache line is small, so once you pull the object from memory, it is not guaranteed where in memory the data is even at. The CPU will of course find everything it needs on its own, but we can definitely guide the CPU so it can get the data quicker with less work.

In ECS, we think more about how pure data can shape this same procedure. We have an Entity id, which is usually just u64. The Component is a struct that is plain old data, and the System is pure logic that runs on some Entity that has the necessary Components. It will be much more cache friendly if the CPU can pull in all position struct's and perform a side effect with it.

EntityPositionHealth
hero (ID: 1){x: 10, y: 20}{hp: 100}
enemy (ID: 2){x: 50, y: 80}{hp: 40}

A system will query whatever component it needs and act upon it. If we have a heal_enemy system, we can just query all health components that belong to the enemy and very quickly do a hp += 10. So creating a system to print a single line and calling the add_systems function from our app will now get us somewhere.

use bevy::prelude::*;

fn main(){
	App::new()
		.add_systems(Update, hello_world)
		.run();
}
fn hello_world(){
	println!("hello");
}

Plugins

Bevy uses the concept of Plugins for modularity. Every game has different requirements, so it makes to pick and choose whatever that is needed. For example, if a game for some reason does not need graphics, then we do not need the RenderPlugin. Bevy provides DefaultPlugins to get up and started quickly, and provides many of the functionalities of a game. By adding that plugin, we can start to have a window.

use bevy::prelude::*;

fn main(){
	App::new()
		.add_systems(Update, hello_world)
		.add_plugins(DefaultPlugins)
		.run();
}
fn hello_world(){
	println!("hello");
}

Dependency Injection

Right away, it is clear that Bevy favors using dependency injection like most ECS frameworks. This article gives into more detail regarding building one from scratch in Rust.

For those unfamiliar, dependency injection is a design pattern where an object's dependencies are provided to you.

Milestone 1 - A Window

I inserted a background color and just renamed the print_hello to setup and added in some parameters we will need in the future.

Another important note is the use of Startup instead of Update. I want the setup system to only run once upon initialization, and only use Update when we need some system to run once per frame. Later on, having some input system using Update is needed since we want to calculate our keyboards for each frame that is being drawn onto our game scene.

use bevy::prelude::*;

fn main(){
	App::new()
		.insert_resource(ClearColor(Color::srgb(0.5, 0.5, 0.5)))
		.add_systems(Startup, setup)
		.add_plugins(DefaultPlugins)
		.run();
}
fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
    window: Query<&Window>,
) {
	//todo
}

From here on out, I will give a more general look into creating the Pong game, and share some insight I learned when going through the API at each step.


Drawing the Shapes

The paddle will only operate in the Y direction. From quick tests, it appears Bevy uses Normalized Device Coordinates(NDC), where 0,0 is at the center of the screen, and the x,y coordinates range from -1 to 1. I suspect any shapes created, like rectangles will behave the same.

This will be important since our walls will be created according to our window dimensions. The walls will also have a certain thickness as well. For example, our top wall that sits horizontally at the top of our screen will have to stretch out to the window's width, and sit as a border of the top part of the window. In NDC, the most visible pixel will be height / 2.0, which then has to be offset in the negative wall_thickness / 2.0 so nothing gets cut off. We are working in halves due to NDC.

I am doing most of this in the setup system where we ask the command to spawn a Mesh, which is our paddle, rectangle walls, and ball. Bevy uses wgpu as the core rendering framework, which like all modern graphics API's, will use the command list to render all objects.

If you want to support redrawing the shapes when the window resizes, just add another function to do similar logic as the setup, but use the EventReader<WindowResized> to get the event parameters for the new width and height.

Adding an Input System

The input system is straight forward ButtonInput<KeyCode> that you add as a Res resource. Adding it to the parameter of the paddle_move_system, and just using the pressed function is trivial.

fn paddle_move_system(
    input: Res<ButtonInput<KeyCode>>,
    time: Res<Time>,
    mut paddle: Query<(&mut Transform, &Paddle)>,
) {
    for (mut transform, paddle) in paddle.iter_mut() {
        // move paddle up
        if input.pressed(KeyCode::KeyW) {
            transform.translation.y += paddle.speed * time.delta_secs();
        }
        // move paddle down
        if input.pressed(KeyCode::KeyS) {
            transform.translation.y -= paddle.speed * time.delta_secs();
        }
    }
}

In games, we use delta time to have a consistent movement of game objects during the rendering phase of a game. If we don't add this, than the speed of game objects will be dependent on how fast your machine is as you will generate frames a lot faster.

The code above will let the paddle go offscreen and colliding with the walls, which we don't want. We need to get the window size for our move system, so adding the window as a parameter like in the setup system.

We need to do some simple math so the paddle doesn't collide with the walls. But the key part is getting that window size data in paddle_move_system to determine it.

Ball Movement and Respawns

The ball will spawn initially at start up, give a few seconds for the player to get ready, than will travel in a random direction. It will respawn once it detects itself going off screen.

We need the rand crate for this. Also, a tiny bit of trigonometry.

I will make the first ball spawn using the setup function first, than maybe another system that watches if the ball goes out of frame. There's probably a better way of doing this by combining all the ball logic into a single function but perhaps that's for another time.

Anyway, the ball struct now needs a velocity vec2 parameter for its vector direction. We are also using the rand crate, specifically the thread local thread_rng() call.

We just lock the ranges for the angle between the non-vertical "safe zones". Even though we are locking the ranges on the right side of a unit circle, we can get the direction by just doing a coin flip of -1 or 1 for the direction. Create a new Vec2 with a polar to coordinate conversion and spawn the mesh.

let angle: f32 = rng.gen_range(-45.0..45.0);
let direction = if rng.gen_bool(0.5) { 1.0 } else { -1.0 };

For the ball respawn, I made another function that does the exact same logic as the setup, but just added addition checks against if the current ball is current off screen. The checks are a simple comparison on if the ball coordinates are greater or lesser then the window width and heights, depending on which quadrant the ball is currently at.

Collisions

This example can get away with a simple comparison if the ball size is intersecting with the wall position, but I will try a more standard way of doing collision by using Axis Aligned Bounding Box, which Bevy supports.

We need to add a collider component, and add it where we spawn all the game objects in the setup.

#[derive(Component)]
struct Collider{
	size: Vec2,
}
///code

The collider Vec2 itself will be done using half extents, basically means that we are using the center local point of the mesh and thinking of the calculation in terms of halves, as you are already centered. In our case, the top wall has a Rectangle::new(width, wall_thickness) but a collider of Vec2::new(width/2.0, wall_thickness/2.0).

This articlewill explain it much better than I can, but the short answer is if the horizontal and vertical edges of the collider shape overlaps with another collider shape, that is a collision.

In the ball_collision_system function, I create new Aabb2d instances from the transform positions and collider sizes each frame. The collider components themselves persist on the entities, but we need the data to create those collision structures.

Afterwards, for each wall and paddle, you just need to call the intersects method. When a collision occurs, the behavior differs depending on the wall or paddle.

For the paddle, we want the ball to always bounce to the right, so we just set the x velocity to the absolute value, and the y based on where it hit.

For the wall, we have to negate as well for t he x or y velocity, depending on which of the wall sides the ball hit.

Conclusion

Switching from a object oriented approach, which many games still use, to an ECS approach does come at an engineering cost. I would like to investigate further on how the architecture might look in a Bevy game if we were all in and created a fully feature complete game. The amount of systems seems significant and I worry about complexity on all the moving parts regarding the engine.

A positive note though is that the Bevy API is quite clean, in my opinion. I enjoy just working on the system logic and have the engine handle all the queries I need. Everything feels dynamic and modular, and I can definitely see myself working more in Bevy on a larger project.

Regarding using a virtual machine for development, I would say you shouldn't use NixOS for game development, it is way too slow. I would imagine other distros will run into the same issues. I believe Bevy defaulted to software rendering for my game, and it ran very choppy.

It doesn't appear that Bevy (as of October 2025) has a first party physics or particle library, which I intend to use for my next Bevy project, but there are third party ones available.

In all, this was a naive approach and first hand take on using Bevy for the first time and coming away with a tiny game out of it. It all came out to around 300 lines of code, which is reasonable. The community is active, and the resources are plenty to learn from.

Since Bevy is still in an alpha stage, each release will come with breaking changes, which might discourage developers. I hope to see Bevy get more popular as it is a fun game engine to work with, but there are some challenges it will have to overcome most likely to gain more mainstream popularity. I always see on social media how they wish Bevy has an editor GUI and also how Rust is not the most beginner friendly language to work with, but overtime, I can see Bevy thriving.

rust