johan helsing.studio

Cargo Space devlog #7 - Generating space backgrounds

I settled on an RNG crate, added "endless" procedural stars backgrounds, and made sure each run's star background was unique across runs and consistent for all players in a session.

This post is part of a series on my game, Cargo Space. It's a coop 2D space game where you build a ship and fly it through space looking for new parts, fighting pirates and the environment.

You can find an overview of the previous entries here

My one-line pitch for Cargo Space often includes that it's a roguelike, meaning its worlds will be uniquely generated on every run.

While I already had some procedural generation in my game. That is: the asteroids/planets (the big blueish-purple things) were generated using a noise library, they were always the same across each session.

And the things that did vary (the background stars) were not deterministic across machines.

Making it deterministic

It might be weird to obsess about something purely cosmetic being deterministic across peers, however, though I think is actually somewhat important, as players may use the environment when communicating with each other. For instance, they may say things like "let's meet up near that big cluster of stars".

So I started by fixing the arguably "buggy" behavior first, making the star generation deterministic.

The star field was one of the first things I made for the prototype, and I'd simply used rand::thread_rng to get something up and running quickly:

let mut rng = rand::thread_rng();

for _ in 0..10_000 {
    let r = 100.0;
    let p = vec3(rng.gen_range(-r..r), rng.gen_range(-r..r), 0.0);
    commands.spawn(SpriteBundle {
        sprite: Sprite {
            color: Color::WHITE,
            custom_size: Some(vec2(0.02, 0.02)),
            ..default()
        },
        transform: Transform::from_translation(p),
        ..default()
    });
}

thread_rng and the ThreadRng it returns is a poor fit for deterministic procedural generation. It's seeded on the first call, and we do not control the seed. So I needed to replace it with another algorithm/library.

Picking a random number generator (RNG)

Bevy is a quite open and modular game engine, and as such, it doesn't come with a bundled random number generator like for instance Unity and Godot do.

Instead we have to bring our own, this makes Bevy a flexible engine, but also puts the burden on us to pick a good and appropriate implementation.

While rust's rand crate and its thread_rng is sort of a "default" pick in rust, as mentioned it's a great pick for my game, as it aims to be cryptographically secure first and foremost, which is a non-goal for my needs.

Here's my list of actual goals for an RNG:

  1. Seedable: It should be possible to seed the RNG with a given number so it generates the same results each time
  2. Portable/cross-platform deterministic: The same seed should produce the same results across platforms
  3. Compatibility with the rest of the rust ecosystem (implementing rand::RngCore)
  4. Decent statistical qualities (not necessarily cryptographically secure, but avoid obvious artifacts and patterns)
  5. Popular and well-maintained, including its dependencies
  6. Decent performance

I'd like it to implement rand::RngCore as that makes it easier to switch out if I discover I made the wrong choice. For instance: maybe I would want to trade performance for better statistical qualities, or vice-versa at some point. That rules out otherwise tempting crates like nanorand and fastrand.

The Rust Rand Book has a section on the rand compatible generators they provide.

Comparing their list of PRNGs (non-cryptographic implementations) against my list of requirements, we can rule out the ones with quality two stars and below (requirement 4).

We can also rule out SmallRng because it's not portable (requirement 2), `

That leaves Pcg32, Pcg64, Pcg64Mcg and Xoshiro256PlusPlus.

I ended up picking Xoshiro256PlusPlus simply because it had the best performance of the four. As mentioned, since it's implementing RngCore it would be easy to switch it out later.

I created a type alias in my utils module to make this easy, and also because Xoshiro256PlusPlus is annoying to type...

pub type GameRng = rand_xoshiro::Xoshiro256PlusPlus;

And I could make my star field generation deterministic across different players:

let seed = 123;
let mut rng = GameRng::seed_from_u64(seed);

for _ in 0..10_000 {
    let r = 100.0;
    let p = vec3(rng.gen_range(-r..r), rng.gen_range(-r..r), 0.0);
    commands.spawn(SpriteBundle {
        sprite: Sprite {
            color: Color::WHITE,
            custom_size: Some(vec2(0.02, 0.02)),
            ..default()
        },
        transform: Transform::from_translation(p),
        ..default()
    });
}

Now I had a star field that looked exactly the same for all players at least.

Seeding

But being deterministic and having a fixed seed, it's also now exactly the same each time the game is launched, which is not roguelike-like behavior.

What I needed to do, was make sure that the seed in the code above is different for each session, but also the same across all clients.

In previous multiplayer games, I've done this using a hack involving hashing the matchbox players' PeerIds which are randomly generated uuids for each session. Something like this:

for uuid in players {
    uuid.hash(&mut hasher);
}
let seed = hasher.finish();

However, it's pretty brittle because if the players' ids suddenly become non-random, which they very well might in an upcoming release of Matchbox (e.g. if supporting player accounts), it won't work anymore. It also rules out the possibility of playing the same world twice or have things like a daily challenge with a high score... and it makes debugging issues with procedural content harder. The list goes on.

What I ended up doing instead was to use the new reliable channels of Matchbox which I'd just set up and simply added a seed field to my existing Ready message which the players already sent when were ready to start the game:

pub enum CargoMessage {
    // ...
    Ready {
        seed: u64,
    },
}

I could now simply let each player send their current time as seed. When all players are ready, and the game is about to start, we update a Seed resource:

#[derive(Resource, Component, Reflect, Debug, Clone, Copy, Deref, DerefMut, From)]
#[reflect(Component, Resource)]
pub struct Seed(pub u64);

fn start_session(
    // ...
    mut seed: ResMut<Seed>,
) {
    // ...

    let seeder = lobby
        .players
        .iter()
        // the peer with the smallest peer_id gets to decide the seed
        // (to make it deterministic)
        .min_by(|a, b| a.peer_id.cmp(&b.peer_id))
        .unwrap();

    // set our seed from the other player's seed
    **seed = seeder.seed;

Alternatively, I could have xor'ed the players' seeds together, which would probably have been appropriate if I was making a competitive game for instance.

Letting one peer decide has its own advantages, however. Most importantly, it makes it easy to manually specify a seed on the command line, which, as I mentioned, is really nice for debugging.

Now, in my star generation system, I can simply use that Seed resource when creating an RNG:

let mut rng = GameRng::seed_from_u64(**seed);

// same as before:
for _ in 0..10_000 {
    let r = 100.0;
    let p = vec3(rng.gen_range(-r..r), rng.gen_range(-r..r), 0.0);
    commands.spawn(SpriteBundle {
        sprite: Sprite {
            color: Color::WHITE,
            custom_size: Some(vec2(0.02, 0.02)),
            ..default()
        },
        transform: Transform::from_translation(p),
        ..default()
    });
}

And I finally had session-unique star backgrounds deterministic across all peers.

A more interesting background

Now that I had a mechanism in place for seeding the RNG, it was time to make the background a bit more interesting than just tiny white dots, so I added some bigger stars with a randomized size

I won't win any awards with this, and might have to tone it down a little, but certainly a bit more interesting, and good enough for now.

"Endless" space

The next problem to tackle, was that I only created stars in a small area around the player. Since this is a space exploration game, it's a bit unfortunate that we currently run out of stars very quickly:

However, since stars are entities, I can't simply spawn an infinite amount of them, even just a couple of tens of thousands would tank performance, so I made a system that spawns and despawns "sectors" of stars when they come into view.

In the video you can see sectors of the star background (yellow rects) spawning and despawning as the player camera (red square) moves around.

If you paid close attention, you might notice that the star sectors are all identical... That's because they all use the same global seed when spawning theirs stars.

I solved this by combining the global seed with a hash of the sector coordinates:

fn spawn_star_fields(
    seed: Res<Seed>,
    // ...
)
    // ...
    commands.spawn(StarFieldBundle {
        seed: seed.hash_with(grid_pos),
        // ...
    });

hash_with is a generic method that lets me combine any hashable struct with a seed to produce a new seed:

impl Seed {
    /// derive a new seed by hashing with the given value
    pub fn hash_with<T: std::hash::Hash>(self, other: T) -> Self {
        let mut hasher = FixedState.build_hasher();
        self.hash(&mut hasher);
        other.hash(&mut hasher);
        Self(hasher.finish())
    }
}

This means I can easily insert combine a bunch of seeds as needed. For instance I could also do this for the star field's seed:

seed: seed.hash_with(("stars", grid_pos)),

To be relatively sure that it won't be too similar to other procedurally generated stuff.

Adding depth with parallax layers

While the star field system now worked more or less as intended, it looked a bit too flat... So I added another layer of stars and made both layers follow the camera with slightly offset speeds.

This did wonders to the sense of depth in the game.

And that concludes this pass on the background. I will probably revisit it later, but it's definitely not the most pressing matter anymore.

Getting lost

While playing the game now, I noticed it was quite easy to get lost and not find my way back to where I started.

There are a couple of obvious ways this could be solved:

Manual zooming to see a bigger area?

While I kind of had zooming functionality implemented already (the user can scroll the mouse wheel to zoom in and out), I intended it to only be a debug feature.

Given the opportunity, players will optimize the fun out of a game.

I'm worried that if I give players the option to control zoom they will end up always playing at the most zoomed out level, because that gives them the best information about approaching enemies and planning their journey. I suspect this would be bad, because it takes some of the immediacy and surprise out of the game. You can never be ambushed by pirates if you see them coming from way off in the distance.

Also, I think you'd have a better feeling and connection with the character if you see them more up-close.

So I opted not to do always-available manual zooming. I am however thinking that it might be a cool idea for a ship upgrade. Maybe you could have a radar machine, the user could approach and be able to zoom out temporarily and get an overview of the area.

Minimaps?

Maybe strong words, but I hate minimaps with a passion, at least the way they're implemented in most games.

I think they're an overused and lazy solution to the problem that has even worse implications than allowing manual zooming.

Many games suffer from the fact that their minimaps are waaay too useful, more useful than the game itself. The consequence of this is that games with beautifully rendered and otherwise immersive worlds incentivize the player to stare at an area that is less than 10% of the screen for maybe 80% of the time.

Why bother making a good-looking game if you are telling players not to look at it?

/rant

If you still want more: some other people having the same rant.

Not an interface problem!

Perhaps you saw this coming a mile off; I believe the best solution is to make sure the world itself is inherently easy to navigate.

At the very least I think it's the best place to start, then UI solutions can be reconsidered as a fallback if all else fails.

So the question I had to ask myself now was: how do I make the world easy to navigate?

Weenies

One commonly used tool in level design is called "weenies". It's a term borrowed from Walt Disney's theme park design for visually interesting objects visible from far away that draw the guests into the park.

It's also how people usually navigate in the real world, you see an interesting tall building in a city, and use it as reference to know where you are, or you see a tall mountain and use it to navigate through smaller hills.

The connection and application is obvious for 3d games, but how does it translate to my open-world, procedurally-generated 2d game?

My 2d game, being a 2d game, has an orthographic perspective, and it's pretty close up, and I just said I was not going to let the player zoom in and out at will, so we can't really have something visible from far off... or can we?

While this is definitely the case for the actual gameplay objects, remember those parallax layers I just added? It turns out they're the perfect place to put weenies.

To test out the idea, I had a go at adding some distant and interesting objects:

I also had a bit of fun increasing the density of the galaxy layers:

Further work on navigation

This is it for now. I have more ideas about how to make the world more unique, interesting and navigatable. Mostly about limiting and managing where the actual interactive objects in the world are. Maybe it makes sense to let the player follow a trail of wreckage, or be blocked by a dense asteroid field etc. All this feels like things I should postpone a bit, though.

Updates

For future updates, join the discord server or follow me on Mastodon.

Comments

Loading comments...