Rust Game Development: Bevy, Macroquad and More
Lots of people learn Rust because they want to make a game. That’s a great reason! Games need speed, careful memory use and code that doesn’t crash in the middle of a boss fight, and those are exactly the things Rust is good at.
This page gives you an honest picture: what people already build with Rust, which engine or library to pick, and the two ideas almost every game uses, the game loop and ECS. Then you’ll see a first program in Bevy and in Macroquad.
Which examples run where? The game loop, the tiny ECS world and the practice exercise are plain Rust, so they have a “Try it Yourself” button for the Rust Playground. Bevy and Macroquad open a window, so those examples are for your own computer with cargo run. The Macroquad example was compiled (also for the web) while writing this page; the Bevy example follows Bevy’s official examples.
The honest state of Rust game development
Section titled “The honest state of Rust game development”The community site Are We Game Yet? tracks the Rust game ecosystem. Its answer to its own question is: “Almost. We have the blocks, bring your own glue.” That’s a fair summary.
What’s good today:
- Real games ship. Tiny Glade, a cozy castle-building game on Steam, and Veloren, an open-source voxel RPG, are both listed on Are We Game Yet as Rust games.
- A strong engine. Bevy has a large, active community and a new release every few months.
- Lots of building blocks. Are We Game Yet lists crates for physics, audio, input, networking, UI and more.
- Your game can run on the web. Rust compiles to WebAssembly, so many Rust games run in a browser too.
What’s still hard:
- No “Unity of Rust” yet. Most Rust engines are code-first. Visual editors exist (Fyrox has one, and Godot can use Rust), but they’re younger than the big commercial tools.
- Things change. Bevy is still before 1.0, and each release has breaking changes. A tutorial from last year may not compile anymore.
- Compile times. Big engines take minutes for the first build. You’ll learn the tricks for this below.
If your goal is to ship a commercial 3D game next year with a team of artists, Unity, Unreal or Godot are safer. If you want to learn, make 2D games, game jams, tools or simulations, or you simply enjoy Rust, it’s a very fun place to be. 🦀
Which engine or library?
Section titled “Which engine or library?”There are two kinds of tools. An engine gives you a whole structure: how game objects, scenes and assets work. A library (or framework) gives you a window, drawing, sound and input, and you organize the rest yourself.
| Crate | What it is | Good for | Things to know |
|---|---|---|---|
bevy (0.20) |
A full 2D and 3D engine built around ECS | Bigger projects, 3D, learning how modern engines work | The best-known Rust engine. Breaking changes every release; long first build |
macroquad (0.4) |
A simple 2D library, inspired by raylib | Your first game, game jams, web games | Very few dependencies, builds fast. You call drawing functions in a loop |
ggez (0.10) |
A lightweight 2D framework, inspired by LÖVE | 2D games with a clear structure | You implement an EventHandler trait with update and draw |
fyrox (1.0) |
A 2D and 3D engine with a scene editor | People who want an editor, like in Godot or Unity | Reached version 1.0 in 2026. Smaller community than Bevy |
raylib (6.0) |
Rust bindings to the C library raylib | People who know raylib from C | Needs CMake and C build tools, since it builds the C library (see FFI) |
godot (0.5, “gdext”) |
Rust bindings for the Godot 4 engine | Using Godot’s editor with game logic in Rust | You write Rust classes that Godot loads as a GDExtension |
A simple rule: start with Macroquad for your first 2D game. Pick Bevy when you want a full engine and you’re happy to learn ECS. Pick Godot with gdext or Fyrox if you want an editor to place things visually.
The game loop
Section titled “The game loop”Almost every game is one big loop that runs again and again until you quit:
- Read input (keys, mouse, controller).
- Update the game: move things, check collisions, count points.
- Draw the new picture on the screen.
One loop is called a frame. At 60 frames per second (FPS), each frame has about 16 milliseconds. But frames don’t all take the same time. Your computer might be busy, or one frame might need to load a file.
If your game moves the player “2 units every frame”, it runs faster on a fast computer and slower on a slow one. A popular fix is a fixed timestep: the game logic always moves forward in equal steps (here 10 ms), and each frame runs as many steps as fit into the time that passed. Leftover time waits in an accumulator until the next frame.
const STEP_MS: u32 = 10; // the game logic always moves forward 10 ms at a timeconst SPEED: i32 = 2; // the player moves 2 units per update
fn main() { // How long each frame really took. A real game measures this with a clock. let frame_times = [16, 16, 45, 5, 16, 33];
let mut accumulator = 0; // time that has passed but isn't simulated yet let mut position = 0;
for (frame, elapsed) in frame_times.into_iter().enumerate() { accumulator += elapsed;
// Run as many fixed steps as fit into the time we have let mut updates = 0; while accumulator >= STEP_MS { position += SPEED; // update(): game logic accumulator -= STEP_MS; updates += 1; }
// render(): draw once per frame, whatever happened println!("frame {frame}: {elapsed:>2} ms, {updates} update(s), player at {position:>2}, {accumulator} ms left over"); }}Output:
frame 0: 16 ms, 1 update(s), player at 2, 6 ms left overframe 1: 16 ms, 2 update(s), player at 6, 2 ms left overframe 2: 45 ms, 4 update(s), player at 14, 7 ms left overframe 3: 5 ms, 1 update(s), player at 16, 2 ms left overframe 4: 16 ms, 1 update(s), player at 18, 8 ms left overframe 5: 33 ms, 4 update(s), player at 26, 1 ms left overLook at frame 2: it was slow (45 ms), so the game ran 4 updates to catch up. In total 131 ms passed, and the game ran 13 updates of 10 ms. The player ends at 26 no matter how the time was split into frames. That makes physics stable and the same on every computer, which also matters for replays and online games. One danger: if a frame is very slow, the game runs many updates to catch up, which makes the next frame slow too. So real loops also cap the number of updates per frame (Bevy’s game clock, for example, moves at most 250 ms per frame by default).
The other common way is delta time: you measure the seconds since the last frame and multiply speeds by it (x += speed * dt). It’s simpler, and it’s what the Bevy and Macroquad examples below do. Many engines use both: delta time for animation, a fixed step for physics.
What ECS is: entities, components, systems
Section titled “What ECS is: entities, components, systems”In many languages you’d make a Player class, an Enemy class that inherits from Character, and so on. That gets messy fast: what about an enemy that can also be picked up? Most Rust game engines use ECS (Entity Component System) instead:
- An entity is just an ID, like a number. It has no data and no behavior of its own.
- A component is a piece of plain data you attach to an entity:
Position,Velocity,Health. In Rust these are normal structs or enums. - A system is a function that runs on every entity that has certain components. A movement system, for example, works on everything that has both a
Positionand aVelocity.
A tree has a Position but no Velocity, so the movement system skips it. Want the tree to move? Give it a Velocity. You don’t change any class. Data and behavior stay separate, and this also fits Rust’s borrowing rules well.
Here is a tiny ECS world in plain Rust. Each component type lives in its own vector, and the entity ID is the index. None means “this entity doesn’t have that component” (see Option).
struct Position { x: i32, y: i32 }struct Velocity { dx: i32, dy: i32 }
// An entity is just an ID: an index into every component listtype Entity = usize;
#[derive(Default)]struct World { names: Vec<&'static str>, positions: Vec<Option<Position>>, velocities: Vec<Option<Velocity>>,}
impl World { fn spawn(&mut self, name: &'static str, pos: Option<Position>, vel: Option<Velocity>) -> Entity { self.names.push(name); self.positions.push(pos); self.velocities.push(vel); self.names.len() - 1 }}
// A system: works on every entity that has BOTH a Position and a Velocityfn movement_system(world: &mut World) { for (pos, vel) in world.positions.iter_mut().zip(&world.velocities) { if let (Some(pos), Some(vel)) = (pos, vel) { pos.x += vel.dx; pos.y += vel.dy; } }}
// Another system: prints every entity that has a Positionfn print_system(world: &World, tick: u32) { println!("tick {tick}:"); for (id, pos) in world.positions.iter().enumerate() { if let Some(pos) = pos { println!(" #{id} {:<6} at ({}, {})", world.names[id], pos.x, pos.y); } }}
fn main() { let mut world = World::default(); world.spawn("player", Some(Position { x: 0, y: 0 }), Some(Velocity { dx: 1, dy: 0 })); world.spawn("tree", Some(Position { x: 5, y: 2 }), None); // no Velocity: it never moves world.spawn("arrow", Some(Position { x: 0, y: 3 }), Some(Velocity { dx: 3, dy: -1 }));
for tick in 1..=2 { movement_system(&mut world); print_system(&world, tick); }}Output:
tick 1: #0 player at (1, 0) #1 tree at (5, 2) #2 arrow at (3, 2)tick 2: #0 player at (2, 0) #1 tree at (5, 2) #2 arrow at (6, 1)The tree stays at (5, 2) because it has no Velocity. Nobody wrote “trees don’t move”; it simply follows from the data. Real ECS libraries are much faster and smarter than this (they store components so the CPU can read them quickly, and run systems in parallel), but the idea is the same.
Your first Bevy app
Section titled “Your first Bevy app”Bevy is the best-known Rust engine, and everything in it is ECS. Version 0.20 came out in October 2026. Bevy needs the latest stable Rust, so update first, then create a project:
rustup updatecargo new my_bevy_gamecd my_bevy_gamecargo add bevyOn Linux you also need a few system libraries (sound, input, graphics). Bevy’s setup guide lists them for each distribution. On Windows you need the Visual Studio C++ Build Tools, and on macOS the Xcode command line tools.
This program is based on Bevy’s official 2d/move_sprite example. It draws an orange square that slides left and right. (The official example loads a picture; here a plain colored square is used, so you don’t need any image files.)
use bevy::prelude::*;
fn main() { App::new() .add_plugins(DefaultPlugins) // window, rendering, input, audio... .add_systems(Startup, setup) // runs once at the start .add_systems(Update, sprite_movement) // runs every frame .run();}
// A component: which way this entity is moving#[derive(Component)]enum Direction { Left, Right,}
fn setup(mut commands: Commands) { commands.spawn(Camera2d);
// Spawn one entity with three components commands.spawn(( Sprite::from_color(Color::srgb(1.0, 0.5, 0.2), Vec2::new(50.0, 50.0)), Transform::from_xyz(0.0, 0.0, 0.0), Direction::Right, ));}
// A system: runs on every entity that has a Direction AND a Transformfn sprite_movement(time: Res<Time>, mut sprite_position: Query<(&mut Direction, &mut Transform)>) { for (mut logo, mut transform) in &mut sprite_position { match *logo { Direction::Right => transform.translation.x += 150. * time.delta_secs(), Direction::Left => transform.translation.x -= 150. * time.delta_secs(), }
if transform.translation.x > 200. { *logo = Direction::Left; } else if transform.translation.x < -200. { *logo = Direction::Right; } }}Compare it with the tiny ECS world above. #[derive(Component)] turns a normal enum into a component. commands.spawn((...)) creates an entity with a tuple of components. And Query<(&mut Direction, &mut Transform)> asks Bevy for “every entity that has both”, just like our movement_system did with zip. time.delta_secs() is the delta time from the game loop section, so the square moves 150 pixels per second on any computer.
Run it with cargo run. The first build compiles hundreds of crates and can take several minutes, so be patient. After that, rebuilds are much faster.
Make Bevy compile (and run) faster
Section titled “Make Bevy compile (and run) faster”Bevy’s setup guide recommends two things. First, add this to Cargo.toml. It optimizes Bevy and your other dependencies, but keeps your own code quick to compile:
[dependencies]bevy = "0.20"
# Enable a small amount of optimization in the dev profile.[profile.dev]opt-level = 1
# Enable a large amount of optimization in the dev profile for dependencies.[profile.dev.package."*"]opt-level = 3Without this, a debug build of a game can be very slow: low frame rates and long loading times. Dependencies compile once, so you pay for the optimization only on the first build. (Profiles are explained on Performance.)
Second, while you’re developing, turn on the dynamic_linking feature. Bevy is then built as a separate shared library, so Cargo doesn’t have to link all of Bevy into your program again on every change:
cargo run --features bevy/dynamic_linkingTwo warnings from the Bevy guide: on Windows, dynamic_linking only works together with the opt-level settings above. And don’t ship your game with it: the game would need an extra library file next to it. Build your release without the feature. The guide also shows how to use a faster linker (lld or mold), which helps a lot on Linux.
A Macroquad window
Section titled “A Macroquad window”Macroquad feels very different. There’s no ECS and no app builder: you write the game loop yourself, and you draw with simple functions like draw_circle. It has very few dependencies, so a fresh build takes seconds, not minutes.
cargo new move_the_ballcd move_the_ballcargo add macroquad[dependencies]macroquad = "0.4"This little game shows a ball that you move with the arrow keys:
use macroquad::prelude::*;
#[macroquad::main("Move the Ball")]async fn main() { let mut player = vec2(screen_width() / 2.0, screen_height() / 2.0); let speed = 250.0; // pixels per second
loop { // 1. Input: arrow keys move the player let dt = get_frame_time(); // seconds since the last frame if is_key_down(KeyCode::Right) { player.x += speed * dt; } if is_key_down(KeyCode::Left) { player.x -= speed * dt; } if is_key_down(KeyCode::Down) { player.y += speed * dt; } if is_key_down(KeyCode::Up) { player.y -= speed * dt; } if is_key_pressed(KeyCode::Escape) { break; }
// 2. Keep the player on the screen player.x = player.x.clamp(0.0, screen_width()); player.y = player.y.clamp(0.0, screen_height());
// 3. Draw everything for this frame clear_background(DARKBLUE); draw_circle(player.x, player.y, 20.0, ORANGE); draw_text("Arrow keys to move, Esc to quit", 20.0, 30.0, 28.0, WHITE);
next_frame().await }}You can see the three steps of the game loop: input, update, draw. #[macroquad::main("Move the Ball")] opens a window with that title. main is async, but you don’t need Tokio or to know async: next_frame().await simply means “show this frame and wait for the next one”. On Linux, Macroquad’s README lists a few system packages to install first (pkg-config libx11-dev libxi-dev libgl1-mesa-dev libasound2-dev on Ubuntu).
Games on the web (WebAssembly)
Section titled “Games on the web (WebAssembly)”Both Macroquad and Bevy can build your game for the browser as WebAssembly. If Wasm is new to you, read Rust and WebAssembly first. You always start by adding the Wasm target:
rustup target add wasm32-unknown-unknownMacroquad makes this very easy. One command builds a .wasm file:
cargo build --release --target wasm32-unknown-unknownThe file lands in target/wasm32-unknown-unknown/release/move_the_ball.wasm (for the example above it was about 600 KB). Macroquad’s README gives a short index.html that loads it with a small JavaScript file, mq_js_bundle.js. Put both files in one folder and serve it with any local web server, for example basic-http-server . (install it with cargo install basic-http-server). Just double-clicking the HTML file usually doesn’t work, because browsers block loading the .wasm file straight from disk.
Bevy uses wasm-bindgen to create the JavaScript glue. These steps are adapted from Bevy’s examples README (for the my_bevy_game project above):
cargo install wasm-bindgen-clicargo build --release --target wasm32-unknown-unknownwasm-bindgen --out-dir web --target web target/wasm32-unknown-unknown/release/my_bevy_game.wasmThis writes a .js and a _bg.wasm file into the web folder; a small HTML page imports the .js file and starts the game. Bevy’s setup guide also suggests a wasm-release profile with opt-level = "s" to make the download smaller. Web builds are a great way to share game-jam entries: people can play with one click, nothing to install.
Common mistakes
Section titled “Common mistakes”Moving by a fixed amount every frame. x += 5.0 each frame means the game runs twice as fast at 120 FPS as at 60 FPS. Multiply by delta time (x += speed * dt) or use a fixed timestep.
Following an old Bevy tutorial. Bevy changes its API in every release, so a blog post for 0.12 often won’t compile with 0.20. Check which version a tutorial uses, and prefer the official examples for your version. Each release also has a migration guide on the Bevy website.
Slow debug builds. If your Bevy game runs at a few frames per second, you probably forgot the opt-level settings in Cargo.toml.
Starting too big. An open-world RPG is a hard first project in any language. Start with Pong, Snake, Breakout or Flappy Bird. Finish it, then make the next one a bit bigger.
Practice
Section titled “Practice”1. Game states with an enum
Section titled “1. Game states with an enum”Every game has screens: a menu, the game itself, a pause screen, “game over”. Make an enum GameState with Menu, Playing, Paused and GameOver, and an enum Input with Enter, Escape and PlayerDied. Write fn next_state(state: GameState, input: Input) -> GameState: Enter starts the game from the menu, Escape pauses and unpauses, dying ends the game, and Enter on “game over” goes back to the menu. Any other input changes nothing. Feed it a fixed list of inputs and print each change. Hint: match on the tuple (state, input), and finish with (same, _) => same.
Show solution
#[derive(Debug, Clone, Copy)]enum GameState { Menu, Playing, Paused, GameOver,}
#[derive(Debug, Clone, Copy)]enum Input { Enter, Escape, PlayerDied,}
fn next_state(state: GameState, input: Input) -> GameState { match (state, input) { (GameState::Menu, Input::Enter) => GameState::Playing, (GameState::Playing, Input::Escape) => GameState::Paused, (GameState::Paused, Input::Escape) => GameState::Playing, (GameState::Playing, Input::PlayerDied) => GameState::GameOver, (GameState::GameOver, Input::Enter) => GameState::Menu, // Anything else: ignore the input and stay where we are (same, _) => same, }}
fn main() { use Input::*; // lets us write Enter instead of Input::Enter let inputs = [Escape, Enter, Escape, PlayerDied, Escape, PlayerDied, Enter];
let mut state = GameState::Menu; for input in inputs { let new_state = next_state(state, input); println!("{state:?} + {input:?} -> {new_state:?}"); state = new_state; }}Output:
Menu + Escape -> MenuMenu + Enter -> PlayingPlaying + Escape -> PausedPaused + PlayerDied -> PausedPaused + Escape -> PlayingPlaying + PlayerDied -> GameOverGameOver + Enter -> MenuMatching on a tuple lists every allowed change in one place, and the last arm makes all other inputs harmless. A paused player can’t die, because (Paused, PlayerDied) falls into (same, _). Bevy has the same idea built in, called States.
- Rust is a real option for games: “almost there”, with a strong engine (Bevy), simple libraries (Macroquad, ggez), editor-based options (Fyrox, Godot with gdext) and many building blocks.
- Every game runs a loop: input, update, draw. Use delta time or a fixed timestep so the speed doesn’t depend on the frame rate.
- ECS splits a game into entities (IDs), components (plain data) and systems (functions that run on entities with certain components).
- Bevy is ECS all the way down. Add the
opt-levelprofile settings, and usedynamic_linkingonly while developing. - Macroquad is the quickest start for 2D: you write the loop yourself and draw with simple functions.
- Both can build for the browser with the
wasm32-unknown-unknowntarget.
Quick check
Section titled “Quick check”Quick check
1. In ECS, what is an entity?
2. Why does a fixed timestep make a game behave the same on every computer?
3. Which Bevy setting should you NOT ship your finished game with?
4. You want to make your first small 2D game with very short build times. Which crate does this page suggest?
Score: 0 / 4
Read more
Section titled “Read more”- Are We Game Yet?: the community overview of Rust game engines, libraries for physics, audio and networking, and games made with Rust.
- Bevy Quick Start: Setup: the official guide to installing Bevy, the fast-compile settings and the release and Wasm profiles.
- Game development in Rust with Macroquad: a free online book that builds a complete space shooter step by step with Macroquad.
- The godot-rust book: how to write Godot 4 game logic in Rust with gdext.
- Game Loop — Game Programming Patterns: a friendly, detailed explanation of game loops, delta time and fixed timesteps.