2D Physics Sandbox with Bevy

Build an interactive physics toy with ECS, rigid bodies, and custom forces in Rust.

Introduction

30 Second Summary

Every video game you have ever played runs on invisible physics rules. Objects fall, collide, and bounce because someone wrote the systems that make that happen. These same patterns power everything from mobile games to engineering simulations.

In this project, you will build an interactive 2D physics sandbox from scratch using Bevy (a Rust game engine) and Avian2D (a physics plugin). You will click to spawn shapes that fall, collide, and bounce off walls, then place gravity wells that warp their paths.

What You'll Build

A neon-colored 2D physics playground where you spawn shapes with mouse clicks, switch between circles and rectangles, and place gravity wells that pull objects into orbit.

By the end of this project, you'll have:

  • A running Bevy application with a full physics sandbox scene containing walls, dynamic bodies, and collision handling.
  • A click-to-spawn system with randomized shapes, neon colors, and keyboard controls for switching shape types.
  • A custom gravity well attractor system that demonstrates the Entity Component System pattern with queries and force application.
  • Secret Mission: an explosion system that blasts all nearby bodies away from a point with radial impulse.

Are there any prerequisites?

You need the Rust toolchain (rustc and cargo via rustup) already installed on a Linux system. Basic programming experience is expected, but no prior Rust game dev knowledge is required.

Set Up the Project and Dependencies

Every Bevy application starts as a standard Rust project with specific dependencies and build configuration tuned for game development.

In this step, you'll install the required Linux build tools, scaffold the project, and wire up the Avian2D physics engine. By the end, a dark window will open on screen to confirm everything compiles cleanly.

In this step, get ready to:
  • Install Bevy's Linux build dependencies and create the project.
  • Write a minimal Bevy app that opens a physics-enabled window.
  • Configure the LLD linker for fast recompilation.
Install build dependencies and create the project

Bevy requires the LLD linker and Clang compiler to build on Linux. These are lightweight packages that enable faster linking during compilation.

  • Install lld and clang by running this command:
sudo apt-get install lld clang

What does this command do?

lld is a fast linker from the LLVM project that Bevy uses to combine compiled code into an executable. clang is a C/C++ compiler that acts as the linker driver, passing the right flags to lld.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder by running this command:
cargo new physics_sandbox
  • Move into the new project directory by running:
cd physics_sandbox
  • Confirm the project was created by running:
ls

You should see Cargo.toml and a src/ directory in the output.

Permission denied on apt-get?

Make sure you include sudo at the start of the command. If your user doesn't have sudo access, contact your system administrator.

help me troubleshoot installing lld and clang on my Linux system

Now you need to configure the project's dependencies. Cargo uses Cargo.toml to know which crates (libraries) to download and how to compile them.

  • Open Cargo.toml in your code editor and replace its entire contents with the following:
[package]
name = "physics_sandbox"
version = "0.1.0"
edition = "2024"

[dependencies]
bevy = { version = "0.18.1", features = ["dynamic_linking"] }
avian2d = "0.6"
rand = "0.9"

# 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 = 3

What does this configuration do?

  • bevy = { version = "0.18.1", features = ["dynamic_linking"] } pulls in the Bevy game engine with dynamic linking enabled for faster recompiles.
  • avian2d = "0.6" adds the Avian2D physics engine, which integrates natively with Bevy's Entity Component System.
  • rand = "0.9" provides random number generation for spawning shapes with random sizes and colors later.
  • The [profile.dev] sections enable partial optimization during development so the physics simulation runs at playable speed even in debug builds.
  • Save Cargo.toml.
Write the minimal app and verify it runs

With dependencies configured, you can write the entry point for your physics sandbox. This minimal app creates a window, initializes the physics engine, and spawns a camera so you can see things on screen.

  • Open src/main.rs in your code editor and replace its contents with:
use avian2d::prelude::*;
use bevy::prelude::*;

const WINDOW_WIDTH: f32 = 1200.0;
const WINDOW_HEIGHT: f32 = 800.0;

fn main() {
    App::new()
        .add_plugins(
            DefaultPlugins.set(WindowPlugin {
                primary_window: Some(Window {
                    title: "Physics Sandbox".to_string(),
                    resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                    ..default()
                }),
                ..default()
            }),
        )
        .add_plugins(PhysicsPlugins::default())
        .insert_resource(ClearColor(Color::srgb(0.05, 0.05, 0.08)))
        .insert_resource(Gravity(Vec2::new(0.0, -980.0)))
        .add_systems(Startup, setup_arena)
        .run();
}

fn setup_arena(mut commands: Commands) {
    // Spawn a 2D camera so we can see entities on screen
    commands.spawn(Camera2d);
}

What does this code do?

  • DefaultPlugins loads Bevy's core systems (rendering, input, windowing) with a custom window title and resolution of 1200x800 pixels.
  • PhysicsPlugins::default() initializes Avian2D's physics simulation (collision detection, rigid bodies, forces).
  • ClearColor sets the background to a near-black dark blue, giving you a clean canvas for neon-colored shapes.
  • Gravity(Vec2::new(0.0, -980.0)) sets gravity to 980 pixels/second squared downward. Standard gravity (9.81) is far too small when your world is measured in pixels.
  • setup_arena is a startup system that spawns a Camera2d entity. Without a camera, nothing renders on screen.
  • Save src/main.rs.
  • Build and run the project by running this command:
cargo run

First compile takes a while

Bevy has many dependencies, so the first cargo run takes several minutes while Cargo downloads and compiles everything. Subsequent runs are much faster.

A dark window titled "Physics Sandbox" should appear. Close it to return to your terminal.

Window not appearing or compile error?

  • If you see a linker error mentioning cc, confirm you installed lld and clang in the previous substep.
  • If you see missing system library errors (libudev, libasound2), install them with sudo apt-get install libudev-dev libasound2-dev.
  • If the window opens but immediately closes with a GPU error, check that your graphics drivers are up to date.

help me fix a compile or runtime error when building my Bevy project on Linux

Configure fast compiles with LLD

Bevy projects recompile frequently during development. The LLD linker is significantly faster than the default GNU linker, cutting link times from seconds to milliseconds.

  • Create the .cargo directory by running:
mkdir .cargo
  • Create a new file at .cargo/config.toml and add the following:
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

What does this configuration do?

This tells Cargo to use clang as the linker driver for your Linux target, passing it the -fuse-ld=lld flag to invoke LLD. Combined with the dynamic_linking feature in Cargo.toml, this means Bevy links as a shared library. Incremental recompiles now take seconds instead of minutes.

  • Save .cargo/config.toml.
  • Verify the new linker works by running:
cargo run

The same dark window should appear. Notice the compile time is faster now that LLD handles the linking step.

Linker error after adding config.toml?

  • Double-check that .cargo/config.toml is inside your physics_sandbox/ project folder, not in your home directory's ~/.cargo/.
  • Confirm lld is installed by running lld --version in your terminal.

help me fix a linker configuration error in my Bevy Rust project

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files before moving on.

ⓧ I'd like to double check the full code

Here are all three files as they should look at the end of this step.

[package]
name = "physics_sandbox"
version = "0.1.0"
edition = "2024"

[dependencies]
bevy = { version = "0.18.1", features = ["dynamic_linking"] }
avian2d = "0.6"
rand = "0.9"

[profile.dev]
opt-level = 1

[profile.dev.package."*"]
opt-level = 3
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
use avian2d::prelude::*;
use bevy::prelude::*;

const WINDOW_WIDTH: f32 = 1200.0;
const WINDOW_HEIGHT: f32 = 800.0;

fn main() {
    App::new()
        .add_plugins(
            DefaultPlugins.set(WindowPlugin {
                primary_window: Some(Window {
                    title: "Physics Sandbox".to_string(),
                    resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                    ..default()
                }),
                ..default()
            }),
        )
        .add_plugins(PhysicsPlugins::default())
        .insert_resource(ClearColor(Color::srgb(0.05, 0.05, 0.08)))
        .insert_resource(Gravity(Vec2::new(0.0, -980.0)))
        .add_systems(Startup, setup_arena)
        .run();
}

fn setup_arena(mut commands: Commands) {
    // Spawn a 2D camera so we can see entities on screen
    commands.spawn(Camera2d);
}

Your physics sandbox has a heartbeat. Next up, you'll build the arena walls and drop your first physics body to see gravity and collisions in action.

Build the Arena with Walls

Your dark window proves the physics engine is running. But without boundaries, any object you spawn will fall forever into the void.

In this step, you'll build an enclosed arena from invisible static walls. You'll also drop in a test circle to confirm that collisions actually work.

In this step, get ready to:
  • Create four static wall colliders that form an enclosed arena.
  • Spawn a dynamic test body that falls and bounces off the floor.
Create the arena walls

A physics sandbox needs boundaries. Without them, dynamic bodies just fall off-screen and disappear. You'll create four walls as static rigid bodies positioned at the window edges.

  • In src/main.rs, find the existing constants at the top of the file. Add a new constant below WINDOW_HEIGHT:
const WALL_THICKNESS: f32 = 20.0;
  • Now find the setup_arena function and replace it entirely with this version that spawns four walls:
fn setup_arena(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
) {
    commands.spawn(Camera2d);

    let half_w = WINDOW_WIDTH / 2.0;
    let half_h = WINDOW_HEIGHT / 2.0;
    let half_t = WALL_THICKNESS / 2.0;

    // Floor
    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WINDOW_WIDTH, WALL_THICKNESS),
        Transform::from_xyz(0.0, -half_h + half_t, 0.0),
        Mesh2d(meshes.add(Rectangle::new(WINDOW_WIDTH, WALL_THICKNESS))),
        MeshMaterial2d(materials.add(Color::srgb(0.15, 0.15, 0.2))),
    ));

    // Ceiling
    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WINDOW_WIDTH, WALL_THICKNESS),
        Transform::from_xyz(0.0, half_h - half_t, 0.0),
    ));

    // Left wall
    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WALL_THICKNESS, WINDOW_HEIGHT),
        Transform::from_xyz(-half_w + half_t, 0.0, 0.0),
    ));

    // Right wall
    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WALL_THICKNESS, WINDOW_HEIGHT),
        Transform::from_xyz(half_w - half_t, 0.0, 0.0),
    ));
}

What does this code do?

Each wall is an entity made of components. RigidBody::Static means the wall never moves. Collider::rectangle(width, height) defines the collision shape that other objects bounce off.

The floor also gets Mesh2d and MeshMaterial2d so you can see it as a dark bar. The ceiling and side walls are invisible because they only need to block bodies, not be seen.

The function signature now accepts ResMut<Assets<Mesh>> and ResMut<Assets<ColorMaterial>>. Bevy automatically passes these resources into any system that requests them.

Why use pixel-space gravity?

Avian's default gravity is 9.81, which is measured in meters per second squared. Since your window is 800 pixels tall, an object falling at 9.81 pixels per second would look nearly frozen.

Using -980.0 for gravity (set in Step 1) makes objects fall convincingly in pixel space. The walls are positioned at the pixel edges of the window to match.

  • Save src/main.rs.
  • Run cargo run in your terminal. You should see the dark window with a thin dark-gray bar across the bottom. Nothing moves yet because there are no dynamic bodies.

Seeing a compile error about missing traits?

Make sure your setup_arena function signature matches the code above exactly, including mut meshes: ResMut<Assets<Mesh>> and mut materials: ResMut<Assets<ColorMaterial>>. Bevy needs these parameters to register the system correctly.

Also confirm that const WALL_THICKNESS: f32 = 20.0; is placed outside the function, alongside your other constants at the top of the file.

help me fix a compile error in my setup_arena system

Spawn a test circle to verify collisions

Walls alone are invisible physics. You need a dynamic body to prove the arena works. A dynamic rigid body is affected by gravity and responds to collisions.

  • In src/main.rs, find the closing } of the setup_arena function. Add this spawn call just above that closing brace, after the right wall:
    // Test circle
    commands.spawn((
        RigidBody::Dynamic,
        Collider::circle(25.0),
        Transform::from_xyz(0.0, 100.0, 0.0),
        Mesh2d(meshes.add(Circle::new(25.0))),
        MeshMaterial2d(materials.add(Color::srgb(0.0, 7.0, 7.0))),
        Restitution::new(0.7),
    ));

What does this code do?

RigidBody::Dynamic tells the physics engine this body responds to gravity and forces. Collider::circle(25.0) gives it a circular collision shape with a 25-pixel radius.

The Color::srgb(0.0, 7.0, 7.0) produces a bright cyan glow. Values above 1.0 create a neon bloom effect against the dark background.

Restitution::new(0.7) sets the bounciness coefficient. A value of 0.7 means the circle retains 70% of its energy on each bounce, giving a satisfying but not endless bounce pattern.

  • Save src/main.rs and run cargo run.
  • You should see a bright cyan circle fall from the center of the screen, hit the dark floor bar, and bounce several times before settling.

Circle falls through the floor?

Check that your floor's Transform::from_xyz uses -half_h + half_t for the Y value. If you used -half_h without adding half_t, the collider sits half outside the window and the circle clips through.

Also verify the floor has RigidBody::Static. Without it, the floor itself would fall under gravity.

help me debug why my circle falls through the floor

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and the circle bounces cleanly.

ⓧ I'd like to double check the full code

use avian2d::prelude::*;
use bevy::prelude::*;

const WINDOW_WIDTH: f32 = 1200.0;
const WINDOW_HEIGHT: f32 = 800.0;
const WALL_THICKNESS: f32 = 20.0;

fn main() {
    App::new()
        .add_plugins(
            DefaultPlugins.set(WindowPlugin {
                primary_window: Some(Window {
                    title: "Physics Sandbox".to_string(),
                    resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                    ..default()
                }),
                ..default()
            }),
        )
        .add_plugins(PhysicsPlugins::default())
        .insert_resource(ClearColor(Color::srgb(0.05, 0.05, 0.08)))
        .insert_resource(Gravity(Vec2::new(0.0, -980.0)))
        .add_systems(Startup, setup_arena)
        .run();
}

fn setup_arena(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
) {
    commands.spawn(Camera2d);

    let half_w = WINDOW_WIDTH / 2.0;
    let half_h = WINDOW_HEIGHT / 2.0;
    let half_t = WALL_THICKNESS / 2.0;

    // Floor
    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WINDOW_WIDTH, WALL_THICKNESS),
        Transform::from_xyz(0.0, -half_h + half_t, 0.0),
        Mesh2d(meshes.add(Rectangle::new(WINDOW_WIDTH, WALL_THICKNESS))),
        MeshMaterial2d(materials.add(Color::srgb(0.15, 0.15, 0.2))),
    ));

    // Ceiling
    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WINDOW_WIDTH, WALL_THICKNESS),
        Transform::from_xyz(0.0, half_h - half_t, 0.0),
    ));

    // Left wall
    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WALL_THICKNESS, WINDOW_HEIGHT),
        Transform::from_xyz(-half_w + half_t, 0.0, 0.0),
    ));

    // Right wall
    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WALL_THICKNESS, WINDOW_HEIGHT),
        Transform::from_xyz(half_w - half_t, 0.0, 0.0),
    ));

    // Test circle
    commands.spawn((
        RigidBody::Dynamic,
        Collider::circle(25.0),
        Transform::from_xyz(0.0, 100.0, 0.0),
        Mesh2d(meshes.add(Circle::new(25.0))),
        MeshMaterial2d(materials.add(Color::srgb(0.0, 7.0, 7.0))),
        Restitution::new(0.7),
    ));
}

Your arena is solid. Objects stay contained and collisions feel natural. Next up, you'll let the player click to spawn bodies anywhere on screen.

Click to Spawn Physics Bodies

Your arena has four walls and a floor, but only a single test circle bouncing around. A sandbox needs to let you create things on demand.

In this step, you'll build a click-to-spawn system that places colorful physics shapes wherever your cursor lands. You'll also add keyboard controls to switch between circles and rectangles, and a performance cap that removes the oldest bodies when the count gets too high.

In this step, get ready to:
  • Create a click-to-spawn system that converts screen coordinates to world positions.
  • Add shape variety with keyboard controls and randomized neon colors.
  • Cap the body count to keep performance smooth.
Create the click-to-spawn system

Before writing the spawning logic, you need several supporting data types. These include an ActiveShape resource that tracks which shape to spawn, a PhysicsBody component for tracking spawn order, and a palette of neon colors.

  • In src/main.rs, find the line use bevy::prelude::*; and add this import directly below it:
use rand::Rng;
  • Next, find the line const WALL_THICKNESS: f32 = 20.0; and add this constant below it:
const MAX_BODIES: usize = 200;
  • Now add the type definitions below the main() function by pasting this code:
// Tracks which shape type the user wants to spawn
#[derive(Resource, Default)]
struct ActiveShape {
    shape_type: ShapeType,
}

// Two shape options the user can cycle between
#[derive(Default, Clone, Copy)]
enum ShapeType {
    #[default]
    Circle,
    Rectangle,
}

// Marker component that records when each body was created
#[derive(Component)]
struct PhysicsBody {
    spawn_order: u32,
}

// Bright HDR colors that glow on the dark background
static NEON_COLORS: &[(f32, f32, f32)] = &[
    (0.0, 7.0, 7.0),
    (7.0, 0.0, 7.0),
    (0.0, 4.0, 8.0),
    (8.0, 4.0, 0.0),
    (0.0, 8.0, 2.0),
    (8.0, 8.0, 0.0),
];

// Global counter to assign each body a unique spawn order
static mut SPAWN_COUNTER: u32 = 0;

What do these types do?

ActiveShape is a Resource, which means it's global state shared across all systems. It holds which shape the player currently wants to spawn.

PhysicsBody is a Component attached to each spawned entity. Its spawn_order field lets the cap system know which bodies are oldest when it's time to despawn.

The NEON_COLORS values exceed 1.0 on purpose. In Bevy, HDR color values above 1.0 appear bright and bloom-ready against dark backgrounds.

Now you have all the data types in place. The next chunk is the actual spawning system that runs every frame and checks for mouse clicks.

  • Add the spawn_body function below the setup_arena function by pasting this code:
fn spawn_body(
    mut commands: Commands,
    mouse: Res<ButtonInput<MouseButton>>,
    windows: Query<&Window>,
    camera_q: Query<(&Camera, &GlobalTransform)>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
    active_shape: Res<ActiveShape>,
) {
    // Only proceed if the left mouse button was just clicked
    if !mouse.just_pressed(MouseButton::Left) { return; }
    let window = windows.single();
    let (camera, camera_transform) = camera_q.single();
    // Convert the cursor's screen position to world coordinates
    let Some(cursor_pos) = window.cursor_position() else { return; };
    let Ok(world_pos) = camera.viewport_to_world_2d(camera_transform, cursor_pos) else { return; };
    // Pick a random neon color from the palette
    let mut rng = rand::rng();
    let color_idx = rng.random_range(0..NEON_COLORS.len());
    let (r, g, b) = NEON_COLORS[color_idx];
    let color = Color::srgb(r, g, b);
    // Track spawn order for the body cap system
    let spawn_order = unsafe { SPAWN_COUNTER += 1; SPAWN_COUNTER };
    match active_shape.shape_type {
        ShapeType::Circle => {
            let radius = rng.random_range(15.0..35.0);
            commands.spawn((
                RigidBody::Dynamic,
                Collider::circle(radius),
                Transform::from_xyz(world_pos.x, world_pos.y, 0.0),
                Mesh2d(meshes.add(Circle::new(radius))),
                MeshMaterial2d(materials.add(color)),
                Restitution::new(0.7),
                LinearDamping(0.5),
                PhysicsBody { spawn_order },
            ));
        }
        ShapeType::Rectangle => {
            let w = rng.random_range(20.0..50.0);
            let h = rng.random_range(20.0..50.0);
            commands.spawn((
                RigidBody::Dynamic,
                Collider::rectangle(w, h),
                Transform::from_xyz(world_pos.x, world_pos.y, 0.0),
                Mesh2d(meshes.add(Rectangle::new(w, h))),
                MeshMaterial2d(materials.add(color)),
                Restitution::new(0.7),
                LinearDamping(0.5),
                PhysicsBody { spawn_order },
            ));
        }
    }
}

What does this system do?

  • mouse.just_pressed(MouseButton::Left) ensures the system only fires once per click, not every frame the button is held.
  • viewport_to_world_2d converts the cursor's pixel position on screen into the physics world's coordinate space. Without this, spawning would happen at the wrong location.
  • Restitution::new(0.7) sets bounciness. A value of 0.7 means bodies retain 70% of their velocity on bounce.
  • LinearDamping(0.5) applies slight air resistance so bodies gradually slow down instead of bouncing forever.
  • Save src/main.rs.

Why unsafe for SPAWN_COUNTER?

Rust requires unsafe to mutate a static mut because the compiler cannot guarantee thread safety for global mutable state. In a single-threaded game loop this is safe in practice. A production alternative would be an AtomicU32 resource.

Add shape switching with keyboard controls

The spawn_body function already reads from ActiveShape to decide which shape to create. Now you need a system that lets the player toggle between circles and rectangles using the keyboard.

  • Add the switch_shape function below spawn_body by pasting this code:
fn switch_shape(keyboard: Res<ButtonInput<KeyCode>>, mut active_shape: ResMut<ActiveShape>) {
    // Press 1 for circles, 2 for rectangles
    if keyboard.just_pressed(KeyCode::Digit1) { active_shape.shape_type = ShapeType::Circle; }
    else if keyboard.just_pressed(KeyCode::Digit2) { active_shape.shape_type = ShapeType::Rectangle; }
}

What does this system do?

This system runs every frame and checks for keypress events. When the player presses 1 or 2, it updates the ActiveShape resource. On the next click, spawn_body reads the updated resource and spawns the correct shape.

  • Save src/main.rs.
Cap the body count and wire the systems together

Spawning bodies endlessly would eventually tank performance. You need a cleanup system that removes the oldest bodies once the count exceeds a limit.

  • Add the cap_body_count function below switch_shape by pasting this code:
fn cap_body_count(mut commands: Commands, bodies: Query<(Entity, &PhysicsBody)>) {
    // Collect all physics bodies with their spawn order
    let mut body_list: Vec<(Entity, u32)> = bodies.iter().map(|(e, pb)| (e, pb.spawn_order)).collect();
    if body_list.len() > MAX_BODIES {
        // Sort by spawn order so the oldest are first
        body_list.sort_by_key(|(_, order)| *order);
        let to_remove = body_list.len() - MAX_BODIES;
        for (entity, _) in body_list.iter().take(to_remove) {
            commands.entity(*entity).despawn();
        }
    }
}

What does this system do?

Every frame, this system counts all entities that have a PhysicsBody component. If the count exceeds MAX_BODIES (200), it sorts them by spawn order and despawns the oldest ones first. This keeps the simulation responsive no matter how click-happy you get.

All three systems are written. Now you need to register them with the app and remove the test circle from the previous step.

  • In the main() function, find this line:
.insert_resource(Gravity(Vec2::new(0.0, -980.0)))
  • Add this line directly below it to register the ActiveShape resource:
.init_resource::<ActiveShape>()
  • Find this line:
.add_systems(Startup, setup_arena)
  • Add this line directly below it to register all three new systems in the Update schedule:
.add_systems(Update, (spawn_body, switch_shape, cap_body_count))

What is the Update schedule?

Bevy runs systems on a schedule. Startup runs once when the app launches. Update runs every single frame. By placing your input-handling and spawning systems in Update, they continuously check for player actions.

  • Finally, scroll down to the setup_arena function and find the test circle block at the bottom:
    // Test circle
    commands.spawn((
        RigidBody::Dynamic,
        Collider::circle(25.0),
        Transform::from_xyz(0.0, 100.0, 0.0),
        Mesh2d(meshes.add(Circle::new(25.0))),
        MeshMaterial2d(materials.add(Color::srgb(0.0, 7.0, 7.0))),
        Restitution::new(0.7),
    ));
  • Delete the entire test circle block (all 8 lines from the comment through the closing );). You no longer need it because the player will spawn bodies by clicking.
  • Save src/main.rs.
  • Run the sandbox by running:
cargo run
  • Click anywhere in the window. You should see a brightly colored circle appear at your cursor and fall under gravity.
  • Press 2 on your keyboard, then click again. You should see a rectangle spawn instead.
  • Press 1 to switch back to circles.
  • Spawn many shapes rapidly. They should collide with each other and bounce off the walls.

Shapes not appearing when you click?

  • Check that you added .init_resource::<ActiveShape>() in main(). Without it, Bevy cannot find the resource and the system will panic.
  • Make sure spawn_body is listed inside the .add_systems(Update, (...)) call. If it's only in Startup, it runs once then never checks for clicks again.
  • If you see a compile error about rand, verify that use rand::Rng; is at the top of the file.

Help me debug why clicking isn't spawning shapes in my Bevy physics sandbox.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

use avian2d::prelude::*;
use bevy::prelude::*;
use rand::Rng;

const WINDOW_WIDTH: f32 = 1200.0;
const WINDOW_HEIGHT: f32 = 800.0;
const WALL_THICKNESS: f32 = 20.0;
const MAX_BODIES: usize = 200;

fn main() {
    App::new()
        .add_plugins(
            DefaultPlugins.set(WindowPlugin {
                primary_window: Some(Window {
                    title: "Physics Sandbox".to_string(),
                    resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                    ..default()
                }),
                ..default()
            }),
        )
        .add_plugins(PhysicsPlugins::default())
        .insert_resource(ClearColor(Color::srgb(0.05, 0.05, 0.08)))
        .insert_resource(Gravity(Vec2::new(0.0, -980.0)))
        .init_resource::<ActiveShape>()
        .add_systems(Startup, setup_arena)
        .add_systems(Update, (spawn_body, switch_shape, cap_body_count))
        .run();
}

#[derive(Resource, Default)]
struct ActiveShape {
    shape_type: ShapeType,
}

#[derive(Default, Clone, Copy)]
enum ShapeType {
    #[default]
    Circle,
    Rectangle,
}

#[derive(Component)]
struct PhysicsBody {
    spawn_order: u32,
}

static NEON_COLORS: &[(f32, f32, f32)] = &[
    (0.0, 7.0, 7.0),
    (7.0, 0.0, 7.0),
    (0.0, 4.0, 8.0),
    (8.0, 4.0, 0.0),
    (0.0, 8.0, 2.0),
    (8.0, 8.0, 0.0),
];

static mut SPAWN_COUNTER: u32 = 0;

fn setup_arena(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
) {
    commands.spawn(Camera2d);

    let half_w = WINDOW_WIDTH / 2.0;
    let half_h = WINDOW_HEIGHT / 2.0;
    let half_t = WALL_THICKNESS / 2.0;

    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WINDOW_WIDTH, WALL_THICKNESS),
        Transform::from_xyz(0.0, -half_h + half_t, 0.0),
        Mesh2d(meshes.add(Rectangle::new(WINDOW_WIDTH, WALL_THICKNESS))),
        MeshMaterial2d(materials.add(Color::srgb(0.15, 0.15, 0.2))),
    ));
    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WINDOW_WIDTH, WALL_THICKNESS),
        Transform::from_xyz(0.0, half_h - half_t, 0.0),
    ));
    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WALL_THICKNESS, WINDOW_HEIGHT),
        Transform::from_xyz(-half_w + half_t, 0.0, 0.0),
    ));
    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WALL_THICKNESS, WINDOW_HEIGHT),
        Transform::from_xyz(half_w - half_t, 0.0, 0.0),
    ));
}

fn spawn_body(
    mut commands: Commands,
    mouse: Res<ButtonInput<MouseButton>>,
    windows: Query<&Window>,
    camera_q: Query<(&Camera, &GlobalTransform)>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
    active_shape: Res<ActiveShape>,
) {
    if !mouse.just_pressed(MouseButton::Left) { return; }
    let window = windows.single();
    let (camera, camera_transform) = camera_q.single();
    let Some(cursor_pos) = window.cursor_position() else { return; };
    let Ok(world_pos) = camera.viewport_to_world_2d(camera_transform, cursor_pos) else { return; };
    let mut rng = rand::rng();
    let color_idx = rng.random_range(0..NEON_COLORS.len());
    let (r, g, b) = NEON_COLORS[color_idx];
    let color = Color::srgb(r, g, b);
    let spawn_order = unsafe { SPAWN_COUNTER += 1; SPAWN_COUNTER };
    match active_shape.shape_type {
        ShapeType::Circle => {
            let radius = rng.random_range(15.0..35.0);
            commands.spawn((
                RigidBody::Dynamic,
                Collider::circle(radius),
                Transform::from_xyz(world_pos.x, world_pos.y, 0.0),
                Mesh2d(meshes.add(Circle::new(radius))),
                MeshMaterial2d(materials.add(color)),
                Restitution::new(0.7),
                LinearDamping(0.5),
                PhysicsBody { spawn_order },
            ));
        }
        ShapeType::Rectangle => {
            let w = rng.random_range(20.0..50.0);
            let h = rng.random_range(20.0..50.0);
            commands.spawn((
                RigidBody::Dynamic,
                Collider::rectangle(w, h),
                Transform::from_xyz(world_pos.x, world_pos.y, 0.0),
                Mesh2d(meshes.add(Rectangle::new(w, h))),
                MeshMaterial2d(materials.add(color)),
                Restitution::new(0.7),
                LinearDamping(0.5),
                PhysicsBody { spawn_order },
            ));
        }
    }
}

fn switch_shape(keyboard: Res<ButtonInput<KeyCode>>, mut active_shape: ResMut<ActiveShape>) {
    if keyboard.just_pressed(KeyCode::Digit1) { active_shape.shape_type = ShapeType::Circle; }
    else if keyboard.just_pressed(KeyCode::Digit2) { active_shape.shape_type = ShapeType::Rectangle; }
}

fn cap_body_count(mut commands: Commands, bodies: Query<(Entity, &PhysicsBody)>) {
    let mut body_list: Vec<(Entity, u32)> = bodies.iter().map(|(e, pb)| (e, pb.spawn_order)).collect();
    if body_list.len() > MAX_BODIES {
        body_list.sort_by_key(|(_, order)| *order);
        let to_remove = body_list.len() - MAX_BODIES;
        for (entity, _) in body_list.iter().take(to_remove) {
            commands.entity(*entity).despawn();
        }
    }
}

Your sandbox is interactive. Click-spawning, shape switching, and a body cap are all running together in the ECS update loop. Next, you'll add gravity wells that pull bodies into orbital patterns using a custom force system.

Add Gravity Wells and Debug View

Your sandbox has bodies that fall, collide, and bounce. But gravity only pulls in one direction. What if you could place attractors that pull bodies toward them, creating orbital patterns and chaotic interactions?

In this step, you will write a custom force system that computes gravitational attraction between physics bodies and point attractors you place with a keypress. You will also add a debug view that reveals the invisible collider wireframes underneath your visual meshes.

In this step, get ready to:
  • Define a GravityWell component and a system that spawns gravity wells at the cursor.
  • Write a custom force system that pulls dynamic bodies toward each well.
  • Add a debug view toggle so you can see the physics layer.
Define the GravityWell component and placement system

A gravity well needs two things: a component that stores how strongly it attracts, and a system that spawns it wherever your cursor is when you press a key.

  • In src/main.rs, find the MAX_BODIES constant at the top of the file. Add the gravity well strength constant directly below it:
const GRAVITY_WELL_STRENGTH: f32 = 500_000.0;
  • Next, find the PhysicsBody struct. Add the GravityWell component definition directly below it:
#[derive(Component)]
struct GravityWell {
    strength: f32,
}

What does this component do?

In the ECS pattern, components are just data. GravityWell stores a single strength value that controls how powerfully this well attracts nearby bodies.

The constant 500_000.0 is large because Avian2D works in pixel-space. A value of 9.81 would be invisible at this scale.

  • Now write the place_gravity_well function. Add it below the switch_shape function by copying the following code:
fn place_gravity_well(
    mut commands: Commands,
    keyboard: Res<ButtonInput<KeyCode>>,
    windows: Query<&Window>,
    camera_q: Query<(&Camera, &GlobalTransform)>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
) {
    if !keyboard.just_pressed(KeyCode::KeyG) { return; }
    let window = windows.single();
    let (camera, camera_transform) = camera_q.single();
    let Some(cursor_pos) = window.cursor_position() else { return; };
    let Ok(world_pos) = camera.viewport_to_world_2d(camera_transform, cursor_pos) else { return; };
    // Spawn a ring-shaped entity with the GravityWell component
    commands.spawn((
        GravityWell { strength: GRAVITY_WELL_STRENGTH },
        Transform::from_xyz(world_pos.x, world_pos.y, 0.0),
        Mesh2d(meshes.add(Annulus::new(20.0, 25.0))),
        MeshMaterial2d(materials.add(Color::srgb(5.0, 0.5, 5.0))),
    ));
}

What does this system do?

  • keyboard.just_pressed(KeyCode::KeyG) triggers only on the frame the G key goes down.
  • The cursor-to-world conversion is the same pattern you used in spawn_body. It maps screen pixels to world coordinates.
  • Annulus::new(20.0, 25.0) creates a ring shape (inner radius 20, outer radius 25) so the well looks like a glowing ring rather than a solid circle.
  • Color::srgb(5.0, 0.5, 5.0) uses values above 1.0 to create a bright magenta neon glow effect.
  • Register the new system so Bevy calls it each frame. In your main() function, find this line:
.add_systems(Update, (spawn_body, switch_shape, cap_body_count))
  • Replace it with the following, which adds place_gravity_well to the tuple:
.add_systems(Update, (spawn_body, switch_shape, place_gravity_well, cap_body_count))
  • Save src/main.rs and run your sandbox:
cargo run
  • Move your cursor to the center of the window and press G. You should see a bright magenta ring appear at that position.

Don't see the ring when pressing G?

  • Make sure you added place_gravity_well to the .add_systems(Update, ...) tuple. The function exists but Bevy won't call it unless it's registered.
  • Check that the key code is KeyCode::KeyG (capital K in Key, capital G). Bevy key codes are case-sensitive enum variants.
  • Make sure your cursor is inside the window when you press G. The system returns early if it cannot read the cursor position.

Why isn't my gravity well ring appearing when I press G?

Write the gravitational attraction system

The ring is just a visual marker right now. It has no physics effect. You need a system that queries every dynamic body and every gravity well, computes the pull direction and strength, and applies a force each frame.

  • Add the apply_gravity_wells function below place_gravity_well by copying this code:
fn apply_gravity_wells(
    mut bodies: Query<(Forces, &GlobalTransform), With<PhysicsBody>>,
    wells: Query<(&GravityWell, &GlobalTransform)>,
) {
    for (mut forces, body_transform) in &mut bodies {
        let body_pos = body_transform.translation().truncate();
        for (well, well_transform) in &wells {
            let well_pos = well_transform.translation().truncate();
            // Compute direction and distance from body to well
            let diff = well_pos - body_pos;
            let distance_sq = diff.length_squared();
            // Skip if body is essentially on top of the well
            if distance_sq < 1.0 { continue; }
            // Inverse-square force: stronger when closer
            let direction = diff.normalize();
            let magnitude = well.strength / distance_sq;
            forces.apply_force(direction * magnitude);
        }
    }
}

What does this system do?

  • Forces is Avian's special QueryData type for applying forces to rigid bodies. It is NOT a regular component reference, which is why you write Forces directly instead of &mut Forces.
  • The system uses two queries: one for all dynamic bodies (filtered by With<PhysicsBody>), and one for all gravity wells.
  • The formula strength / distance_squared is an inverse-square law. Bodies close to the well feel a strong pull. Bodies far away barely notice it.
  • The distance_sq < 1.0 guard prevents division by near-zero, which would launch bodies to infinity.

Why forces instead of impulses?

forces.apply_force() adds a continuous force that is cleared after each physics step. This creates smooth, ongoing attraction like real gravity. An impulse would be an instantaneous velocity change, better suited for one-off events like explosions or jumps.

  • Register the new system. Find your .add_systems(Update, ...) line which currently looks like this:
.add_systems(Update, (spawn_body, switch_shape, place_gravity_well, cap_body_count))
  • Add apply_gravity_wells after place_gravity_well, so the line now looks like this:
.add_systems(Update, (spawn_body, switch_shape, place_gravity_well, apply_gravity_wells, cap_body_count))
  • Save src/main.rs and run with cargo run.
  • Click to spawn several bodies, then press G to place a gravity well nearby. You should see bodies curving toward the ring instead of falling straight down.
  • Try placing two or three wells in different positions. Bodies should trace complex orbital paths between them.

Bodies ignoring the gravity well?

  • Make sure you added apply_gravity_wells to the .add_systems(Update, ...) call. The function place_gravity_well only spawns the entity. apply_gravity_wells is what actually pulls bodies.
  • Check that the bodies query uses With<PhysicsBody> as a filter. Without it, the query might not match spawned bodies.
  • If the effect is barely visible, try placing the well closer to where bodies are falling. The inverse-square law means distance matters a lot.

Help me debug why my gravity wells aren't attracting physics bodies.

Add the physics debug view toggle

Avian2D includes a debug renderer that draws wireframe outlines around every collider. This is invaluable for understanding what the physics engine actually sees versus what you draw on screen.

  • Add the toggle_debug function at the bottom of src/main.rs by copying this code:
fn toggle_debug(
    mut commands: Commands,
    keyboard: Res<ButtonInput<KeyCode>>,
    debug_config: Option<Res<PhysicsDebugConfig>>,
) {
    if keyboard.just_pressed(KeyCode::KeyD) {
        match debug_config {
            Some(config) => {
                // Toggle the existing config's enabled state
                if config.enabled {
                    commands.insert_resource(PhysicsDebugConfig { enabled: false, ..default() });
                } else {
                    commands.insert_resource(PhysicsDebugConfig { enabled: true, ..default() });
                }
            }
            None => {
                // No config exists yet, insert one with debug enabled
                commands.insert_resource(PhysicsDebugConfig { enabled: true, ..default() });
            }
        }
    }
}

What does this system do?

  • Option<Res<PhysicsDebugConfig>> is a Bevy pattern for resources that might not exist yet. The first time you press D, the resource doesn't exist, so the None branch inserts one.
  • commands.insert_resource() overwrites the existing resource with a new value. This is how you toggle the enabled field between true and false.
  • The PhysicsDebugPlugin is already included in PhysicsPlugins::default() (via the debug-plugin feature). You just need to control the config.
  • Finally, update the system registration to its complete form. Find your .add_systems(Update, ...) line:
.add_systems(Update, (spawn_body, switch_shape, place_gravity_well, apply_gravity_wells, cap_body_count))
  • Replace it with the following, adding toggle_debug at the end:
.add_systems(
    Update,
    (
        spawn_body,
        switch_shape,
        place_gravity_well,
        apply_gravity_wells,
        cap_body_count,
        toggle_debug,
    ),
)
  • Save src/main.rs and run with cargo run.
  • Spawn a few bodies by clicking, then press D. You should see green wireframe outlines appear around every collider, including the walls and all spawned shapes.
  • Press D again to hide the debug wireframes.

Debug wireframes not appearing?

  • Make sure toggle_debug is registered in .add_systems(Update, ...).
  • The key code is KeyCode::KeyD (not KeyCode::Digit4 or similar). Check for typos in the enum variant.
  • If you see a compile error about PhysicsDebugConfig not being found, confirm your import line is use avian2d::prelude::*; which re-exports all debug types.

Why won't the physics debug view toggle on when I press D?

✔️ Awesome, I've got everything!

Great. Double check you've saved src/main.rs and everything compiles cleanly with cargo run.

ⓧ I'd like to double check the full code

Here is the complete src/main.rs at this point. Compare it with yours to find any differences.

use avian2d::prelude::*;
use bevy::prelude::*;
use rand::Rng;

const WINDOW_WIDTH: f32 = 1200.0;
const WINDOW_HEIGHT: f32 = 800.0;
const WALL_THICKNESS: f32 = 20.0;
const MAX_BODIES: usize = 200;
const GRAVITY_WELL_STRENGTH: f32 = 500_000.0;

fn main() {
    App::new()
        .add_plugins(
            DefaultPlugins.set(WindowPlugin {
                primary_window: Some(Window {
                    title: "Physics Sandbox".to_string(),
                    resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                    ..default()
                }),
                ..default()
            }),
        )
        .add_plugins(PhysicsPlugins::default())
        .insert_resource(ClearColor(Color::srgb(0.05, 0.05, 0.08)))
        .insert_resource(Gravity(Vec2::new(0.0, -980.0)))
        .init_resource::<ActiveShape>()
        .add_systems(Startup, setup_arena)
        .add_systems(
            Update,
            (
                spawn_body,
                switch_shape,
                place_gravity_well,
                apply_gravity_wells,
                cap_body_count,
                toggle_debug,
            ),
        )
        .run();
}

#[derive(Resource, Default)]
struct ActiveShape {
    shape_type: ShapeType,
}

#[derive(Default, Clone, Copy)]
enum ShapeType {
    #[default]
    Circle,
    Rectangle,
}

#[derive(Component)]
struct PhysicsBody {
    spawn_order: u32,
}

#[derive(Component)]
struct GravityWell {
    strength: f32,
}

static NEON_COLORS: &[(f32, f32, f32)] = &[
    (0.0, 7.0, 7.0),
    (7.0, 0.0, 7.0),
    (0.0, 4.0, 8.0),
    (8.0, 4.0, 0.0),
    (0.0, 8.0, 2.0),
    (8.0, 8.0, 0.0),
];

static mut SPAWN_COUNTER: u32 = 0;

fn setup_arena(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
) {
    commands.spawn(Camera2d);
    let half_w = WINDOW_WIDTH / 2.0;
    let half_h = WINDOW_HEIGHT / 2.0;
    let half_t = WALL_THICKNESS / 2.0;
    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WINDOW_WIDTH, WALL_THICKNESS),
        Transform::from_xyz(0.0, -half_h + half_t, 0.0),
        Mesh2d(meshes.add(Rectangle::new(WINDOW_WIDTH, WALL_THICKNESS))),
        MeshMaterial2d(materials.add(Color::srgb(0.15, 0.15, 0.2))),
    ));
    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WINDOW_WIDTH, WALL_THICKNESS),
        Transform::from_xyz(0.0, half_h - half_t, 0.0),
    ));
    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WALL_THICKNESS, WINDOW_HEIGHT),
        Transform::from_xyz(-half_w + half_t, 0.0, 0.0),
    ));
    commands.spawn((
        RigidBody::Static,
        Collider::rectangle(WALL_THICKNESS, WINDOW_HEIGHT),
        Transform::from_xyz(half_w - half_t, 0.0, 0.0),
    ));
}

fn spawn_body(
    mut commands: Commands,
    mouse: Res<ButtonInput<MouseButton>>,
    windows: Query<&Window>,
    camera_q: Query<(&Camera, &GlobalTransform)>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
    active_shape: Res<ActiveShape>,
) {
    if !mouse.just_pressed(MouseButton::Left) { return; }
    let window = windows.single();
    let (camera, camera_transform) = camera_q.single();
    let Some(cursor_pos) = window.cursor_position() else { return; };
    let Ok(world_pos) = camera.viewport_to_world_2d(camera_transform, cursor_pos) else { return; };
    let mut rng = rand::rng();
    let color_idx = rng.random_range(0..NEON_COLORS.len());
    let (r, g, b) = NEON_COLORS[color_idx];
    let color = Color::srgb(r, g, b);
    let spawn_order = unsafe { SPAWN_COUNTER += 1; SPAWN_COUNTER };
    match active_shape.shape_type {
        ShapeType::Circle => {
            let radius = rng.random_range(15.0..35.0);
            commands.spawn((
                RigidBody::Dynamic,
                Collider::circle(radius),
                Transform::from_xyz(world_pos.x, world_pos.y, 0.0),
                Mesh2d(meshes.add(Circle::new(radius))),
                MeshMaterial2d(materials.add(color)),
                Restitution::new(0.7),
                LinearDamping(0.5),
                PhysicsBody { spawn_order },
            ));
        }
        ShapeType::Rectangle => {
            let w = rng.random_range(20.0..50.0);
            let h = rng.random_range(20.0..50.0);
            commands.spawn((
                RigidBody::Dynamic,
                Collider::rectangle(w, h),
                Transform::from_xyz(world_pos.x, world_pos.y, 0.0),
                Mesh2d(meshes.add(Rectangle::new(w, h))),
                MeshMaterial2d(materials.add(color)),
                Restitution::new(0.7),
                LinearDamping(0.5),
                PhysicsBody { spawn_order },
            ));
        }
    }
}

fn switch_shape(keyboard: Res<ButtonInput<KeyCode>>, mut active_shape: ResMut<ActiveShape>) {
    if keyboard.just_pressed(KeyCode::Digit1) { active_shape.shape_type = ShapeType::Circle; }
    else if keyboard.just_pressed(KeyCode::Digit2) { active_shape.shape_type = ShapeType::Rectangle; }
}

fn place_gravity_well(
    mut commands: Commands,
    keyboard: Res<ButtonInput<KeyCode>>,
    windows: Query<&Window>,
    camera_q: Query<(&Camera, &GlobalTransform)>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
) {
    if !keyboard.just_pressed(KeyCode::KeyG) { return; }
    let window = windows.single();
    let (camera, camera_transform) = camera_q.single();
    let Some(cursor_pos) = window.cursor_position() else { return; };
    let Ok(world_pos) = camera.viewport_to_world_2d(camera_transform, cursor_pos) else { return; };
    commands.spawn((
        GravityWell { strength: GRAVITY_WELL_STRENGTH },
        Transform::from_xyz(world_pos.x, world_pos.y, 0.0),
        Mesh2d(meshes.add(Annulus::new(20.0, 25.0))),
        MeshMaterial2d(materials.add(Color::srgb(5.0, 0.5, 5.0))),
    ));
}

fn apply_gravity_wells(
    mut bodies: Query<(Forces, &GlobalTransform), With<PhysicsBody>>,
    wells: Query<(&GravityWell, &GlobalTransform)>,
) {
    for (mut forces, body_transform) in &mut bodies {
        let body_pos = body_transform.translation().truncate();
        for (well, well_transform) in &wells {
            let well_pos = well_transform.translation().truncate();
            let diff = well_pos - body_pos;
            let distance_sq = diff.length_squared();
            if distance_sq < 1.0 { continue; }
            let direction = diff.normalize();
            let magnitude = well.strength / distance_sq;
            forces.apply_force(direction * magnitude);
        }
    }
}

fn cap_body_count(mut commands: Commands, bodies: Query<(Entity, &PhysicsBody)>) {
    let mut body_list: Vec<(Entity, u32)> = bodies.iter().map(|(e, pb)| (e, pb.spawn_order)).collect();
    if body_list.len() > MAX_BODIES {
        body_list.sort_by_key(|(_, order)| *order);
        let to_remove = body_list.len() - MAX_BODIES;
        for (entity, _) in body_list.iter().take(to_remove) {
            commands.entity(*entity).despawn();
        }
    }
}

fn toggle_debug(
    mut commands: Commands,
    keyboard: Res<ButtonInput<KeyCode>>,
    debug_config: Option<Res<PhysicsDebugConfig>>,
) {
    if keyboard.just_pressed(KeyCode::KeyD) {
        match debug_config {
            Some(config) => {
                if config.enabled {
                    commands.insert_resource(PhysicsDebugConfig { enabled: false, ..default() });
                } else {
                    commands.insert_resource(PhysicsDebugConfig { enabled: true, ..default() });
                }
            }
            None => {
                commands.insert_resource(PhysicsDebugConfig { enabled: true, ..default() });
            }
        }
    }
}

Your sandbox now has real gravitational physics. Bodies orbit, cluster, and slingshot between wells. The debug view lets you peek behind the curtain at the collider shapes driving it all. Next up, you will push things even further with an explosion system in the Secret Mission.

Secret mission

Add an Explosion Impulse System

Your sandbox has gravity wells that pull things in. What about the opposite? In this challenge, you'll build an explosion system that blasts all nearby bodies away from a point with radial impulse. Press E to detonate at the cursor, and watch the physics chaos unfold.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The physics_sandbox/ project directory on your Desktop (source code, compiled artifacts, Cargo cache).

Keep everything running

No action needed. Choose this if you want to keep experimenting with your physics sandbox.

  • Your project lives in the physics_sandbox/ directory on your Desktop.
  • Run it anytime with cargo run from inside that directory.
  • The target/ folder inside the project can grow large (several GB) due to compiled dependencies. This is normal for Rust projects.

Pause - I'll come back to this later

Free up disk space from compiled artifacts while keeping your source code intact.

  • Navigate into your project directory and run the following command to remove compiled artifacts:
cd ~/Desktop/physics_sandbox
cargo clean
  • This deletes the target/ folder, freeing several GB of disk space.
  • Your source code in src/main.rs and Cargo.toml remain untouched.
  • When you return, run cargo run again and Cargo will recompile everything automatically.

Delete - I don't want to use this again

Remove all project files and start fresh. There are no cloud resources to tear down.

  • Delete the entire project directory by running this command:
rm -rf ~/Desktop/physics_sandbox
  • Confirm the folder is gone by running:
ls ~/Desktop | grep physics_sandbox
  • If the command returns nothing, the directory has been successfully removed.

That's a Wrap!

That's a Wrap!

Nice work. You just built a fully interactive 2D physics sandbox from scratch using Bevy and Avian2D. Shapes fall, collide, bounce, and orbit gravity wells in a neon-lit arena you designed and coded yourself.

You've learned how to:

  • Built a Bevy application from scratch using the Entity Component System architecture, where entities are IDs, components are data, and systems are behavior.
  • Integrated the Avian2D physics engine with rigid bodies, colliders, and forces to create a click-to-spawn system with randomized shapes and neon colors.
  • Created a custom gravity well system that queries entities and applies inverse-square forces, demonstrating how to write ECS systems that mutate game state in Rust.
  • Secret Mission: Implemented an explosion system with radial impulse falloff that blasts nearby bodies away from the detonation point.

Ready to quiz yourself?