Procedural content generation (PCG) creates game content algorithmically instead of by hand. Terrain, dungeons, loot tables, NPC names, quest lines, and weather patterns can all be generated from rules and randomness. For browser games, PCG is especially valuable because it lets a small team create infinite content without shipping massive asset files.
The best procedural generation feels handcrafted. The trick is constraints: not "random terrain" but "terrain that always has a river, exactly two mountain passes, and a sheltered valley for the starting village."
Perlin noise and Simplex noise generate smooth, natural-looking random values across 2D or 3D space. Use them for terrain heightmaps, biome distribution, cave systems, and cloud patterns.
// Simple terrain generation with layered noise
function generateTerrain(width, height, seed) {
const noise = new SimplexNoise(seed);
const terrain = [];
for (let y = 0; y < height; y++) {
terrain[y] = [];
for (let x = 0; x < width; x++) {
// Layer multiple noise frequencies
let elevation = 0;
elevation += noise.noise2D(x * 0.01, y * 0.01) * 1.0; // large features
elevation += noise.noise2D(x * 0.05, y * 0.05) * 0.3; // medium detail
elevation += noise.noise2D(x * 0.15, y * 0.15) * 0.1; // fine detail
// Classify biome by elevation
if (elevation < -0.3) terrain[y][x] = 'water';
else if (elevation < 0.0) terrain[y][x] = 'sand';
else if (elevation < 0.5) terrain[y][x] = 'grass';
else if (elevation < 0.8) terrain[y][x] = 'forest';
else terrain[y][x] = 'mountain';
}
}
return terrain;
}
The magic is in layering. A single noise function produces bland terrain. Three frequencies layered together (large landmasses + medium hills + fine detail) create terrain that looks geographically plausible.
For games with interior spaces (dungeons, buildings, caves), the most common approach is the BSP (Binary Space Partitioning) algorithm:
This guarantees every room is reachable and the layout is reasonable. For more organic caves, use cellular automata: start with random noise, then repeatedly apply a rule like "a cell becomes wall if it has 5+ wall neighbors." After 4-5 iterations, natural-looking cave systems emerge.
Weighted random selection is the foundation of loot systems. Each item has a weight (relative probability), and you roll against the total weight to pick one:
function weightedRandom(table) {
const total = table.reduce((s, item) => s + item.weight, 0);
let roll = Math.random() * total;
for (const item of table) {
roll -= item.weight;
if (roll <= 0) return item;
}
return table[table.length - 1];
}
const lootTable = [
{ name: 'Common Seed', weight: 60, rarity: 'common' },
{ name: 'Silver Hoe', weight: 25, rarity: 'uncommon' },
{ name: 'Golden Watering Can', weight: 10, rarity: 'rare' },
{ name: 'Ancient Tractor', weight: 4, rarity: 'epic' },
{ name: 'Mythic Scarecrow', weight: 1, rarity: 'legendary' },
];
For generated items with random properties (damage, durability, enchantments), use a two-stage system: first pick the base item from the loot table, then roll each property within the item's allowed ranges.
Procedural names for NPCs, towns, and items use Markov chains trained on real names. A second-order Markov chain looks at the last two characters to predict the next one, producing names that sound plausible without being real:
class NameGenerator {
constructor(trainingNames) {
this.chains = {};
for (const name of trainingNames) {
const padded = '^^' + name.toLowerCase() + '$';
for (let i = 2; i < padded.length; i++) {
const key = padded.slice(i - 2, i);
if (!this.chains[key]) this.chains[key] = [];
this.chains[key].push(padded[i]);
}
}
}
generate() {
let name = '^^';
while (true) {
const key = name.slice(-2);
const options = this.chains[key];
if (!options) break;
const next = options[Math.floor(Math.random() * options.length)];
if (next === '$') break;
name += next;
}
return name.slice(2, 3).toUpperCase() + name.slice(3);
}
}
Procedural quests use grammar-based generation. Define quest templates as patterns with variable slots:
Fill the slots with context-appropriate values: a fishing village gives fish-related fetch quests, a mountain town gives mining quests. Difficulty scales with the player's level by adjusting quantities, enemy types, and distance.
Use seeded random number generators so the same seed always produces the same world. This enables multiplayer (all clients generate the same terrain from a shared seed), save/load (store only the seed, not the entire world), and debugging (reproduce exact scenarios).
// Simple seeded PRNG (mulberry32)
function mulberry32(seed) {
return function() {
seed |= 0; seed = seed + 0x6D2B79F5 | 0;
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}