2.6 Randomness & Noise

Phase 2 · Game Math · Study time: 15–25 h

Pseudo-random numbers and distributions, plus Perlin and Simplex noise — the tools behind procedural generation, VFX and natural-looking variation.

Every game uses randomness somewhere: which enemy spawns, how much damage a hit rolls for, which item drops, how a mountain range is shaped, how a torch flickers. This chapter is about how a computer — a machine that is completely deterministic, the same input always gives the same output — manages to produce something that looks random, how to use that ability correctly (a surprising number of "obvious" ways to use it are quietly broken), and how to go one step further and generate randomness that looks natural instead of chaotic, which is what terrain, clouds, and camera shake actually need.

As always: small code, real output, plain explanation. Every number shown below is the real output of a small C++ program that was compiled and run — type them in yourself and you will see the same thing.

1. Pseudo-random numbers: they only look random

A computer cannot flip a real coin. Instead, a pseudo-random number generator (PRNG, "pseudo" means "fake" — it only resembles the real thing) produces numbers using a fixed formula. You give it a starting value called a seed, and from then on each new number is computed from the previous state using that formula. The sequence looks random to a human glancing at it, but it is 100% determined by the seed. Same seed in, same exact sequence out, forever.

Here is one of the simplest PRNGs that exists, a linear congruential generator (LCG): state = state * A + B, using a fixed A and B. We use an unsigned 32-bit integer, so when the multiplication overflows it just wraps around — that wraparound is exactly what keeps the output jumping around unpredictably.

#include <cstdint>
#include <iostream>

struct Rng { uint32_t state; };

// One step of a linear congruential generator (LCG): a formula that
// turns the current state into a new state. Same formula, same state
// in -> same state out, every single time.
uint32_t nextRaw(Rng& r) {
    r.state = r.state * 1103515245u + 12345u;   // wraps around on overflow (that's fine)
    return r.state;
}

int rollDie(Rng& r) {
    return (nextRaw(r) % 6) + 1;   // 1..6 (we will fix the % trap in section 3)
}

int main() {
    Rng a{42};                     // seed = 42
    for (int i = 0; i < 8; i++) std::cout << rollDie(a) << " ";
    std::cout << "\n";

    Rng b{42};                     // same seed as a
    for (int i = 0; i < 8; i++) std::cout << rollDie(b) << " ";
    std::cout << "\n";

    Rng c{7};                      // different seed
    for (int i = 0; i < 8; i++) std::cout << rollDie(c) << " ";
    std::cout << "\n";
}

Output:

6 5 2 1 4 3 2 3
6 5 2 1 4 3 2 3
3 4 5 4 1 4 1 4

Look closely: rolls a and b print the exact same eight numbers, because they started from the same seed (42) and used the same formula. Roll c started from seed 7 and produced a completely different sequence. Nothing here is magic — state is just a number, and state = state * A + B is just arithmetic. If you know the seed and the formula, you can predict every single roll in advance. That is what "pseudo" means.

Tip Real code should not hand-roll an LCG like this — it has known weaknesses (short repeating patterns in the low bits, for one). We built it here purely so you can see the mechanism with your own eyes. From section 2 onward we switch to C++'s real generator, std::mt19937, which is a much higher-quality PRNG (a Mersenne Twister) but works on exactly the same principle: seed in, deterministic sequence out.

2. Same seed, same sequence: why determinism matters

C++'s standard library gives you a proper PRNG, std::mt19937, plus helper objects called distributions that turn its raw output into the shape of randomness you actually want (an int in a range, a float in a range, and so on — we will use them starting next section). It works exactly like our toy LCG: construct it with a seed, and it deterministically produces the same sequence every time.

#include <random>
#include <iostream>

int main() {
    std::mt19937 rngA(1234);                    // real production-quality PRNG, fixed seed
    std::uniform_int_distribution<int> die(1, 6);
    std::cout << "run A: ";
    for (int i = 0; i < 8; i++) std::cout << die(rngA) << " ";
    std::cout << "\n";

    std::mt19937 rngB(1234);                    // same seed
    std::uniform_int_distribution<int> die2(1, 6);
    std::cout << "run B: ";
    for (int i = 0; i < 8; i++) std::cout << die2(rngB) << " ";
    std::cout << "\n";
}

Output:

run A: 4 6 5 5 1 2 2 2
run B: 4 6 5 5 1 2 2 2 

Run this program a hundred times and both lines print the exact same numbers every single time, because the seed (1234) never changes. This sounds like a boring party trick, but it is one of the most useful properties in all of game programming.

Why it matters for testing

Imagine a bug report that says "sometimes the boss drops two items instead of one." If your loot code pulls from a generator seeded from the current time, that bug happens on some runs and not others — you cannot reliably reproduce it, which makes it nearly impossible to debug. If your test harness instead seeds the RNG with a fixed number, the exact same sequence of "random" events happens every single run. A crash becomes 100% reproducible. This is why serious game codebases let you pass in a seed for testing, even if real gameplay uses an unpredictable one.

Why it matters for networked games

Some multiplayer games (especially real-time strategy games) use a technique called lockstep: instead of sending every random result over the network, every player's machine starts from the same seed and simulates the same sequence of "random" events locally, in sync. As long as everyone's simulation does the exact same math in the exact same order, everyone ends up seeing the exact same outcome, using only a tiny fraction of the network traffic that sending every dice roll would cost.

Common mistake Lockstep determinism is fragile in a way that surprises people: floating-point arithmetic can produce tiny differences between different CPUs, compilers, or optimization settings, even when the source code is identical. A single flipped bit early in a long simulation snowballs into a full desync. Studios that rely on lockstep either restrict themselves to fixed-point (integer-based) math for anything that must stay in sync, or very carefully control the floating-point environment on every machine. This is a real, well-known pitfall — worth remembering long before you need it.

When you actually want unpredictability — real gameplay, not a test — seed from a real source of entropy instead of a fixed number, for example std::random_device (it pulls from the operating system, which gathers noise from things like hardware timing). The pattern is simple: fixed seed for anything that needs to be repeatable, entropy-seeded for anything that should genuinely surprise the player.

3. Random integers in a range: the modulo-bias trap

The instinctive way to turn a raw random number into "a number from 0 to N-1" is raw % N (the % operator, modulo, gives the remainder after division). This looks correct and often is close enough in practice — but it is subtly biased whenever the generator's range is not an exact multiple of N, and for a small range the bias is easy to see.

Say our raw generator only ever produces the ten values 0 through 9 (a small range on purpose, so we can check the math by hand). We want to fairly pick 0, 1, or 2. Ten does not divide evenly by three (10 = 3*3 + 1), so one value is left over:

#include <iostream>

int main() {
    // Pretend our raw generator only ever outputs 0..9 (easy to check by hand).
    int counts[3] = {0, 0, 0};
    int state = 0;
    for (int i = 0; i < 10000; i++) {
        state = (11 * state + 3) % 10;   // cycles through 0..9
        int bucket = state % 3;          // naive "pick 0, 1, or 2" -- BIASED
        counts[bucket]++;
    }
    std::cout << "naive %3   -> 0:" << counts[0]
               << " 1:" << counts[1] << " 2:" << counts[2] << "\n";

    // Fix: reject the leftover value (9) so only 0..8 (9 values, divides evenly by 3) is used.
    int counts2[3] = {0, 0, 0};
    state = 0;
    int accepted = 0;
    for (int i = 0; i < 10000 && accepted < 9000; i++) {
        state = (11 * state + 3) % 10;
        if (state == 9) continue;        // reject, try again next loop
        counts2[state % 3]++;
        accepted++;
    }
    std::cout << "rejection  -> 0:" << counts2[0]
               << " 1:" << counts2[1] << " 2:" << counts2[2] << "\n";
}

Output:

naive %3   -> 0:4000 1:3000 2:3000
rejection  -> 0:3000 1:3000 2:3000

With the naive version, bucket 0 comes up 4000 times out of 10000 — 40%, not the fair 33.3% — because the values 0, 3, 6, and 9 all map to bucket 0 (four values), while buckets 1 and 2 only get three values each. Small skew, but a real one, and it gets worse the further the range is from a clean multiple.

The fix used above is called rejection sampling: throw away the one leftover raw value (9) that would cause unevenness, and re-roll. With only 0-8 left (nine values, dividing evenly into three groups of three), the result is exactly fair: 3000/3000/3000.

Tip You do not need to hand-write rejection sampling yourself. std::uniform_int_distribution<int> (used already in section 2 for the die) does exactly this internally, correctly, for any range. Prefer it over a raw % whenever the range is not a power of two that matches your generator's output width. The rule of thumb: never write rng() % n for game logic — always go through a real distribution.

4. Random floats in a range

Plenty of things in a game are not whole numbers — a spawn delay, a spread angle, a scale multiplier. std::uniform_real_distribution<float> gives you a random float across a range, and by default that range is half-open: it includes the low bound but not the high bound, written [lo, hi).

#include <random>
#include <iostream>

int main() {
    std::mt19937 rng(2024);
    std::uniform_real_distribution<float> unit(0.0f, 1.0f);   // range [0, 1)

    std::cout << "5 random floats in [0,1): ";
    for (int i = 0; i < 5; i++) std::cout << unit(rng) << " ";
    std::cout << "\n";

    std::mt19937 rng2(2024);                                  // same seed again
    float lo = 10.0f, hi = 20.0f;
    std::cout << "5 random floats in [10,20): ";
    for (int i = 0; i < 5; i++) {
        float f = lo + unit(rng2) * (hi - lo);                // remap [0,1) into [lo,hi)
        std::cout << f << " ";
    }
    std::cout << "\n";
}

Output:

5 random floats in [0,1): 0.588015 0.757153 0.699109 0.738747 0.188152
5 random floats in [10,20): 15.8801 17.5715 16.9911 17.3875 11.8815 

Notice the second run reuses the exact same seed and the exact same underlying [0,1) distribution object — so the raw fractions (0.588, 0.757...) are identical, and the second line is just the first line remapped with lo + t * (hi - lo). That remapping formula — a fraction t from 0 to 1, scaled and shifted into any range you like — will come back again and again in this chapter, including inside the noise sections. You could also skip the manual remap and just construct std::uniform_real_distribution<float>(lo, hi) directly; both approaches are common in real code.

5. Weighted random choice: loot tables and gacha rates

A plain uniform pick treats every option equally, but most loot systems do not want that — a Legendary item should be rare, a Common item should be frequent. A loot table (a list of possible drops, each with a weight controlling how likely it is) solves this with cumulative weights: lay the weights end to end on a number line, roll one random number across the whole line, and see which slice it lands in.

loot table weights (should add up to 100 to read as percent): Common 60 Rare 25 Epic 12 Legendary 3 -------------- total 100 turn the weights into CUMULATIVE ranges on a 0..100 number line: 0 60 85 97 100 |-----------|---------|----|--| | Common | Rare |Epic|Lg| |-----------|---------|----|--| roll = randomFloat(0, 100) one roll picks one item roll = 42.0 -> falls in [0, 60) -> Common roll = 91.5 -> falls in [85, 97) -> Epic roll = 99.1 -> falls in [97, 100) -> Legendary
#include <random>
#include <iostream>
#include <string>
#include <vector>

struct Item { std::string name; float weight; };

std::string pickWeighted(std::vector<Item>& table, float totalWeight, std::mt19937& rng) {
    std::uniform_real_distribution<float> unit(0.0f, totalWeight);
    float roll = unit(rng);            // one roll in [0, totalWeight)
    float cursor = 0.0f;
    for (auto& it : table) {
        cursor += it.weight;
        if (roll < cursor) return it.name;   // roll landed in this item's slice
    }
    return table.back().name;          // safety net for float rounding
}

int main() {
    std::vector<Item> table = {
        {"Common",    60.0f},
        {"Rare",      25.0f},
        {"Epic",      12.0f},
        {"Legendary",  3.0f},
    };
    float total = 0.0f;
    for (auto& it : table) total += it.weight;

    std::mt19937 rng(99);
    int counts[4] = {0, 0, 0, 0};
    const int N = 10000;
    for (int i = 0; i < N; i++) {
        std::string got = pickWeighted(table, total, rng);
        for (size_t k = 0; k < table.size(); k++)
            if (table[k].name == got) counts[k]++;
    }
    for (size_t k = 0; k < table.size(); k++)
        std::cout << table[k].name << ": " << counts[k]
                   << " (" << (100.0 * counts[k] / N) << "%)\n";
}

Output:

Common: 5965 (59.65%)
Rare: 2586 (25.86%)
Epic: 1168 (11.68%)
Legendary: 281 (2.81%)

Ten thousand pulls, and the measured percentages (59.65%, 25.86%, 11.68%, 2.81%) land close to the target weights (60, 25, 12, 3) — not exact, because it is still random, but close, and they get closer the more pulls you simulate. This cumulative-weight technique, with a random roll walking a number line, is exactly how gacha-style loot systems work under the hood — many HoYoverse-style games use base rates around 0.5-0.6% for their rarest tier on a single pull.

Tip Real gacha systems usually add a pity mechanic on top of this: if you go too many pulls without getting the rare tier, the game boosts that tier's weight (soft pity), and eventually guarantees it outright at some pull count (hard pity). Notice that is not a separate system — it is just this same weighted table, with the weights adjusted based on how many pulls you have made since your last rare drop.

6. Shuffling a list: the Fisher-Yates algorithm

Shuffling means putting a list into a genuinely random order — every one of the n! ("n factorial", the number of ways to order n items) possible orderings should be equally likely. The Fisher-Yates shuffle does this correctly and cheaply, in a single pass: walk from the last index down to index 1, and at each step swap that slot with a uniformly random slot chosen from 0 up to and including the current index — never touching a slot after it has already been placed.

#include <random>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> deck = {1, 2, 3, 4, 5, 6, 7, 8};
    std::mt19937 rng(7);

    // Walk from the LAST index down to 1. At each step, swap that slot
    // with a uniformly random slot from 0..i (inclusive) -- including
    // possibly itself. Never touch an index after it has been fixed.
    for (int i = (int)deck.size() - 1; i > 0; i--) {
        std::uniform_int_distribution<int> pick(0, i);
        int j = pick(rng);
        std::swap(deck[i], deck[j]);
        std::cout << "i=" << i << " j=" << j << "  -> ";
        for (int x : deck) std::cout << x << " ";
        std::cout << "\n";
    }
}

Output:

i=7 j=7  -> 1 2 3 4 5 6 7 8
i=6 j=4  -> 1 2 3 4 7 6 5 8
i=5 j=1  -> 1 6 3 4 7 2 5 8
i=4 j=3  -> 1 6 3 7 4 2 5 8
i=3 j=3  -> 1 6 3 7 4 2 5 8
i=2 j=0  -> 3 6 1 7 4 2 5 8
i=1 j=1  -> 3 6 1 7 4 2 5 8 

Trace it: on the first step i=7, the random pick j can be anything from 0 to 7 (the whole array is still "live"). Once index 7 is swapped, it is never touched again — the next step only picks from 0 to 6. That shrinking range is the whole trick: it guarantees each of the 8! = 40320 possible orderings is equally likely, with no leftover bias.

Common mistake A very common bug looks almost identical to Fisher-Yates but is not: swapping every index i with a random index picked from the entire array every time (0..n-1, not the shrinking 0..i), instead of shrinking the range as you go. It compiles, it runs, it even looks shuffled — but it is measurably biased. Running both versions 60,000 times each on a 4-item list (24 possible orders, so a fair shuffle should land near 2500 per order) gives: the buggy full-range version ranges from as low as 1880 to as high as 3401 for different orders, while proper Fisher-Yates stays tight, between 2380 and 2616. Some orderings really do come up more often than others with the buggy version — enough to matter if that "shuffle" controls something players can measure, like a card game's deck.

7. Random points inside a circle and a rectangle

Scattering things over an area — spawning particles, placing foliage, picking a landing spot for area damage — needs a point chosen uniformly across some shape, meaning every equal patch of area is equally likely to get a point.

Rectangle: trivial

A rectangle is the easy case: width and height are independent, so just pick two independent random floats, one per axis.

#include <random>
#include <iostream>

int main() {
    std::mt19937 rng(3);
    std::uniform_real_distribution<float> ux(0.0f, 200.0f);   // rectangle width  200
    std::uniform_real_distribution<float> uy(0.0f, 100.0f);   // rectangle height 100

    std::cout << "5 random points inside a 200x100 rectangle:\n";
    for (int i = 0; i < 5; i++) {
        float x = ux(rng);
        float y = uy(rng);
        std::cout << "  (" << x << ", " << y << ")\n";
    }
}

Output:

5 random points inside a 200x100 rectangle:
  (110.16, 7.07249)
  (141.63, 83.9949)
  (58.1809, 12.1329)
  (102.166, 56.9311)
  (178.589, 43.7062)

Circle: rejection sampling

A circle is not two independent axes, so the same trick does not directly work. The simplest correct approach is another use of rejection sampling from section 3: generate a point in the square that surrounds the circle, and only keep it if it actually lands inside the circle — otherwise throw it away and try again.

square from (-1,-1) to (1,1), circle of radius 1 inscribed: +-------------------+ | x x x | x = rejected (outside the circle) | .-------. | | x . o . | o = accepted (inside the circle) | / o o \ | | | o o o | x| | \ o o / | | x '. o .' | | '-------' x | | x x x | +-------------------+ keep the point only if x*x + y*y <= 1 about 78.5% of points in the square land inside the circle (pi / 4)
#include <random>
#include <iostream>

int main() {
    std::mt19937 rng(3);
    std::uniform_real_distribution<float> unit(-1.0f, 1.0f);   // covers the surrounding square

    std::cout << "rejection sampling, points inside a radius-1 circle:\n";
    int found = 0;
    while (found < 5) {
        float x = unit(rng);
        float y = unit(rng);
        if (x * x + y * y <= 1.0f) {       // inside the circle? keep it.
            std::cout << "  (" << x << ", " << y << ")\n";
            found++;
        }
        // else: outside the circle -- throw it away and roll again.
    }
}

Output:

rejection sampling, points inside a radius-1 circle:
  (0.101596, -0.85855)
  (0.416296, 0.679898)
  (-0.418191, -0.757343)
  (0.0216552, 0.138623)
  (0.785894, -0.125876)

Every printed point satisfies x*x + y*y <= 1 — check the third one: (-0.418)^2 + (-0.757)^2 = 0.175 + 0.573 = 0.748, comfortably under 1. On average about 78.5% of raw square points are kept (the circle's area is pi times its radius squared, the square's area is 4 times the radius squared, and pi/4 ≈ 0.785), so this wastes some rolls but stays perfectly fair, and the fraction thrown away is small enough not to matter for gameplay code.

Common mistake A tempting shortcut is polar coordinates: pick a random angle and a random radius directly, r = maxRadius * randomFloat(0,1), then convert to x/y. This clusters points toward the center far more than it should, because a ring near the center covers much less area than a ring near the edge, yet a uniformly random r visits both rings equally often. Measuring it directly: the inner half-radius of a circle only holds 25% of the circle's area, so a fair sample should put about 25% of points there. The naive r = maxRadius * random() approach put 49.68% of 20,000 sample points in that inner half — roughly double what it should be. The fix is one square root: r = maxRadius * sqrt(randomFloat(0,1)), which correctly weights larger radii more often and measured at 24.81% — right where it should be.

8. Noise: white noise vs smooth noise

Noise, in this context, means a function that produces a value that varies across space or time — not a single random event like a dice roll, but a whole varying field of values you can sample anywhere. Terrain height, cloud brightness, and a camera's shake offset are all examples of something you want as noise rather than as one random pick.

There are two very different flavors. White noise assigns a completely fresh, independent random value at every point — no relationship at all between neighbors. It is called "white" by analogy to white light containing all frequencies evenly; visually and audibly it looks like static, like the snow on an old untuned TV. Smooth noise (the general term is gradient noise, and the most famous kind is Perlin noise) is built so that nearby points have close, correlated values, so the whole thing glides gradually instead of jumping.

#include <random>
#include <iostream>
#include <vector>

float smoothstep(float t) { return t * t * (3.0f - 2.0f * t); }

int main() {
    const int W = 40;

    // White noise: a fresh, independent random value at every x.
    std::mt19937 rngW(11);
    std::uniform_real_distribution<float> unit(0.0f, 1.0f);
    std::cout << "white  : ";
    for (int x = 0; x < W; x++) {
        int h = (int)(unit(rngW) * 9.0f);
        std::cout << h;
    }
    std::cout << "\n";

    // Smooth noise: random values on a coarse grid, blended between.
    const int spacing = 8;                       // one grid point every 8 units
    const int gridN = W / spacing + 2;
    std::mt19937 rngG(11);
    std::vector<float> grid(gridN);
    for (auto& g : grid) g = unit(rngG);

    std::cout << "smooth : ";
    for (int x = 0; x < W; x++) {
        int   cell = x / spacing;
        float t    = (x % spacing) / (float)spacing;
        float a = grid[cell];
        float b = grid[cell + 1];
        float v = a + smoothstep(t) * (b - a);    // blend a -> b using an eased t
        int h = (int)(v * 9.0f);
        std::cout << h;
    }
    std::cout << "\n";
}

Output (each digit is a height from 0 to 9, one per x position):

white  : 1005416830420847807866048775165608182710
smooth : 1111100000000000001234555555544444332221

Read across the white row: 1, 0, 0, 5, 4, 1, 6, 8... every neighbor is unrelated to the last, jumping wildly. Read across the smooth row: 1, 1, 1, 1, 1, 0, 0, 0... it eases from one plateau to the next, never jumping more than a couple of digits between neighbors. Same idea, drawn as bars — the difference is obvious at a glance:

WHITE NOISE (jagged, no relation between neighbors): ## # # # ## ## # # # ## ### # # # # # ## ###### # # # ## # ### ###### # # # ## # ### ###### # # #### # ### ###### ## ###### # ############## ###### ## ######################## ------------------------ SMOOTH NOISE (gradient/value noise, neighbors are close in value): ####### # ######### # ########## ## ############ ### ############### ### ################ #### ################# ##### ######################## ######################## ------------------------

White noise is exactly what you want for a coin flip or a loot roll — total independence is the point. But if you used white noise for terrain height, every neighboring tile would jump to a totally different height, giving spiky, unusable garbage instead of a mountain. Smooth noise is what terrain, clouds, and natural motion actually need — the next two sections build it and use it.

9. Building smooth noise: interpolating a grid of random values

The smooth row above was not magic — it followed a simple recipe called value noise, one of the simplest members of the gradient-noise family:

#include <random>
#include <iostream>
#include <vector>

float smoothstep(float t) { return t * t * (3.0f - 2.0f * t); }

float valueNoise1D(const std::vector<float>& grid, int spacing, int x) {
    int   cell = x / spacing;               // which two grid points x falls between
    float t    = (x % spacing) / (float)spacing;   // 0..1, how far between them
    float a = grid[cell];
    float b = grid[cell + 1];
    return a + smoothstep(t) * (b - a);      // eased blend, not a straight line
}

int main() {
    std::mt19937 rng(11);
    std::uniform_real_distribution<float> unit(0.0f, 1.0f);
    std::vector<float> grid(7);
    for (auto& g : grid) g = unit(rng);       // one fixed random value per grid point

    for (size_t i = 0; i < grid.size(); i++)
        std::cout << "grid[" << i << "] = " << grid[i] << "\n";

    float v = valueNoise1D(grid, 8, 4);       // x=4 is halfway between grid[0] and grid[1]
    std::cout << "noise at x=4 (t=0.5): " << v << "\n";
}

Output:

grid[0] = 0.1803
grid[1] = 0.0683
grid[2] = 0.0195
grid[3] = 0.6647
grid[4] = 0.4632
grid[5] = 0.1941
grid[6] = 0.7249
noise at x=4 (t=0.5): 0.1243

These are the exact grid values that produced the "smooth" row back in section 8. Follow the math by hand for x=4:

one random value sits at every grid point (spacing = 8): x: 0 8 16 24 | | | | grid: 0.18 0.07 0.02 0.66 between two grid points, blend with an EASED t, not a straight line: smoothstep(t) = t*t*(3 - 2*t) t=0.0 -> 0.00 (flat start, matches the left grid point's slope) t=0.25-> 0.156 t=0.5 -> 0.50 (still the midpoint, same as plain linear here) t=0.75-> 0.844 t=1.0 -> 1.00 (flat finish, matches the right grid point's slope) worked example at x=4, cell 0, t=4/8=0.5: v = grid[0] + smoothstep(0.5) * (grid[1]-grid[0]) v = 0.1803 + 0.5 * (0.0683-0.1803) v = 0.1803 + 0.5 * (-0.1120) v = 0.1243

0.1243 * 9 = 1.12, which truncates to 1 — exactly the digit that appeared at position 4 in the smooth row earlier. Every value in that whole row is nothing but this same blend, computed over and over at different x.

Value noise vs true Perlin noise

What we just built is value noise: random values sitting at the grid points. The classic Perlin noise (invented by Ken Perlin for the movie Tron, and later awarded a technical Academy Award) is a close cousin that stores a random direction (a gradient vector) at each grid point instead of a plain value, and blends based on how far each sample point is along that direction. It looks a little smoother and avoids a subtle "bump directly on the grid point" look that plain value noise can have — but the core idea is identical: random grid, blend between neighbors, ease the blend. Once value noise makes sense, Perlin noise is just a refinement, not a different concept. In real projects you generally will not hand-roll either one — Unity has Mathf.PerlinNoise built in, Unreal has noise nodes in its material and Blueprint systems, and libraries like FastNoiseLite exist for everything else — but knowing the mechanism means you can actually understand what their parameters do.

10. Using noise: heightmaps, textures, and natural motion

Heightmaps: terrain that looks natural

A heightmap is a grid of numbers where each number is the height of the terrain at that spot. Sample 2D smooth noise once per tile and use the result as that tile's height, and you get rolling, connected hills instead of a jagged mess — because, exactly as in section 8 and 9, neighboring tiles get correlated values.

#include <random>
#include <iostream>
#include <vector>

float smoothstep(float t) { return t * t * (3.0f - 2.0f * t); }
float lerp(float a, float b, float t) { return a + t * (b - a); }

struct Grid2D {
    int n;
    std::vector<float> v;
    float at(int x, int y) const { return v[(y % n) * n + (x % n)]; }
};

float noise2D(const Grid2D& g, int spacing, int x, int y) {
    int cx = x / spacing, cy = y / spacing;
    float tx = (x % spacing) / (float)spacing;
    float ty = (y % spacing) / (float)spacing;
    float v00 = g.at(cx, cy),     v10 = g.at(cx + 1, cy);
    float v01 = g.at(cx, cy + 1), v11 = g.at(cx + 1, cy + 1);
    float top = lerp(v00, v10, smoothstep(tx));    // blend along x on the top edge
    float bot = lerp(v01, v11, smoothstep(tx));    // blend along x on the bottom edge
    return lerp(top, bot, smoothstep(ty));         // blend the two results along y
}

int main() {
    std::mt19937 rng(5);
    std::uniform_real_distribution<float> unit(0.0f, 1.0f);
    Grid2D grid{6, std::vector<float>(36)};
    for (auto& x : grid.v) x = unit(rng);

    const char* bands = " .-~^#";   // low height to high height
    const int W = 24, H = 12, spacing = 4;

    for (int y = 0; y < H; y++) {
        for (int x = 0; x < W; x++) {
            float h = noise2D(grid, spacing, x, y);
            int band = (int)(h * 5.0f);
            if (band > 5) band = 5;
            std::cout << bands[band];
        }
        std::cout << "\n";
    }
}

Output (space and . are low ground, ^ and # are peaks):

.     -~^^^^^~-.........
.... .-~^~~~~~-.........
-------~~~--------...---
^^^^^~~---.....-----.-~~
^^^^^^~---.   .-~---.-~^
^^^^^^~---.. ..-------~^
^^~~~~----.....----~~~~^
~~~-----------...-~^^^^^
~~--..--------...-~^^^^^
~~-------------..-~^^^^~
-------...--------~^^^~-
..-~~~-. ..-~~~~~~~^^~-.

That is a 2D version of exactly the same trick as section 9, just blended along two axes instead of one (noise2D blends along x twice, then blends those two results along y). You can see one connected mountain range on the right side of the map, not scattered random spikes — that connectedness is the entire point of using smooth noise instead of a random number per tile.

Textures

The exact same 2D noise, instead of driving height, can drive brightness or color per pixel — feed the 0..1 output into a grayscale value and you get a passable cloud or marble texture; layer a couple of colors based on noise thresholds and you get camouflage-style patterns. It is the same function, just displayed differently.

Natural motion

Noise does not have to be sampled across space — sampling it across time instead gives you a value that wanders smoothly from frame to frame, which is exactly what camera shake, wind-blown foliage, or a flickering torch need. Picking a brand new random offset every single frame (white noise in time) looks like jitter or static. Sampling smooth noise as time moves forward looks organic, because each frame's value stays close to the previous frame's.

#include <random>
#include <iostream>
#include <iomanip>
#include <vector>

float smoothstep(float t) { return t * t * (3.0f - 2.0f * t); }

float noise1D(const std::vector<float>& grid, int spacing, float x) {
    int   cell = (int)x / spacing;
    float t    = ((int)x % spacing) / (float)spacing;
    float a = grid[cell % grid.size()];
    float b = grid[(cell + 1) % grid.size()];
    return a + smoothstep(t) * (b - a);
}

int main() {
    std::mt19937 rng(21);
    std::uniform_real_distribution<float> unit(0.0f, 1.0f);
    std::vector<float> grid(6);
    for (auto& g : grid) g = unit(rng);

    const int spacing = 5;
    const float amplitude = 4.0f;             // max shake offset, in pixels

    std::cout << std::fixed << std::setprecision(2);
    std::cout << "frame  time  shakeOffset\n";
    for (int frame = 0; frame < 10; frame++) {
        float time = frame * 1.5f;                        // time keeps moving forward
        float n = noise1D(grid, spacing, time);            // smooth 0..1 value
        float offset = (n - 0.5f) * 2.0f * amplitude;      // remap to -amplitude..+amplitude
        std::cout << "  " << frame << "    " << time << "   " << offset << "\n";
    }
}

Output:

frame  time  shakeOffset
  0    0.00   -3.61
  1    1.50   -2.99
  2    3.00   0.28
  3    4.50   1.77
  4    6.00   1.97
  5    7.50   0.96
  6    9.00   -1.26
  7    10.50   -1.69
  8    12.00   -0.59
  9    13.50   0.34

Watch the offset column: -3.61, -2.99, 0.28, 1.77, 1.97, 0.96... — it drifts up, peaks, then drifts back down, never jumping randomly frame to frame. Feed a value like this straight into a camera's local position offset and it reads as a natural wobble. Feed a fresh randomFloat(-4,4) into the camera every frame instead, and it would read as broken, flickery noise — same range of numbers, completely different feel, purely because of how each value relates to the one before it.

11. Octaves: layering noise for detail

A single layer of smooth noise looks soft and blobby — real terrain has that same rolling large-scale shape, but also smaller bumps and rocks riding on top of it, and even smaller detail on top of that. The fix is called octaves: add together several layers of noise at different frequencies (how tightly packed the grid is) and amplitudes (how much each layer is allowed to move the total). Each octave typically doubles the frequency of the last while halving its amplitude, so each added layer contributes finer detail with less overall influence.

#include <random>
#include <iostream>
#include <vector>

float smoothstep(float t) { return t * t * (3.0f - 2.0f * t); }

float valueNoise1D(const std::vector<float>& grid, int spacing, int x) {
    int   cell = x / spacing;
    float t    = (x % spacing) / (float)spacing;
    float a = grid[cell % grid.size()];
    float b = grid[(cell + 1) % grid.size()];
    return a + smoothstep(t) * (b - a);
}

std::vector<float> makeGrid(int n, int seed) {
    std::mt19937 rng(seed);
    std::uniform_real_distribution<float> unit(0.0f, 1.0f);
    std::vector<float> g(n);
    for (auto& v : g) v = unit(rng);
    return g;
}

int main() {
    const int W = 40;
    auto grid1 = makeGrid(8, 11);    // octave 1: low frequency,  full amplitude
    auto grid2 = makeGrid(8, 22);    // octave 2: 2x frequency,   half amplitude
    auto grid3 = makeGrid(16, 33);   // octave 3: 4x frequency,   quarter amplitude

    std::cout << "octave1 (freq 1, amp 1.0)  : ";
    for (int x = 0; x < W; x++) std::cout << (int)(valueNoise1D(grid1, 8, x) * 9);
    std::cout << "\n";

    std::cout << "octave2 (freq 2, amp 0.5)  : ";
    for (int x = 0; x < W; x++) std::cout << (int)(valueNoise1D(grid2, 4, x) * 9);
    std::cout << "\n";

    std::cout << "octave3 (freq 4, amp 0.25) : ";
    for (int x = 0; x < W; x++) std::cout << (int)(valueNoise1D(grid3, 2, x) * 9);
    std::cout << "\n";

    std::cout << "sum (normalized)           : ";
    for (int x = 0; x < W; x++) {
        float n1 = valueNoise1D(grid1, 8, x) * 1.0f;
        float n2 = valueNoise1D(grid2, 4, x) * 0.5f;
        float n3 = valueNoise1D(grid3, 2, x) * 0.25f;
        float sum = (n1 + n2 + n3) / 1.75f;    // divide by total amplitude, back to 0..1
        std::cout << (int)(sum * 9);
    }
    std::cout << "\n";
}

Output:

octave1 (freq 1, amp 1.0)  : 1111100000000000001234555555544444332221
octave2 (freq 2, amp 0.5)  : 1245665444566654344556677777764212456654
octave3 (freq 4, amp 0.25) : 2465456435852104776413520378887524654564
sum (normalized)           : 1223322222322211222334555666654333444333

Look at octave 1 alone versus the final sum. Octave 1 has long flat stretches — five zeroes in a row, six fives in a row. The final sum keeps roughly the same rise-and-fall shape (still low in the middle-left, still climbing toward the right) but those flat stretches are gone, replaced by constant small wiggling. That is octaves working exactly as intended: the big shape comes from the lowest-frequency layer, and each higher-frequency layer sands detail on top without changing the overall silhouette.

octave 1 only (low frequency, sets the big shape): ####### # ######### ## ########### ## ############## ### ############### #### ################# #### ######################## ######################## ------------------------ octave 1 + 2 + 3 combined (adds rolling detail, then fine detail): # #### ###### ## ############### ### ################ #### ######################## ######################## ######################## ------------------------

In Unity, Unreal, and most noise libraries you will see this exposed as a few named parameters: octaves (how many layers to add), lacunarity (the frequency multiplier per octave, commonly 2), and persistence or gain (the amplitude multiplier per octave, commonly 0.5). This whole layered sum has a formal name too — fractal Brownian motion (fBm) — but "add a few octaves of noise together" is really all it means.

12. Glossary

13. Exercises

Exercise 1 A raw generator only ever outputs the eight values 0 through 7, each equally likely. Someone picks a bucket with raw % 3 (buckets 0, 1, and 2). List which raw values map to which bucket, count how many raw values land in each bucket, and say which bucket is under-represented and by roughly how much (as a percentage of the 8 raw values).
Show answer

Map each raw value 0-7 through % 3:

0->0  1->1  2->2  3->0  4->1  5->2  6->0  7->1

Bucket 0 gets {0, 3, 6} — 3 values. Bucket 1 gets {1, 4, 7} — 3 values. Bucket 2 gets {2, 5} — only 2 values. Out of 8 raw values, a fair split would be about 2.67 per bucket (33.3%). Bucket 2 only gets 2/8 = 25%, so it is under-represented by about 8 percentage points, while buckets 0 and 1 are each over-represented at 3/8 = 37.5%. This is the exact same modulo-bias pattern from section 3, just with 8 raw values and 3 buckets instead of 10 and 3.

Exercise 2 A loot table has weights Common=70, Rare=25, Legendary=5 (total 100). Using cumulative ranges on a 0-100 number line, which item does each of these rolls land on: roll=12, roll=74, roll=96?
Show answer

Build the cumulative ranges first: Common covers [0, 70), Rare covers [70, 95) (70+25=95), Legendary covers [95, 100) (95+5=100).

  • roll=12 falls in [0,70) -> Common.
  • roll=74 falls in [70,95) -> Rare.
  • roll=96 falls in [95,100) -> Legendary.

This is exactly the cursor += weight; if (roll < cursor) return... loop from section 5, just walked by hand instead of by code.

Exercise 3 Two grid points hold grid[0] = 0.0 and grid[1] = 1.0, with spacing 4. Compute the noise value at x=1 (so t = 1/4 = 0.25) two ways: (a) plain linear interpolation (v = a + t*(b-a)), and (b) smoothstep interpolation (v = a + smoothstep(t)*(b-a), where smoothstep(t) = t*t*(3-2*t)). Which one will look smoother right at the grid points, and why?
Show answer

(a) Linear: v = 0.0 + 0.25 * (1.0 - 0.0) = 0.25.

(b) Smoothstep: first smoothstep(0.25) = 0.25*0.25*(3 - 2*0.25) = 0.0625 * 2.5 = 0.15625, then v = 0.0 + 0.15625 * (1.0 - 0.0) = 0.15625.

The two methods give different answers (0.25 vs 0.15625) because smoothstep is not a straight line — it starts out flatter near t=0 and speeds up in the middle. That flatness right at each grid point is exactly why smoothstep looks smoother: plain linear interpolation has a constant slope right up to the grid point, then the next segment starts with a different constant slope, so the direction visibly kinks at every grid point. Smoothstep's slope is zero at both t=0 and t=1, so one segment eases to a stop exactly where the next one eases to a start, and the seam at the grid point disappears.

Randomness is not one skill, it is several small ones stacked together: know that a seed makes everything reproducible, know how to turn raw numbers into fair picks without the modulo trap, know how to weight choices for loot, know how to shuffle and scatter points without silently favoring some outcomes over others, and know that "random" and "natural-looking" are different goals needing different tools — white noise for the first, layered smooth noise for the second. Every one of those small pieces shows up constantly once you start actually building a game.

← Back to all chapters