
Why this matters now
Bevy moved fast in 2026. The 0.16 release (Q1 2026) and the 0.17 release that followed it brought the largest ergonomics shift since the early 0.10 sprite reform. Three changes in particular reset what "modern Bevy" looks like for an indie 2D team starting a weekend prototype today:
- Required components replaced the bundle-everywhere pattern. You no longer write
SpriteBundle { ... }to put a sprite on screen - you spawn aSprite::from_image(...)and required components quietly fill in the rest. - Observers matured into the canonical way to react to entity-scoped events. Coin pickups, deaths, hit reactions - they all flow through
Trigger<OnAdd, T>or your own#[derive(Event)]triggers without writing a new system that runs every frame and re-checks the same query. - Asset hot reload became the default-friendly path in
AssetPlugin. Save a PNG in Aseprite, save a RON file in your editor, and the running game picks up the change in under 250 milliseconds.
The dominant beginner-Bevy tutorials still on the first page of search results in mid-2026 were written against Bevy 0.13 in 2024. They teach SpriteBundle, Camera2dBundle, NodeBundle, polling systems with Changed<T> filters, and recompilation as the only iteration path. None of that is wrong - but a 2026 indie repo looks visibly different, and a beginner who learns 0.13 patterns this weekend will spend their Sunday afternoon fighting compiler errors that the modern API would have prevented.
This tutorial is the fast lane to a 2026-shaped Bevy 0.17 weekend build. By Sunday evening you will have a playable 2D platformer with a moving player, gravity, ground collision, coyote time, coin pickups handled by an observer, and live PNG hot reload. Everything we write here is code you can keep using for the next prototype.
Who this is for and what you will build
- Who this is for: Anyone who has run
cargo newandcargo runat least once, even if your Rust is shaky. If you have written one Bevy example that compiled (even just the spinning cube), you are ready. - What you will build: A single-screen 2D platformer where a sprite moves left and right, jumps with coyote time, falls under gravity, lands on ground tiles, and picks up coins that disappear with a sound effect. PNG and RON asset hot reload runs the whole weekend without restarts.
- Time it takes: Eight to twelve hours total, spread over a Saturday and a Sunday afternoon. Saturday morning is environment setup and the moving sprite. Saturday afternoon is gravity, jumping, and coyote time. Sunday morning is coins, the observer, and hot reload. Sunday afternoon is the extension ladder.
If you want the deeper API reference for the three modernization wins as you build, keep the Bevy 0.17 modernization guide chapter open in another tab: Bevy 0.17 Required Components, Observers, and Asset Hot Reload - Modernization Path for Indie 2D Iteration (2026). This blog post is the practice run. The chapter is the reference.
Beginner Quick Start - the four words that matter
Before any code, four words to put in your back pocket:
- App - the top-level Bevy thing you build with
App::new(). Plugins go in. Systems go in.app.run()ends yourmain. - Component - a small chunk of data attached to an entity (e.g.
Health,Velocity,Player). With required components, declaring one can pull others along automatically. - System - a function Bevy runs each frame (or whenever you schedule it). It asks for queries and resources and changes the world.
- Observer - a function Bevy runs only when a specific event fires on a specific entity. No polling. No
Changed<T>scan. The opposite of a system you forget to schedule.
That is the whole mental model you need for the weekend. Everything else is API surface.
Step 1 - Project setup that compiles fast (45 minutes)
Open a terminal and run:
rustup update stable
cargo new weekend_platformer
cd weekend_platformer
Replace your Cargo.toml with this:
[package]
name = "weekend_platformer"
version = "0.1.0"
edition = "2021"
[dependencies]
bevy = { version = "0.17", features = ["dynamic_linking"] }
[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 3
Three deliberate choices here that matter on a weekend:
bevy = "0.17"- we pin the minor. Tutorials that say "any 0.x" age badly.features = ["dynamic_linking"]- this is a debug-only speed-up. It cuts your cold compile to roughly 3-5 seconds on a recent laptop. We will turn it off before shipping a release build, but every Saturday minute matters.opt-level = 3for transitive deps in dev - this is the "5x to 10x runtime speedup in debug without losing your own debug info" trick. It compiles your dependencies in release mode but keepsweekend_platformeritself in fast-compile debug mode. The first compile is a little slower; every compile after that is dramatically faster to actually run.
Optional but recommended: install a fast linker. On macOS and Linux, mold is the easiest win:
- macOS:
brew install moldorcargo install --locked sccache wild(thewildlinker is the 2026 cross-platform pick ifmoldis awkward). - Linux:
sudo apt install moldor build from source. - Windows: stay with the default linker for the weekend; the
dynamic_linkingfeature does most of the work.
Wire the linker in .cargo/config.toml:
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[target.aarch64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=/opt/homebrew/bin/ld64.mold"]
Run a quick smoke test:
cargo run
You should see Bevy compile dependencies once (slow, this is a one-time cost), then a black window pops open. Close the window. Your environment is ready.
Success check: A single cargo run opens a black window in under 30 seconds on a re-compile (not first compile).
Step 2 - Spawn a player sprite without SpriteBundle (45 minutes)
This is the first place we say goodbye to 2024 tutorials. Put a player.png at assets/player.png (any 32x32 PNG is fine, even a flat color; we will swap it later to test hot reload). Then replace src/main.rs with:
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.run();
}
#[derive(Component)]
struct Player;
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn(Camera2d);
commands.spawn((
Player,
Sprite::from_image(asset_server.load("player.png")),
Transform::from_xyz(0.0, 100.0, 0.0),
));
}
Run it. You should see your PNG sitting in the middle of a grey-ish window with a slight downward offset (because we spawned it at y = 100 and the camera centres on 0).
Notice what is missing:
- No
SpriteBundle { sprite, texture, transform, ... }. We spawned the components themselves. - No
Camera2dBundle::default(). JustCamera2d. The required-component machinery pulls in theTransform,GlobalTransform,Projection, and so on for us. - No
Visibility::default(), noComputedVisibility, noInheritedVisibility. They are required components ofSpriteand ride along automatically.
This is the required-components win in one screenshot. A bug that used to look like "I forgot ComputedVisibility and now the sprite is invisible" simply cannot happen anymore.
Success check: The PNG renders. Close the window.
Step 3 - Gravity and ground collision (60 minutes)
Add three new components and two systems. The full src/main.rs so far:
use bevy::prelude::*;
const GRAVITY: f32 = -1200.0;
const GROUND_Y: f32 = -200.0;
#[derive(Component)]
struct Player;
#[derive(Component)]
struct Velocity(Vec2);
#[derive(Component)]
struct OnGround(bool);
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, (apply_gravity, ground_collide).chain())
.run();
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn(Camera2d);
commands.spawn((
Player,
Sprite::from_image(asset_server.load("player.png")),
Transform::from_xyz(0.0, 200.0, 0.0),
Velocity(Vec2::ZERO),
OnGround(false),
));
}
fn apply_gravity(time: Res<Time>, mut q: Query<(&mut Velocity, &OnGround)>) {
for (mut v, on_ground) in &mut q {
if !on_ground.0 {
v.0.y += GRAVITY * time.delta_secs();
}
}
}
fn ground_collide(
time: Res<Time>,
mut q: Query<(&mut Transform, &mut Velocity, &mut OnGround)>,
) {
for (mut t, mut v, mut on_ground) in &mut q {
t.translation.y += v.0.y * time.delta_secs();
t.translation.x += v.0.x * time.delta_secs();
if t.translation.y <= GROUND_Y {
t.translation.y = GROUND_Y;
v.0.y = 0.0;
on_ground.0 = true;
} else {
on_ground.0 = false;
}
}
}
Run it. The sprite falls, hits the imaginary ground at y = -200, and stops. This is also the moment the system ordering decision matters. We used .chain() so gravity runs before collision in the same frame. Without .chain(), your sprite occasionally clips through the floor on the first frame after spawn. A small detail; a real bug.
Success check: Sprite falls, lands, holds still on the ground line.
Step 4 - Input and a real jump (60 minutes)
Now we add horizontal movement and a jump. Replace main and setup to add the input system, and add a new system:
const MOVE_SPEED: f32 = 240.0;
const JUMP_VELOCITY: f32 = 520.0;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, (read_input, apply_gravity, ground_collide).chain())
.run();
}
fn read_input(
keys: Res<ButtonInput<KeyCode>>,
mut q: Query<(&mut Velocity, &OnGround), With<Player>>,
) {
for (mut v, on_ground) in &mut q {
v.0.x = 0.0;
if keys.pressed(KeyCode::ArrowLeft) || keys.pressed(KeyCode::KeyA) {
v.0.x = -MOVE_SPEED;
}
if keys.pressed(KeyCode::ArrowRight) || keys.pressed(KeyCode::KeyD) {
v.0.x = MOVE_SPEED;
}
if (keys.just_pressed(KeyCode::Space) || keys.just_pressed(KeyCode::KeyW))
&& on_ground.0
{
v.0.y = JUMP_VELOCITY;
}
}
}
Run it. Left, right, jump. Space or W jumps; A and D move; arrow keys also work.
Two beginner gotchas:
- We reset
v.0.xto zero each frame before applying input. Without that, the sprite drifts forever after one key tap. - The jump check is
just_pressed, notpressed. Withpressed, holding space would auto-jump every frame the sprite touched ground, which is not a platformer feel.
Success check: You can run left, run right, and jump from the ground.
Step 5 - Coyote time and input buffering (45 minutes)
The single change that makes a hobby Bevy platformer feel like a real one is coyote time - giving the player a tiny grace window after walking off a ledge during which jump still works. We will add it with two timers on the Player:
const COYOTE_SECS: f32 = 0.08;
const JUMP_BUFFER_SECS: f32 = 0.10;
#[derive(Component)]
struct CoyoteTimer(f32);
#[derive(Component)]
struct JumpBuffer(f32);
Update setup to include the timers on the player entity:
commands.spawn((
Player,
Sprite::from_image(asset_server.load("player.png")),
Transform::from_xyz(0.0, 200.0, 0.0),
Velocity(Vec2::ZERO),
OnGround(false),
CoyoteTimer(0.0),
JumpBuffer(0.0),
));
Replace read_input with a coyote-aware version:
fn read_input(
time: Res<Time>,
keys: Res<ButtonInput<KeyCode>>,
mut q: Query<
(&mut Velocity, &OnGround, &mut CoyoteTimer, &mut JumpBuffer),
With<Player>,
>,
) {
let dt = time.delta_secs();
for (mut v, on_ground, mut coyote, mut buffer) in &mut q {
if on_ground.0 {
coyote.0 = COYOTE_SECS;
} else {
coyote.0 = (coyote.0 - dt).max(0.0);
}
buffer.0 = (buffer.0 - dt).max(0.0);
v.0.x = 0.0;
if keys.pressed(KeyCode::ArrowLeft) || keys.pressed(KeyCode::KeyA) {
v.0.x = -MOVE_SPEED;
}
if keys.pressed(KeyCode::ArrowRight) || keys.pressed(KeyCode::KeyD) {
v.0.x = MOVE_SPEED;
}
if keys.just_pressed(KeyCode::Space) || keys.just_pressed(KeyCode::KeyW) {
buffer.0 = JUMP_BUFFER_SECS;
}
if buffer.0 > 0.0 && coyote.0 > 0.0 {
v.0.y = JUMP_VELOCITY;
buffer.0 = 0.0;
coyote.0 = 0.0;
}
}
}
What changed in feel: a player who taps space the moment before they touch the ground still gets the jump (jump buffer). A player who walks off a ledge has 80 milliseconds to press jump anyway (coyote). Both numbers are short enough to feel "fair" without being exploitable.
Success check: You can jump from a ledge for a few frames after walking off. You can press jump just before landing and still get the jump.
Step 6 - Coins with an observer instead of a polling system (60 minutes)
Now the observer win. Drop a coin.png into assets/. Add a coin component and spawn three of them in setup:
#[derive(Component)]
struct Coin;
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn(Camera2d);
commands.spawn((
Player,
Sprite::from_image(asset_server.load("player.png")),
Transform::from_xyz(0.0, 200.0, 0.0),
Velocity(Vec2::ZERO),
OnGround(false),
CoyoteTimer(0.0),
JumpBuffer(0.0),
));
for x in [-200.0, 0.0, 200.0] {
commands.spawn((
Coin,
Sprite::from_image(asset_server.load("coin.png")),
Transform::from_xyz(x, GROUND_Y + 60.0, 0.0),
));
}
}
Add a system that checks coin pickups - but the system only despawns the coin. The score increment happens in an observer:
#[derive(Component)]
struct Score(u32);
fn pickup_coins(
mut commands: Commands,
players: Query<&Transform, With<Player>>,
coins: Query<(Entity, &Transform), With<Coin>>,
) {
let Ok(player_t) = players.get_single() else {
return;
};
for (coin_entity, coin_t) in &coins {
let dist = player_t.translation.truncate().distance(coin_t.translation.truncate());
if dist < 24.0 {
commands.entity(coin_entity).despawn();
}
}
}
Now the observer. In setup, spawn a global score and add an observer that fires whenever a Coin component is removed (because we just despawned the coin):
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn(Camera2d);
commands.spawn(Score(0));
// ... player and coin spawns from above ...
}
fn on_coin_removed(
trigger: Trigger<OnRemove, Coin>,
mut score_q: Query<&mut Score>,
) {
let _ = trigger;
if let Ok(mut score) = score_q.get_single_mut() {
score.0 += 1;
info!("Score: {}", score.0);
}
}
Register the observer:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(
Update,
(read_input, apply_gravity, ground_collide, pickup_coins).chain(),
)
.add_observer(on_coin_removed)
.run();
}
Run the game. Touch a coin. The console prints Score: 1, then Score: 2, then Score: 3.
Why this is the modernization win: the pre-0.17 idiom for "coin was picked up" was a system that ran every frame and asked Query<Entity, (With<Coin>, Changed<Despawned>)> (or the equivalent ugly polling check). With an observer, the function fires only when the trigger condition happens. No per-frame cost. No accidental double-fire. No "I forgot to add a Changed<T> filter" bug.
Success check: Touching a coin removes it and prints the new score.
Step 7 - Asset hot reload that survives Aseprite (45 minutes)
The third 0.17 win. Replace DefaultPlugins with a configured version:
fn main() {
App::new()
.add_plugins(
DefaultPlugins.set(AssetPlugin {
watch_for_changes_override: Some(true),
..default()
}),
)
.add_systems(Startup, setup)
.add_systems(
Update,
(read_input, apply_gravity, ground_collide, pickup_coins).chain(),
)
.add_observer(on_coin_removed)
.run();
}
Run the game. Leave the window open. In a separate window, open assets/player.png in Aseprite or Krita, change the colour, save. Within roughly 200 milliseconds the player sprite in the running game switches colour. No restart. No rebuild.
One known Windows pitfall: Aseprite, like many editors, saves PNGs by writing a temp file and then renaming it over the target. On some Windows configurations, the notify watcher that backs AssetPlugin does not see this as a "file changed" event. The workaround is simple - configure Aseprite to "save in place" rather than "save via temp", or just use cp assets/player.png assets/player.png from a terminal to test that the watcher itself works. If you ever hit silent hot reload on Windows, the dedicated help fix article walks through the diagnostic path: Bevy 0.17 asset hot reload silent fix (planned this cycle from the Help-Planner queue).
Success check: Saving the PNG in your image editor swaps the in-game sprite without restarting.
Common mistakes
- Forgetting
.chain()on the update systems. Without it, gravity, collision, and movement can run in undefined order and your sprite occasionally clips through the floor on respawn. - Mixing
pressedandjust_pressedon the jump key. Always usejust_pressedfor one-shot actions;pressedis for held movement. - Looking for
SpriteBundlein the autocomplete. It is gone in 0.17. TypeSprite::from_image(...)and you are done. - Re-declaring required components. Do not write
#[require(Transform, GlobalTransform)]on aSpritewrapper - those are already required bySprite. Bevy will panic at startup with "component is already required" if you double up. The Help-Planner queue covers this exact failure mode: Bevy 0.17 required components ordering panic fix. - Shipping
dynamic_linkingin a release build. It will silently break on Steam Deck flatpak and some Linux distros. Add[features] release = []and only enabledynamic_linkingondev, or pass--no-default-features --features releasefor the release build. - Watching the wrong folder.
AssetPluginwatchesassets/by default. If you keep dev-only test sprites inassets/dev/, that works; if you keep them indev_assets/, the watcher will not see them. - Forgetting to register the observer.
add_observer(on_coin_removed)is easy to miss. Without it, the function exists but is never called.
Pro tips for a working indie weekend
- Use the
dev/subfolder convention. Put placeholder sprites inassets/dev/. When you replace them with final art on Sunday, the same folder structure stays clean. - Tracy profiler is one feature flag away. Add
features = ["trace_tracy"]to Bevy, install Tracy, run the game withcargo run --features bevy/trace_tracy. Even a weekend prototype will show you where the frame goes. - Keep
bevy/examplesopen in a tab. When ChatGPT or another LLM confidently writesSpriteBundle { texture, ... }, you will catch it immediately because the canonical examples now teach the 2026 shape. - One observer per event type. Resist the temptation to make a giant
on_anything_changedobserver. Small observers are easier to reason about and easier to delete when you change direction. - Pin the Bevy minor version. Tutorials and crates that say "Bevy 0.x" without a minor are pre-0.16 thinking. Pin
0.17and bump intentionally. - Profile your hot reload. If a PNG save takes more than 500 ms to appear in the running game, the bottleneck is usually file system event coalescing, not Bevy.
touch assets/player.pngin a terminal usually fires immediately. - Adopt one win at a time when migrating older Bevy code. Required components first (mechanical search-and-replace). Then observers. Then hot reload. The migration notes in the Bevy 0.17 modernization guide chapter map each previous chapter of the foundations guide to its 2026 equivalent.
First troubleshooting pass when something breaks
The first time something refuses to compile or run on a weekend, take exactly ninety seconds to walk this short ladder before opening a Discord or paste-bombing an LLM:
- Read the full panic line, not just the first sentence. Bevy panics in
0.17are unusually informative - they almost always name both components and both plugins involved. Ninety percent of "component is already required" panics resolve from the trace alone. - Confirm
Cargo.tomlis pinned to0.17. If a dependency on your machine pulled inbevy = "0.16"transitively, types will look right but observers and required components subtly misbehave. Runcargo tree | grep bevyto spot version skew. - Re-run
cargo cleanonce. The weekend cost is roughly two minutes; the bug it shakes out is almost always a staletarget/from whendynamic_linkingwas on for one compile and off for the next. After the second day of building, yourtarget/is the most likely source of "this should compile" mysteries. - Confirm your asset path matches the on-disk name exactly. macOS is case-insensitive in display but case-sensitive in the
AssetServer.Player.pngandplayer.pngare different files to Bevy. - Check
.cargo/config.tomlis pointed at a linker that actually exists. If you pasted themoldsnippet but never installedmold, every build will fail with a confusing "linker not found" line that does not namemold.
If steps 1 through 5 do not surface the problem in ninety seconds, then a focused Bevy Discord question with the panic trace and your Cargo.toml will. The ninety-second discipline keeps you from burning Saturday afternoon on a stale-cache problem.
Weekend extension ladder
If Sunday afternoon arrives and the build above feels too small, climb the ladder one rung at a time:
- Add a second platform at a higher y. Just spawn another
Groundentity (or fake it with a secondGROUND_Yconstant per platform). The collision system stays the same. - Add a death tile at the bottom of the screen that triggers an observer on
Trigger<OnAdd, Dead>and respawns the player at the starting position. - Add a goal flag that fires
Trigger<OnAdd, ReachedGoal>and prints "You won!" via an observer. - Add a particle puff on coin pickup by spawning a small entity with a despawn timer inside the observer. This is the moment "I am writing my own game feel" lands.
- Add a RON config file in
assets/config/player.ronfor the move speed and jump velocity, load it withbevy_common_assets, and feel the hot reload swap your platforming feel mid-jump.
These are deliberately small. The point of the extension ladder is to spend Sunday evening watching the game improve, not Sunday evening reading another tutorial.
If your next prototype is networked rather than single-player, the same indie-pace hot-reload discipline carries directly into Godot 4.5 ENet vs Unity Netcode for GameObjects bandwidth budgeting - see Snapshot Bandwidth Budgeting for Co-op Sessions (2026) for the cross-engine treatment.
How this fits the broader 2026 indie production stack
Three production-side topics pair naturally with a working Bevy 0.17 platformer:
- Funding - Read The 2026 Indie Game Funding Landscape the same weekend so the platformer-in-progress has an honest path to dollars before it stalls at month 4.
- Publisher milestone payments - When the platformer grows into a real project, the Milestone Payment Checklists for Indie Publisher Deals (2026) post tells you how to convert "playable build" into "wire transfer".
- Release-week governance - The 7-Day Release Candidate Freeze Challenge (2026) walks through the daily gate cadence that turns a hobby Bevy build into a partner-ready submission.
You will not need any of those this weekend. Bookmark them. The platformer is the start; the rest is the runway.
Key takeaways
- Bevy 0.17 is the 2026 baseline. Pin the minor. Skip tutorials that still teach
SpriteBundleorCamera2dBundleunless you are migrating an old project. - Required components remove most boilerplate. Spawn
Sprite::from_image(...)andCamera2d; the rest rides along. - Observers replace polling systems for one-shot reactions.
Trigger<OnAdd, T>andTrigger<OnRemove, T>cover most game events for free. - Asset hot reload is opt-in but cheap. Set
watch_for_changes_override: Some(true)once and your weekend gets a 200 ms iteration loop. - Coyote time plus jump buffer is the cheapest "real platformer feel" upgrade. Two timers on the player entity, 0.08 and 0.10 seconds.
- Chain your update systems.
.chain()keeps gravity, collision, and movement deterministic. Without it, edge-case clipping is just waiting for you. - Watch out for the four common mistakes: missing
.chain(), wrong key API (pressedvsjust_pressed), double-required components, and shippingdynamic_linkingin release builds. - Adopt one migration win at a time when modernizing older Bevy code - required components first, observers second, hot reload third. The modernization guide chapter has the full mapping.
- Use the
dev/subfolder for placeholder sprites so Sunday final-art swaps stay clean. - Pin Bevy version, run Tracy, keep
bevy/examplesopen - three habits that keep 2026 Bevy productive. - The weekend build is a starting point, not a shippable game. Climb the extension ladder one rung at a time and stop when Sunday is over.
FAQ
1. Do I need to know Rust to follow this tutorial?
You need shaky-but-existing Rust. If you can run cargo new, edit a function, and read a compiler error, the rest of the friction is just Bevy API discovery. If you have never typed Rust before, work through the first three chapters of "The Rust Book" first - it is roughly four hours and pays for itself many times over.
2. Why Bevy 0.17 specifically and not 0.13 or 0.14? The 0.16 and 0.17 releases (Q1 to Q3 2026) reset the surface API in three places that matter for a beginner: required components, observers, and default-friendly asset hot reload. Tutorials written against 0.13 still compile (sometimes), but they teach idioms that a 2026 indie repo no longer uses. Starting on 0.17 saves you the migration tax later. If you must migrate an existing 0.13 project, the Bevy 0.17 modernization guide chapter lists exactly what to change.
3. Can I follow this on Windows or only on Linux and macOS?
All three platforms work. Windows has a known minor friction around notify-based file watchers and atomic-rename PNG saves (some image editors trigger silent hot reload). The fix is documented in the help cluster and is usually a one-line editor preference change. Other than that, every code snippet in this tutorial runs identically on Windows, macOS, and Linux.
4. Do I need a physics crate like bevy_rapier2d for this?
No - the weekend build deliberately uses a tiny hand-rolled gravity-and-floor pair to keep the scope honest. Once you outgrow a flat-ground platformer (probably the second prototype), bevy_rapier2d is the right next step. Pin the version that matches your Bevy minor; the Bevy ecosystem version skew is the single most common new-team-on-Bevy time sink.
5. How do I share this with other indie devs once I finish?
Push the repo to GitHub, share the cargo run-able build on itch.io as a development build, and screenshot the hot-reload loop into a 15-second clip for Bluesky or Mastodon. Found this tutorial useful? Share it with a friend who has been blocked on "what is the 2026 Bevy starting point?" for the last month - the post solves that exact question.