Bevy / Rust
16 min read

Building a Space Sim in Bevy - Part 3: Starfield Background

Creating an immersive parallax starfield that gives depth and motion to our space game using multiple layers and seamless wrapping.

Share:

Building a Space Sim in Bevy - Part 3: Starfield Background

You know what’s wrong with our game right now? The black void. It’s too empty. When you fly your ship, there’s no sense of motion - just a blue rectangle sliding across darkness.

Real space games solve this with starfields. Not just static stars, but parallax - layers of stars that move at different speeds, creating an illusion of depth. Far stars barely move. Near stars whip past. Suddenly you feel like you’re flying through space.

Let’s build that.

Understanding Parallax

The parallax effect mimics how we perceive depth in real life. When you’re driving, distant mountains seem to crawl across your view while nearby trees fly past. Same principle here.

The math is simple:

apparent_position = base_position - (camera_position × parallax_factor)

The parallax_factor controls how much each layer responds to camera movement:

  • factor = 0.1 → Far stars, barely move (90% parallax)
  • factor = 0.3 → Middle stars
  • factor = 0.6 → Near stars, move significantly

A factor of 1.0 would mean no parallax - the stars would move exactly with the camera, appearing stationary.

Setting Up Our Components

We need a few things: a way to mark stars, their parallax layer, and their “true” position in the world:

// src/background/components.rs
use bevy::prelude::*;

/// Marker for star entities
#[derive(Component)]
pub struct Star;

/// How much this star responds to camera movement
#[derive(Component)]
pub struct ParallaxLayer {
    pub factor: f32,
}

impl ParallaxLayer {
    pub const FAR: f32 = 0.1;
    pub const MID: f32 = 0.3;
    pub const NEAR: f32 = 0.6;
}

/// The star's "home" position before parallax is applied
#[derive(Component)]
pub struct BasePosition {
    pub position: Vec2,
}

The BasePosition is key - we store where each star “really” is, then calculate its apparent position every frame based on camera position. This lets us do seamless wrapping later.

Configuration

I like making my settings configurable. This way I can tweak the look without recompiling:

#[derive(Resource)]
pub struct StarfieldConfig {
    pub area_size: f32,         // How big the star field is
    pub stars_per_layer: usize, // Stars in each layer
    pub layers: Vec<LayerConfig>,
}

pub struct LayerConfig {
    pub parallax: f32,
    pub star_size: f32,
    pub alpha: f32,  // Far stars are dimmer
}

impl Default for StarfieldConfig {
    fn default() -> Self {
        Self {
            area_size: 3000.0,
            stars_per_layer: 200,
            layers: vec![
                LayerConfig { parallax: 0.1, star_size: 1.0, alpha: 0.3 },  // Distant
                LayerConfig { parallax: 0.3, star_size: 1.5, alpha: 0.5 },  // Middle
                LayerConfig { parallax: 0.6, star_size: 2.5, alpha: 0.8 },  // Near
            ],
        }
    }
}

Three layers with different sizes and brightnesses. Far stars are tiny and dim. Near stars are bigger and brighter. This reinforces the depth illusion.

Spawning the Stars

Now let’s scatter some stars. We’ll use the rand crate for random positions (add rand = "0.8" to your Cargo.toml):

use rand::Rng;

pub fn spawn_starfield(
    mut commands: Commands,
    config: Res<StarfieldConfig>,
) {
    let mut rng = rand::thread_rng();
    let half_size = config.area_size / 2.0;

    for layer in &config.layers {
        for _ in 0..config.stars_per_layer {
            // Random position within our area
            let x = rng.gen_range(-half_size..half_size);
            let y = rng.gen_range(-half_size..half_size);
            let base_pos = Vec2::new(x, y);

            // Slight brightness variation for visual interest
            let brightness = rng.gen_range(0.7..1.0);

            commands.spawn((
                Star,
                ParallaxLayer { factor: layer.parallax },
                BasePosition { position: base_pos },
                Sprite {
                    color: Color::srgba(1.0, 1.0, 1.0, layer.alpha * brightness),
                    custom_size: Some(Vec2::splat(layer.star_size)),
                    ..default()
                },
                Transform::from_translation(base_pos.extend(-100.0)),
            ));
        }
    }
}

Notice the Z coordinate is -100.0. In Bevy 2D, Z determines draw order. Negative values are “behind” positive values, so our stars render behind ships and stations.

The Parallax System

Here’s where the magic happens. Every frame, we recalculate each star’s apparent position:

pub fn update_parallax(
    camera: Query<&Transform, With<MainCamera>>,
    mut stars: Query<(&ParallaxLayer, &BasePosition, &mut Transform), Without<MainCamera>>,
    config: Res<StarfieldConfig>,
) {
    let Ok(camera_transform) = camera.get_single() else { return };
    let camera_pos = camera_transform.translation.truncate();
    let half_size = config.area_size / 2.0;

    for (layer, base, mut transform) in stars.iter_mut() {
        // Apply parallax offset
        let offset = camera_pos * layer.factor;
        let mut apparent_pos = base.position - offset;

        // Wrap to keep stars in view (explained below!)
        apparent_pos.x = wrap_coord(apparent_pos.x, camera_pos.x, half_size);
        apparent_pos.y = wrap_coord(apparent_pos.y, camera_pos.y, half_size);

        transform.translation.x = apparent_pos.x;
        transform.translation.y = apparent_pos.y;
    }
}

The Without<MainCamera> filter on the stars query is important - without it, Bevy would complain about conflicting queries since we’re reading camera transform and writing star transforms.

The Wrapping Trick

If we just applied parallax, stars would eventually scroll off-screen and we’d see empty void. The solution is seamless wrapping:

fn wrap_coord(pos: f32, camera: f32, half_size: f32) -> f32 {
    let relative = pos - camera;  // Position relative to camera

    if relative > half_size {
        // Star is too far right - wrap to left
        pos - half_size * 2.0
    } else if relative < -half_size {
        // Star is too far left - wrap to right
        pos + half_size * 2.0
    } else {
        pos
    }
}

This checks if a star has scrolled too far from the camera view and teleports it to the opposite side. The player never sees it happen because the star was off-screen anyway.

The result? Infinite scrolling with just 600 stars (200 per layer). No matter how far you fly, there are always stars.

When to Update

The parallax system needs to run after the camera moves but before rendering. The PostUpdate schedule is perfect:

// src/background/plugin.rs
impl Plugin for BackgroundPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<StarfieldConfig>()
           .add_systems(Startup, spawn_starfield)
           .add_systems(PostUpdate,
               update_parallax.run_if(in_state(GameState::Playing))
           );
    }
}

Try It Out

Run the game and fly around. You should immediately feel the difference:

  • Depth: Near stars rush past while far stars drift lazily
  • Motion: Even gentle movement feels like high speed thanks to the near stars
  • Atmosphere: The randomized brightness creates visual interest

Try zooming your camera out if you’ve implemented that - the parallax still works!

Performance

You might wonder: 600 entities for just a background? Is that expensive?

Not at all. Bevy’s ECS is built for this. The archetype-based storage means iterating over stars is essentially iterating over a dense array. I’ve tested with thousands of stars with no framerate impact.

The only real cost is draw calls, and simple sprites are extremely cheap on modern GPUs.

What We Learned

  • Parallax effect: Creating depth with differential motion
  • Base position pattern: Storing “true” positions separate from displayed positions
  • Seamless wrapping: Infinite scrolling with finite entities
  • Z-ordering: Using the Z coordinate to layer 2D elements
  • PostUpdate schedule: Running systems after main game logic but before rendering

Making It Your Own

Here are some variations you could try:

  • Colored stars: Add occasional blue, red, or yellow stars
  • Nebulae: Large, very transparent sprites in the far layer
  • Twinkling: Randomly vary alpha over time
  • Constellations: Spawn specific star patterns in certain locations

The system we built is flexible enough to handle all of these.

What’s Next

Our void now feels like space! But it’s still empty of things to do. In Part 4, we’ll add space stations - the economic hubs where NPC traders will buy and sell goods.

We’re building a universe!