Building a World That Feels Real
Good world generation is difficult.
I'm a huge RTS and sim game buff, and I wanted to combine my love of games like RimWorld, Dwarf Fortress, Caves of Qud, and Songs of Syx with games like Total War and other grand strategy games.
This is, to say the least, an ambitious project.
The end goal is to have 100k rich, ticking entities in a world where you can grow your towns and then quest out with armies to other towns that are themselves organically growing. I couldn't settle on an engine, so I've been using Rust and hecs as the underlying ECS, handrolling the rest as I go. For the Bevy fans, I apologize, but I didn't want to fight a rapidly changing API every release on a project I expect to take the next five to ten years.
Anyway, in the words of Carl Sagan: "If you wish to make an apple pie from scratch, you must first invent the universe."
For me, that meant generating a rich world that felt real. And I gave myself one rule to hold the whole way: the world has to be a pure function of its seed. Same seed, same world, bit for bit, every single run. That one constraint shaped every decision that follows, and it is the thing that kept a month of rewrites from collapsing into chaos.
I'd dipped my toes into gamedev before and knew the usual generation algos: Voronoi, wave function collapse, and similar systems for small instances. But an entire world, with history, was new to me. I've spent the best part of the last month building, deleting, and rebuilding a generation system that I ended up pretty happy with, and I learned a lot along the way about how our own planet works.
The first attempt: just noise
Here's how nearly all games do it: noise. Noise is fantastic, but it can feel pretty rough on its own. The solution? Layer more noise, at different intensities. I started here, and on the first try it was... OK. I wanted to take several passes, with hydration systems spawned from the ocean that would rain down on land of sufficient elevation. It turns out the windward side of a mountain catches a lot of rainfall, and an arid zone forms on the leeward side. Something I knew vaguely from visiting Hawaii and noticing the wet and dry sides of the islands, but had never really thought about. Turns out it's very important.
Then, temperature. Temperature started a lot simpler: a product of latitude and elevation. The equator at sea level was hot, and the farther you got from there, in distance or in height, the colder it got. Pretty simple.
Next, erosion. Erosion was harder. How much was too much? And what actually erodes a world (water, time, and, surprisingly, temperature)? Out of erosion came valleys and rivers. The game is, for now, fully 2D, so it keeps a hidden elevation layer underneath the map, with plans to make mountains simply impassable.
Finally, biomes. The nice thing about doing climate properly is that biomes are not painted on by hand. They fall out of the numbers you already have: temperature, moisture, and elevation. Three values go in, a biome comes out. Simplified, the core of it looks like this:
fn classify(temp_c: f32, wetness: f32) -> Biome {
match temp_c {
t if t < TUNDRA => Biome::Tundra,
t if t < BOREAL => if wetness < ARID { Biome::Tundra } else { Biome::Taiga },
t if t < TEMPERATE => if wetness < SEMIARID { Biome::Grassland }
else { Biome::TemperateForest },
_ /* hot */ => match wetness {
w if w < ARID => Biome::Desert,
w if w < SEMIARID => Biome::Savanna,
_ => Biome::Rainforest,
},
}
}
Wet and hot gives rainforest; cold, dry, and near the poles gives tundra. Get the climate right and the map paints itself.
Initially, the map was... OK. I had issues with my water algo, and overall it needed some serious TLC. I thought I could do better, though.
The first pass elevation and river view, layered noise. Serviceable, but flat and a little lifeless.
A detour through tectonics
I came across this paper on procedural tectonic planets, and it opened my eyes. What if I simulated more? What if I started with tectonic plates via Voronoi and gave each one a random direction? Where they collided, they'd buckle up into mountains; where they pulled apart, I'd get seams between landmasses. Then I could erode those mountains organically. Early on this turned out really neat, but really wonky. I even got my erosion algo to somehow carve peaks, which I still don't fully understand. But, as Bob Ross said, a happy little accident.
Tectonic plates plus erosion. Those peaks were an accident I never fully explained.
As I pursued this, though, some issues became apparent. By this point I'd added my main races: Northmen, lizardfolk, elves, and the like. Lizard people liked warm, wet climates; the Northfolk liked the cold; and so on.
The problem was twofold. First, these maps were incredibly hard to fine-tune, even with seeds, simply by the nature of how they were built. Second, the continental landmasses were just... wonky. I was advised to add noise to the edges, but I didn't want to go down that route. It would have broken my one rule: a hand-tweaked coastline is no longer a clean function of the seed, and I wanted every world reproducible from a single number. On top of that, I'd noticed a problem with the water and river system.
The river system came out both prolific and chaotic, and the coastlines were awkward.
The issue was this: on Earth, a lot of our coastlines are mountains, just eroded down. But my mountains, even after erosion, were catching most of the water, so the rivers came out both extremely prolific and wonky. It was neat for worldgen, but bad for an actual video game. It wasn't fun, it didn't look right, and the landmasses were awkward shapes overall. So, back to scratch. (I might revisit the whole tectonic idea later; nothing's set in stone yet.)
Another tectonic seed: interesting geology, awkward gameplay shapes.
Pretty to look at, painful to fine-tune.
Back to ground zero
So, back to the start. Where to go from here?
I revisited noise, and took a lot more care this time, giving myself plenty of knobs to turn. This was a decent foundation. I set a target of 60% of the world being ocean, and the trick to hit it is simpler than I expected: sort every cell's elevation, walk to the 60th percentile, and call that height sea level. Everything below it floods. That turns the land-to-water ratio into a single knob instead of something you chase by hand, seed after seed.
Then I reintroduced the tradewinds I'd had in every iteration: easterlies and westerlies running east to west, plus a north-south deflection to mimic the Coriolis effect.
Mountains this time were noise again, but I had to sharpen them into ridges. Even though the player never sees the ridge in the 2D world or in gameplay, it matters for catching moisture and feeding rivers, otherwise you just get cones of rivers cascading straight down off a smooth cap.
From here, biomes and temperature were mostly the same. But it was still kind of off, so, yet again, I reached back to nature for inspiration. A starting point.
Back to noise, but with far more control. A better starting point.
Wind, water, and currents
I wasn't quite done, though. The bands of wind and rain were very intense, and striped the map in a way that felt fake.
Before: the rain came out in hard horizontal stripes, obviously artificial.
This time I used more organic wind. Instead of pushing evaporated water across the map in straight bands, I built a pressure field (high over the subtropics, low at the equator and the poles, with extra lows wherever the land or sea runs hot) and then took the wind as the curl of that pressure. The trick is that wind runs along the pressure contours rather than straight down them, so it swirls into gyres on its own:
pressure[i] = subtropical_high(lat) - equatorial_low(lat) - subpolar_low(lat)
- THERMAL_K * (temperature[i] - mean_temp);
wind_u[i] = -pressure_dy * hemisphere - AGEO * pressure_dx;
wind_v[i] = pressure_dx * hemisphere - AGEO * pressure_dy;
The wind then flows over the land, and with more relaxed precipitation rules (moisture can bleed sideways across several neighboring cells instead of dumping straight down), things started to look a lot better.
Rivers come after an erosion pass. First I fill the little depressions in the terrain with a priority-flood, so water never gets permanently stuck in a single-cell pit. Then each cell points at its steepest downhill neighbor, and I accumulate flow downstream; wherever enough water piles up, that's a river. If you go implementing this yourself, the keywords to search are priority-flood depression filling and flow accumulation over a filled heightmap. That second one is shared with the erosion pass, which is no accident: erosion and rivers are the same water, just measured at different moments.
Finally, I added coastal ocean currents (warm water running up the east coasts of continents, cold water down the west) and let those currents feed evaporation. Then I ran the whole moisture model for 100 passes so it could actually settle instead of raining out the instant it hit shore. With that, I was finally happy with the overall look of the world.
Temperature: hot at the equator, frozen at the poles, cooling up every mountain.
Biomes: the product of temperature, moisture, and elevation.
After: precipitation with wet coasts and windward slopes, and drier continental interiors.
What I learned
I didn't bother rebuilding my debug 3D view of the world, but the hot regions are hot and the cold regions are frozen (I still haven't added ice at the poles as its own floating biome). Overall, though, the world feels much more real than it did before.
The rule paid for itself. Because every world is a pure function of its seed, tuning never turned into guesswork: change one constant, regenerate, and the only thing that moved was the thing I touched. No hidden state, no "well, it looked different last time." Seed 42 is the same planet on my machine today as it will be on a player's machine next year.
For anyone wondering what all this costs: the whole pipeline is single-threaded and generates a 256x256 world in about 0.2 seconds, a 512x512 in just under a second, and a chunky 1024x1024 in roughly 4.5 seconds. Two passes eat almost all of that, and almost exactly evenly: erosion and the 100-pass moisture settle, about 46% of the runtime each. Everything else (temperature, wind, ocean currents, biomes) is rounding error by comparison. I could tune them down, and I still might, but for now I think these are the two most important for making the world feel real at a foundational level.
Some advice for anyone else doing this: sometimes you have to do things the video-game way when you're building a video game. There's a reason so many devs reach for noise and deterministic seeds. It's not that they're lazy, it's just better suited to the medium. At the end of the day it's art, and while a painter could go out and work exclusively in mud and sticks, sometimes it's fine to just grab Brown #40.
Next up is turning this coarse world into something you can actually walk around in: stitching local, explorable zones onto each cell of the map, with those 100k entities finally ticking away on top. That's a post for another day.