An enclave for thoughts

Setting up planetary physics in Bevy

All code mentioned in this blog post can be accessed in https://git.stvnliu.me/homelab/martian-rescue-rs.git/tree/

Previously…

Recently I’d been involved in a game-jam-esque event the school organised, from which, while a working/playable copy of the game could not be completed on time, I was able to simulate rudimentary gravitational physics between stellar bodies. In the context of the game proposal, this was used to create a revolving system in which the player spaceship must take advantage of to obtain enough linear velocity to reach the objective planet.

A solid foundation

To get started, we need to make sure that physically, we understand what’s going on. To evaluate the magnitude of a gravitational force between two stellar bodies with masses and , the formula is given by:
This formula is the mathematical basis upon which we proceed with development.

Scaffolding

To simulate these interactions, we can take advantage of avian2d real-time physics plugin for the Bevy engine.

use bevy::prelude::*;
// the avian library is split into avian2d and avian3d. For simplicity, let's use avian2d for now, although most other things are the same.
use avian2d::prelude::*;
fn main() {
	let app = App::new()
		.insert_resource(WinitSettings::game())
		.add_plugins(DefaultPlugins)
		.add_plugins(PhysicsPlugins::default())
		.add_systems(Startup, (
			/* TODO we will add these systems later */
		))
		.add_systems(Update, (
			/* TODO we will add these systems later */
		))
		.run();
}

Great! Now we have an interface ready for implementing the physics objects. We use two systems, one triggering in the Startup phase, setting up all the stellar bodies we want to simulate; and one triggering every Update phase, updating the physical properties.

Prelude: some custom components

Due to the way that Bevy Entities are queried in systems, it would be good if we had custom bevy Components that are set up such that planetary data can be accessed in a contained way (instead of being splattered all over the place).

use bevy::prelude::*;
#[derive(Component)]
struct Planet {
	display_name: String,
	planet_mass_kg: f32,
	radius: f32,
}
#[derive(Component)]
struct Player {
	display_name: String,
	velocity: f32,
	mass_kg: f32,
}

The reason we may want to do this is to use queries like Query<(&Sprite, &Transform), (With<&Planet>, Without<&Player>)>, to be more accurate and precise when indicating what query we would like to fetch from the world.

The setup

use bevy::prelude::*;
use avian2d::prelude::*;
pub fn setup_planet(mut cmd: Commands) {
    let start_planet = Planet {
        display_name: String::from("Earth"),
        planet_mass_kg: 15000.0,
        radius: 50.0,
    };
    let collider_radius = (&start_planet).radius;
    let planet_mass_kg = (&start_planet).planet_mass_kg;
    cmd.spawn((
        start_planet,
        RigidBody::Static,
        Transform::from_xyz(0.0, 0.0, 0.0),
        Sprite::from_color(Color::WHITE, Vec2 { x: 50.0, y: 50.0 }),
        Collider::rectangle(collider_radius, collider_radius),
        Mass(planet_mass_kg),
    ));
}

In this code, we setup a new planet (aka. new stellar object), and use components belonging to the planet to declare a RigidBody, Collider, and to set a Mass component for it to become a physics object. We might prefer to let it be a stable point of reference, so it will be a RigidBody::Static that is not affected by any physical force.

Whereas for the player, it will be experiencing forces, so we add an extra component ExternalForce to be applied to the player. Initially this will have a value of zero (ExternalForce::ZERO because verbosity can be good), but we don’t need to worry about it too much because soon we will also modify it to apply our own external forces (gravitation).

Note: We set non-persistent force, because avian2d defaults to a constant external force that persistently acts.

use bevy::prelude::*;
use avian2d::prelude::*;
pub fn setup_player(mut commands: Commands) {
    commands.spawn((
        Player {
            velocity: 20000.0,
            display_name: Text2d("Main player".to_string()),
            mass_kg: 100.,
        },
        RigidBody::Dynamic,
        Transform::from_xyz(0., 70., 0.),
        ExternalForce::ZERO.with_persistence(false),
        Sprite::from_color(Color::BLACK, Vec2 { x: 10., y: 10. }),
        Collider::rectangle(10., 10.),
    ));
}

Updating forces

Time for the meat. Now we can apply a force by calculating its magnitude, then applying it to the normalized vector towards the center of the large mass. Let’s make a helper function that does this:

use bevy::prelude::*;
fn calculate_force_vector(
    mass_actor: f32,
    mass_incident: f32,
    pos_actor: Vec2,
    pos_incident: Vec2,
) -> Vec2 {
    let grav_const = 15.0;
    (grav_const * mass_actor * mass_incident / (pos_actor.distance_squared(pos_incident)))
        * Vec2 {
            x: pos_actor.x - pos_incident.x,
            y: pos_actor.y - pos_incident.y,
        }
        .normalize()
}

Now we just need to supply the two masses, their transform position on the “universal grid”, and the things will be calculated for us. We can then apply this force by:

fn update_gravitational_forces(
    mut query_planet: Query<(Entity, &Planet, &Transform)>,
    mut query_object: Query<(Entity, &Player, &mut ExternalForce, &Transform)>,
) {
    let (_es, planet, transform_planet) = query_planet.single_mut();
    let (_ed, player, mut player_force, transform_player) = query_object.single_mut();
    let f0 = calculate_force_vector(
        planet.planet_mass_kg,
        player.mass_kg,
        transform_planet.translation.xy(),
        transform_player.translation.xy(),
    );
    exd.apply_force(f0);
}

Et voila?

Now we just put everything together, spawn in a Camera2d to observe the sprites moving about, and we are done with adding gravitation to our game!

, , , — Mar 26, 2025