Lesson 21

Procedural dungeons: random, but never careless.

Procedural generation is useful only when it produces playable maps. The goal is not pure randomness. The goal is controlled variety with validation.

AdvancedProcedural ContentDungeons45-70 minutes

1. Start from rooms, not noise

Random noise can create interesting caves, but rooms and corridors are easier for action games. Generate rectangular rooms, reject overlaps, then connect room centers with corridors. This gives you spaces that are readable for combat and exploration.

function createDungeon(cols, rows) {
    const map = Array.from({ length: rows }, () => Array(cols).fill(WALL));
    const rooms = [];

    for (let attempt = 0; attempt < 80; attempt++) {
        const room = {
            x: randInt(1, cols - 10),
            y: randInt(1, rows - 8),
            w: randInt(5, 10),
            h: randInt(4, 8)
        };
        if (rooms.some(other => intersects(room, other, 1))) continue;
        carveRoom(map, room);
        if (rooms.length) connectRooms(map, rooms[rooms.length - 1], room);
        rooms.push(room);
    }

    return { map, rooms };
}

Source pattern: Supagames dungeon games build floors from wall data, enemy placement, exits and clear progression rules rather than disconnected random hazards.

Rejecting overlaps is important. If rooms stack on top of each other, the map may still work, but it stops feeling designed.

2. Carve rooms and corridors with simple loops

Carving means changing wall tiles into floor tiles. A corridor can move horizontally first, then vertically. That creates L-shaped connections that are easy for players to navigate and easy for pathfinding to understand.

function carveRoom(map, room) {
    for (let y = room.y; y < room.y + room.h; y++) {
        for (let x = room.x; x < room.x + room.w; x++) {
            map[y][x] = FLOOR;
        }
    }
}

function connectRooms(map, a, b) {
    const ax = Math.floor(a.x + a.w / 2);
    const ay = Math.floor(a.y + a.h / 2);
    const bx = Math.floor(b.x + b.w / 2);
    const by = Math.floor(b.y + b.h / 2);

    for (let x = Math.min(ax, bx); x <= Math.max(ax, bx); x++) map[ay][x] = FLOOR;
    for (let y = Math.min(ay, by); y <= Math.max(ay, by); y++) map[y][bx] = FLOOR;
}

Later you can randomize corridor order, add doors or widen hallways. Start with a boring reliable generator, then add flavor.

3. Place start and exit deliberately

The first room is a good start location. The farthest room is often a good exit or boss room. This gives the dungeon a natural route. If you place the exit randomly, it may appear next to the start and collapse the whole run.

function roomCenter(room) {
    return {
        x: Math.floor(room.x + room.w / 2),
        y: Math.floor(room.y + room.h / 2)
    };
}

function chooseExitRoom(rooms) {
    const start = roomCenter(rooms[0]);
    return rooms.reduce((best, room) => {
        const c = roomCenter(room);
        const d = Math.abs(c.x - start.x) + Math.abs(c.y - start.y);
        return d > best.distance ? { room, distance: d } : best;
    }, { room: rooms[0], distance: 0 }).room;
}

Distance does not need to be perfect. It just needs to make the exit feel earned.

4. Encounters need pacing

Do not put enemies in every room. Alternate quiet rooms, reward rooms, combat rooms and dangerous rooms. The player needs breathing room to understand the dungeon. Use floor number to scale enemy count and enemy types, but keep early rooms gentle.

function populateRooms(state, rooms, floor) {
    for (let i = 1; i < rooms.length; i++) {
        const room = rooms[i];
        if (Math.random() < 0.25) continue;
        const count = Math.min(2 + Math.floor(floor / 2), 6);
        for (let n = 0; n < count; n++) {
            spawnEnemyInRoom(state, room, chooseEnemyType(floor));
        }
    }
}

Procedural does not mean every run must be equally intense. Variety includes quiet.

5. Validate reachability

Before publishing a procedural generator, test that start, exit and required keys are reachable. Use the pathfinding lesson's BFS. If validation fails, generate again or repair the map. Players should never lose because the generator made an impossible floor.

function dungeonIsPlayable(map, start, exit) {
    const path = findPathBfs(map, start, exit);
    return path.length > 0;
}

function generatePlayableDungeon(cols, rows) {
    for (let attempt = 0; attempt < 30; attempt++) {
        const dungeon = createDungeon(cols, rows);
        const start = roomCenter(dungeon.rooms[0]);
        const exit = roomCenter(chooseExitRoom(dungeon.rooms));
        if (dungeonIsPlayable(dungeon.map, start, exit)) {
            return { ...dungeon, start, exit };
        }
    }
    throw new Error("Could not generate playable dungeon");
}

Thirty attempts is usually plenty for a simple room generator. If it fails often, the generator rules are too strict or the map is too small.

6. Theme the same generator

A vampire crypt, slime sewer and clockwork vault can share the same generator but use different tiles, enemies, rewards and room labels. This is how you get variety without rewriting the system. The generator builds structure; theme data gives identity.

Good procedural content feels authored because the constraints are authored. It has a start, a goal, readable rooms, fair enemies and a recovery rhythm. The random number generator is only one collaborator.

Previous: Game UI. Next: Economy and upgrades.