The frame drop that shouldn't happen
You spent three weeks building a platformer in Python. The physics feel tight. The art looks polished. You add fifty enemies to the screen. The frame rate drops from sixty to twelve. You open the profiler and see the CPU stuck in allocation and deallocation, not in your game logic. The garbage collector is pausing your game loop every few seconds to clean up memory you thought was gone.
Rust fixes this. Game development in Rust trades the convenience of automatic memory management for predictable performance. There is no garbage collector to interrupt your frame loop. You control exactly when memory is allocated and freed. This gives you the speed of C++ with safety guarantees that prevent buffer overflows, use-after-free bugs, and data races. You get high performance without sacrificing the ability to reason about your code.
Memory safety without the pause
In managed languages, the runtime decides when to reclaim memory. That decision happens at the worst possible time. A frame takes too long. The game stutters. In Rust, memory is reclaimed deterministically. When a value goes out of scope, it is dropped immediately. When you explicitly free a resource, it is gone. This determinism is essential for games where every millisecond of frame time matters.
The borrow checker enforces this safety at compile time. It ensures that you never have two mutable references to the same data, and you never access data after it has been freed. This eliminates entire classes of bugs that plague C++ game engines. You don't need to hunt down dangling pointers at 3 AM. The compiler rejects the code before it runs.
The trade-off is a steeper learning curve. You have to think about ownership and lifetimes. Game state management requires a shift in mental model. The ecosystem has adapted to this by embracing data-oriented design. The dominant pattern is the Entity Component System.
The spreadsheet engine
Traditional game engines often rely on deep class hierarchies. A Player inherits from Character, which inherits from Entity. Adding a new ability requires modifying base classes, which risks breaking unrelated code. Rust engines use a composition model. You have entities, which are just IDs. You attach components, which are plain data structs. Systems read and write components to implement behavior.
Think of it like a spreadsheet. Entities are rows. Components are columns. A system is a formula that operates on specific columns. You can add a "Flying" column to any entity without touching the "Walking" column. This structure is cache-friendly and scales well. It also plays nicely with Rust's ownership rules. Systems borrow data from the entity store. The borrow checker ensures that systems don't conflict with each other.
The ecosystem centers around this pattern. Bevy is the most active engine. It is built entirely around ECS. Other engines like ggez offer a more traditional API, but the ECS approach is the standard for new Rust game projects.
Minimal game loop
Bevy abstracts the boilerplate of windowing, rendering, and input. You define your game as a collection of systems and resources. Here is a minimal application that sets up the engine and registers a system.
use bevy::prelude::*;
/// Marker component to identify player entities.
#[derive(Component)]
struct Player;
fn main() {
// Create the application and add default plugins for windowing, rendering, and input.
App::new()
.add_plugins(DefaultPlugins)
// Register the system to run every frame during the Update stage.
.add_systems(Update, move_player)
.run();
}
/// System that moves entities with the Player component.
fn move_player(mut query: Query<&mut Transform, With<Player>>) {
// Iterate over all entities that have a Transform and a Player component.
for mut transform in query.iter_mut() {
// Move the entity one unit to the right each frame.
transform.translation.x += 1.0;
}
}
The App::new() call creates the game loop. add_plugins(DefaultPlugins) sets up the window, renderer, audio, and input handling. add_systems registers logic to run during the update phase. The move_player function is a system. It takes a Query to fetch data. The query filters for entities that have both a Transform and a Player component. The system mutates the Transform to update the position.
How the loop works
When you call run(), Bevy starts the main loop. Each frame, it executes systems in the order they were added. The Update stage runs after input is processed but before rendering. Systems run sequentially by default, but Bevy can schedule them in parallel if they don't conflict.
The Query is the bridge between systems and data. It borrows components from the entity store. The type signature Query<&mut Transform, With<Player>> tells the compiler that this system needs mutable access to Transform and only for entities with Player. The borrow checker verifies that no other system is accessing the same data in a conflicting way. If two systems try to mutate the same component, the compiler rejects the code.
This verification happens at compile time. You get safety without runtime overhead. The query iteration is fast. Bevy uses archetype storage to keep component data contiguous in memory. This improves cache locality and speeds up iteration.
Realistic state management
Real games need shared state. Configuration, score, or global settings are often stored as resources. Resources are singletons that systems can read or write. Here is a system that updates velocity based on input and a configuration resource.
use bevy::prelude::*;
/// Component storing entity velocity.
#[derive(Component)]
struct Velocity {
x: f32,
y: f32,
}
/// Global game configuration.
#[derive(Resource)]
struct GameConfig {
speed: f32,
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
// Insert the resource into the world.
.insert_resource(GameConfig { speed: 5.0 })
.add_systems(Update, update_velocity)
.run();
}
/// System that applies input to velocity using global config.
fn update_velocity(
mut query: Query<&mut Velocity>,
config: Res<GameConfig>,
keys: Res<Input<KeyCode>>,
) {
// Read the speed from the immutable resource.
let speed = config.speed;
// Iterate over all entities with Velocity.
for mut vel in query.iter_mut() {
// Check input and update velocity.
if keys.pressed(KeyCode::KeyD) {
vel.x += speed;
}
if keys.pressed(KeyCode::KeyA) {
vel.x -= speed;
}
}
}
The Res<GameConfig> argument gives the system read access to the resource. The Res<Input<KeyCode>> provides the current input state. The system mutates the Velocity component. The borrow checker ensures that config is read-only and vel is mutable. If you tried to mutate GameConfig in the same system, you would need ResMut<GameConfig>, and the compiler would check for conflicts with other systems.
Convention aside: always use cargo run --release to test performance. Debug builds include extra checks and lack optimizations. Frame times in debug mode are not representative of the final game. Release builds strip debug info and enable aggressive optimization.
Pitfalls and compiler fights
The borrow checker can be strict. Game logic often requires reading and writing related data. If you try to mutate a component while holding an immutable reference to it, the compiler rejects the code with E0502 (cannot borrow as mutable because it is also borrowed as immutable). This happens when a system tries to access the same entity through multiple queries or when you split a query into separate calls.
The solution is to structure queries carefully. Use a single query with multiple components if you need to read and write related data. Or use Query filters to separate concerns. If you need to mutate a resource and query entities, ensure the resource mutation happens in a separate system or use Commands to schedule changes.
Compile times can be long. Rust compiles slowly, especially with large dependency graphs. Game projects often pull in many crates. Incremental compilation helps, but full rebuilds can take minutes. Use Why Is Rust Slow to Compile and What Can Be Done About It? for strategies to reduce build times. Profile your code with How to profile Rust code to find bottlenecks.
Another pitfall is overusing Box<T>. Heap allocation is slower than stack allocation. In game loops, avoid allocating boxes every frame. Reuse memory with pools or fixed-size arrays. The borrow checker encourages stack allocation and borrowing. Reach for Box<T> only when you need dynamic dispatch or the size is unknown at compile time.
Treat the borrow checker as a design constraint. It forces you to structure data in ways that are safe and efficient. The code that compiles is usually the code that performs well.
Choosing your stack
The Rust game ecosystem offers different tools for different needs. Pick the engine that matches your project requirements and mental model.
Use Bevy when you want a data-driven ECS engine with a large community and active development. Bevy is the standard for new Rust games. It handles rendering, audio, input, and state management. The ECS model scales well and integrates with Rust's safety features.
Use ggez when you are building a simple 2D game and prefer a traditional object-oriented API. ggez provides immediate mode rendering and a straightforward game loop. It is easier to learn for developers coming from Python or JS. The API is less flexible than Bevy but requires less boilerplate.
Reach for a custom engine when you need extreme control over the rendering pipeline or are building a specialized tool. Writing your own engine gives you full control over memory layout and system scheduling. This is only worth it for experienced developers or projects with unique requirements.
Use wgpu directly when you are writing a graphics library or need low-level GPU access without an engine wrapper. wgpu is the underlying graphics API for Bevy and other engines. It provides safe access to Vulkan, Metal, and DirectX. Use it only if you need to build rendering abstractions yourself.
Pick the tool that matches your mental model, then learn the model the tool enforces. Bevy rewards ECS thinking. ggez rewards imperative thinking. Custom engines reward systems thinking.