Lesson 06
Procedural levels: use randomness as seasoning, not as the whole meal.
Randomness can make browser games replayable, but it can also create impossible jumps, unfair enemy spawns and maps that feel lazy. The trick is to randomize inside safe rules.
1. Start with authored structure
Good procedural games usually begin with handcrafted constraints. In Sky Dash, the level has known platform data, known star positions, known spike sets and known enemy data. Randomness is added around those rules to create motion, decoration and scaled pressure.
const platforms = LEVEL_DATA[globalLevel];
const stars = STARS_DATA[globalLevel];
const spikes = SPIKES_DATA[globalLevel];
const enemies = ENEMIES_DATA[globalLevel];
platforms.forEach(p => this.platforms.create(p.x, p.y, `${k}-${p.type || "platform"}`));
stars.forEach(pos => {
const star = this.stars.create(pos.x, pos.y, "star");
star.body.setAllowGravity(false);
});
Source pattern: Sky Dash.
This approach keeps the main route playable. Randomness can decorate and vary the level, but it does not decide whether the player can reach the exit. That is important for quality: if a player dies, it should feel like a mistake they can learn from, not a bad dice roll.
2. Randomize within safe ranges
Sky Dash uses random values for moving hazards, but the values are bounded. A moving spike gets a starting x position, a travel range and a duration. The level index increases the danger gradually.
const spikeCount = 2 + this.levelIndex;
for (let i = 0; i < spikeCount; i++) {
const sx = 500 + i * 600 + Math.random() * 200;
const spike = this.spikes.create(sx, 570, `${k}-spike`);
this.tweens.add({
targets: spike,
x: sx + 60 + this.levelIndex * 30,
duration: 1300 + Math.random() * 800,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1
});
}
Notice the random parts are small. `Math.random() * 200` shifts the spike, but the spike still belongs to a predictable lane. `Math.random() * 800` varies timing, but it does not make the spike teleport. Controlled randomness feels alive; uncontrolled randomness feels broken.
3. Use seedable random numbers for debugging
`Math.random()` is fine for particles and decoration, but it is painful for debugging procedural maps. If a player reports that level seed 4187 is impossible, you need to reproduce it. A small seeded generator makes that possible.
function makeRng(seed) {
let value = seed >>> 0;
return function rng() {
value = (value * 1664525 + 1013904223) >>> 0;
return value / 4294967296;
};
}
const rng = makeRng(4187);
const x = 200 + rng() * 300;
const enemyType = rng() < 0.25 ? "ranged" : "chaser";
This is not cryptography. It is a repeatable random stream for game content. The same seed produces the same layout, which means you can test, share and fix specific generated levels.
4. Validate generated content
Generation should not stop at creating objects. It should also ask whether the result is fair. For a platformer, that means checking jump distance. For a dungeon, it means checking that rooms connect. For a management game, it means checking that order timers are possible.
function isPlatformReachable(a, b, player) {
const dx = Math.abs(b.x - a.x);
const dy = b.y - a.y;
return dx <= player.maxJumpX && dy <= player.maxJumpUp;
}
function validateRoute(platforms, player) {
for (let i = 1; i < platforms.length; i++) {
if (!isPlatformReachable(platforms[i - 1], platforms[i], player)) {
return false;
}
}
return true;
}
This validation can be simple. You are not proving a theorem. You are catching obvious impossible layouts before a player sees them.
5. Let randomness support the fantasy
Randomness should match the game. A haunted house can randomize whispers, room order and clue placement. A racing game can randomize traffic lanes, but not the laws of steering. A time management game can randomize customer orders, but not spawn five urgent orders before the player can click a single station.
Supagames uses this principle heavily after many fixes. When a game feels unfair, the first question is not "how do we make the player stronger?" It is "what random or timed system is asking too much too quickly?" Good procedural design respects human reaction time.
6. Practical exercise: generate a safe coin trail
A safe first procedural feature is a coin trail. It adds replay value, but does not control whether the level can be completed. You can place coins around authored platforms and validate that each coin is near something the player can stand on.
function generateCoins(platforms, rng) {
const coins = [];
platforms.forEach(function(platform, index) {
if (index === 0) return; // keep the start clean
if (rng() > 0.65) return;
coins.push({
x: platform.x + (rng() - 0.5) * platform.w * 0.7,
y: platform.y - 42,
value: rng() > 0.9 ? 5 : 1
});
});
return coins;
}
This gives variety without creating impossible geometry. If the coin is missed, the player can still finish. If the coin is collected, the player feels rewarded for exploring the route. That is the safest place to start with procedural content: optional rewards before mandatory paths.
After this works, you can move one step deeper. Generate alternate bonus rooms, optional enemy groups or weather changes. Keep the main path authored until your validation tools are strong enough to guarantee a fully generated route.
Questions to ask before using randomness
- Can the game still be completed if the random result is unlucky?
- Can I reproduce a broken layout from a seed or log?
- Is the random range small enough that the player can learn the pattern?
- Does randomness create a decision, or only visual noise?
- Would a handcrafted version of this moment be more fun?
If the answer to the last question is yes, author that moment by hand. Procedural generation is a tool, not a badge of quality. The player only cares whether the result is interesting, readable and fair.