Concept · Building the World

Procedural Generation

Nobody sculpted FloraForge's mountains, and no artist painted its coastlines. The entire 65-kilometre world — every hill, forest, desert and river — is computed from rules and math, starting from a single number. This page explains how that works: what noise is, how layers of it become terrain, and how the engine turns two numeric fields into a world with biomes and rivers.

Content from rules, not from hands

The traditional way to build a game world is to build it: artists place every rock, sculpt every valley, paint every texture. Procedural generation replaces hand placement with a function. Instead of storing the height of the ground everywhere, you store a rule that can answer the height of the ground anywhere — and the landscape falls out of that rule.

The numbers make the case. FloraForge's world is a 256 × 256 grid of 256-metre chunks, each sampled on a 129 × 129 grid — over a billion height samples if you tried to store them all. What the engine actually stores is one 32-bit integer: the seed (42, by default). Every random-looking choice in generation is derived from it, so the same seed always rebuilds exactly the same world, down to the position of each tree. You never save the mountains; you save the number that grows them.

Noise: randomness you can walk across

Pure randomness makes terrible terrain — independent random heights next to each other look like television static, not hills. The core ingredient of procedural worlds is noise: a function that looks random at a distance but is perfectly smooth up close, so neighbouring points always get similar values. FloraForge uses OpenSimplex noise, a member of the same family as the classic Perlin noise. It has three properties the engine leans on:

  • Deterministic. The same seed and coordinates always produce the same value — the foundation of the seed guarantee.
  • Smooth. Values vary continuously, so the terrain has slopes instead of cliffs of static.
  • Stateless. Any point can be evaluated on its own, without computing its neighbours first. That is what lets a chunk anywhere in the world be generated in complete isolation — the property chunk streaming depends on.

Octaves: one wave is a dune, three are a landscape

A single layer of noise produces blobby, same-sized lumps. Real terrain has structure at every scale: continents, ridgelines, and the small roughness underfoot. The standard fix — often called fractal Brownian motion, or fBm — is to stack several octaves of noise: each layer has a higher frequency (smaller features) and a lower amplitude (less height influence) than the last, and the sum has detail at every scale. FloraForge's height function uses three named octaves:

1 · Continental frequency 0.0001 — features ~10 km wide · amplitude 180 m + 2 · Ridges frequency 0.0009 · amplitude 45 m — creases from 1 − |noise| + 3 · Detail frequency 0.018 — bumps ~55 m wide · amplitude 6 m = The terrain profile — all three octaves summed sea level — valleys below it become ocean broad rise and fall: octave 1 peaks creased by octave 2, roughened by octave 3
Octave stacking, with FloraForge's real parameters. Each layer is smooth noise; the broad layer sets where the land rises, the ridge layer (note its sharp creases) adds mountain lines, and the detail layer adds surface roughness. Their weighted sum is the terrain.

The same recipe at different scales appears all over the engine — the sky's clouds are a five-octave fBm stack computed in a fragment shader. One refinement is worth pointing out: the ridge octave isn't used raw. The engine takes 1.0 - noise.abs(), which folds the noise at its zero crossings into sharp creases — that single absolute value is the difference between round hills and mountain ridgelines.

The 4D trick: a world without edges

FloraForge's world wraps: fly far enough east and you arrive from the west. Plain 2D noise would put an ugly seam at the wrap line, where two unrelated parts of the noise field meet. The engine's solution is geometric: map each world axis onto a circle, so that walking across the whole world travels exactly once around it. Two circles need four coordinates — together they trace a torus embedded in 4D space — so the engine samples 4D OpenSimplex noise on that torus surface. Coming back to your starting point means arriving at literally the same noise input, so the terrain is exactly periodic and the seam doesn't exist:

src/world_core/heightmap.rs — sampling noise on a torus (phase offsets trimmed)
fn torus_sample(noise: &OpenSimplex, x: f64, z: f64, frequency: f64) -> f64 {
    let l = WORLD_SIZE_METERS;
    // Whole noise cycles per world lap — snapping k to an integer is
    // what makes the field exactly periodic.
    let k = (frequency * l).round().max(1.0);
    let radius = k / TAU;
    let theta_x = TAU * x / l;
    let theta_z = TAU * z / l;
    noise.get([
        radius * theta_x.cos(),
        radius * theta_x.sin(),
        radius * theta_z.cos(),
        radius * theta_z.sin(),
    ])
}

pub fn sample_height(&self, x: f32, z: f32) -> f32 {
    let c = &self.config;
    let (broad, ridge_raw) = self.fields.sample(x, z);
    let ridges = 1.0 - ridge_raw.abs(); // fold into sharp creases
    let rough = Self::torus_sample(&self.detail, x as f64, z as f64,
                                   c.detail.frequency) as f32;

    broad * c.continental.amplitude + ridges * c.ridge.amplitude + rough * c.detail.amplitude
}

Elegance has a price: 4D noise is the single most expensive thing the generator does. Profiling showed that roughly 98% of the cost of generating a chunk is noise sampling — each 256-metre chunk evaluates 129 × 129 = 16,641 points, for both a height field and a moisture field. The engine claws much of that back with two tricks visible in the code above: the low-frequency octaves (self.fields) are precomputed once on a coarse global grid and merely interpolated per vertex, and the per-axis sine/cosine pairs are hoisted out of the grid loops — both bit-identical to the naive version, just cheaper.

From two numbers to a living world

Height alone gives geography but not character. The generator samples a second, independent noise field — moisture — and the pair classifies every point into a biome with rules simple enough to quote in full:

src/world_core/biome.rs — the whole biome classifier
pub fn classify(height: f32, moisture: f32, config: &BiomeConfig) -> Biome {
    if height > config.snow_height {        // above 165 m
        return Biome::Snow;
    }
    if height > config.rock_height {        // above 120 m
        return Biome::Rock;
    }
    if moisture < config.desert_moisture {  // drier than 0.3
        return Biome::Desert;
    }
    if moisture > config.forest_moisture {  // wetter than 0.62
        return Biome::Forest;
    }
    Biome::Grassland
}

Five biomes from two thresholds each — and because the inputs are smooth fields, the borders between them are natural, wandering transitions rather than straight lines. The biome then drives what grows there: which plant species appear, how densely, and where houses may stand. Those ecological rules have their own page, The Biology of FloraForge.

One feature refuses to be a pure per-point function: rivers. Water flows downhill across chunk boundaries and accumulates — a global phenomenon. So once per world load, the engine solves hydrology over a coarse grid spanning the whole world: it fills depressions, builds a drainage tree where every cell points downhill toward an ocean outlet, and accumulates upstream area. Cells that collect enough drainage become rivers, and a carve depth and wetness are baked into the grid (src/world_core/rivers.rs). Per-chunk generation then just subtracts the carved channel from the noise terrain — cheap, and globally consistent, because every chunk reads the same baked field.

Determinism pays twice

Because the whole pipeline is a pure function of the seed, the result is perfectly cacheable. FloraForge exploits that with a precomputed base world: generating all 65,536 chunks once (mostly to place the base vegetation) is real work, so the result is saved as world_base.bin. Releases ship that file, and the browser build downloads it instead of regenerating the planet from scratch in your tab. The snapshot is stamped with a key derived from the generation parameters, so if the rules ever change, a stale snapshot is rejected and rebuilt rather than silently mismatching the code.

In the engine
Generation lives in src/world_core/: heightmap.rs (the noise octaves), biome.rs, rivers.rs (the hydrology solve), and chunk_generator.rs, which runs the layers in order — terrain, biomes, then content — for one chunk at a time. Unit tests pin the guarantees down: the terrain must be exactly periodic across the world seam, two generators with the same seed must place identical plants, and the optimised grid samplers must match the simple per-point ones bit for bit.