The 7 Day Roguelike challenge starts this weekend and the first thing every entry needs is a map. It doesn't have to be a good one. It needs rooms, corridors between them, and no closed box for the player to get stuck in. Everything else can wait until day three.
Rogue solved that in 1980 with an idea so simple it feels like cheating. I wrote about my game's concept and mockups last year and skipped the generation part. Let's catch up on that, starting with the original.
The grid
Rogue splits the level into a three by three grid. Each cell gets at most one room, placed somewhere inside the cell with a random size, and that's the whole layout algorithm. Rooms can't overlap because they can't leave their cell, and the level always looks a little like a tic-tac-toe board with the lines rubbed out.
Some cells don't get a room. Rogue calls those gone rooms: a single floor tile where the corridors meet, so the cell still takes part in the connections but looks like a junction. Without them every level would be exactly nine rooms, and you'd notice after the second floor.
// src/dungeon/rooms.ts
export type Room = { x: number; y: number; w: number; h: number; gone: boolean };
const COLS = 3;
const ROWS = 3;
export function placeRooms(width: number, height: number, rng: () => number): Room[] {
const cellW = Math.floor(width / COLS);
const cellH = Math.floor(height / ROWS);
const rooms: Room[] = [];
// Rogue leaves out up to three rooms per level and draws a junction in their place
const gone = new Set<number>();
const leftOut = Math.floor(rng() * 4);
while (gone.size < leftOut) gone.add(Math.floor(rng() * COLS * ROWS));
for (let row = 0; row < ROWS; row++) {
for (let col = 0; col < COLS; col++) {
const cellX = col * cellW;
const cellY = row * cellH;
if (gone.has(row * COLS + col)) {
rooms.push({ x: cellX + Math.floor(cellW / 2), y: cellY + Math.floor(cellH / 2), w: 1, h: 1, gone: true });
continue;
}
// Leave a one tile margin so rooms in neighbouring cells never touch
const w = 4 + Math.floor(rng() * (cellW - 6));
const h = 3 + Math.floor(rng() * (cellH - 5));
const x = cellX + 1 + Math.floor(rng() * (cellW - w - 2));
const y = cellY + 1 + Math.floor(rng() * (cellH - h - 2));
rooms.push({ x, y, w, h, gone: false });
}
}
return rooms;
}
The rng argument is a function returning a number between 0 and 1. Pass Math.random today, pass a seeded one later and the same seed gives you the same level. I want that for bug reports, so I'm not wiring Math.random in anywhere.
The corridors
Here's the part people get wrong when they recreate this from memory. Rogue doesn't connect every room to every neighbour, and it doesn't connect them at random either. It builds a spanning tree first: pick a room, connect it to a neighbour that isn't connected yet, keep going until all nine are in the graph. Then it adds a handful of extra connections for loops.
The tree guarantees the player can reach every room. The extras make it feel less like a tree.
// src/dungeon/corridors.ts
import type { Room } from './rooms';
type Link = [number, number];
// Rooms are in a 3x3 grid: index 0 to 8, neighbours are up, down, left, right
function neighbours(i: number): number[] {
const col = i % 3;
const row = Math.floor(i / 3);
const out: number[] = [];
if (col > 0) out.push(i - 1);
if (col < 2) out.push(i + 1);
if (row > 0) out.push(i - 3);
if (row < 2) out.push(i + 3);
return out;
}
export function connectRooms(rooms: Room[], rng: () => number): Link[] {
const links: Link[] = [];
const inGraph = new Set<number>([Math.floor(rng() * rooms.length)]);
// Spanning tree: every room ends up reachable from the first one
while (inGraph.size < rooms.length) {
const from = [...inGraph][Math.floor(rng() * inGraph.size)];
const candidates = neighbours(from).filter((n) => !inGraph.has(n));
if (candidates.length === 0) continue;
const to = candidates[Math.floor(rng() * candidates.length)];
links.push([from, to]);
inGraph.add(to);
}
// A few extra passages so the level has loops to run around in
const extras = Math.floor(rng() * 4);
for (let i = 0; i < extras; i++) {
const from = Math.floor(rng() * rooms.length);
const options = neighbours(from).filter((to) => !links.some(([a, b]) => (a === from && b === to) || (a === to && b === from)));
if (options.length > 0) links.push([from, options[Math.floor(rng() * options.length)]]);
}
return links;
}
Carving the corridor itself is an L shape: walk horizontally from a random point on one room's wall, then vertically to a random point on the other's. Rogue puts a door tile where the corridor meets the room. If both rooms are in the same column you only need the vertical leg, and a gone room is its own door.
What you get
Nine cells, six to nine rooms, up to three junctions, a tree plus a couple of loops. Every level is connected and different, and every level looks like Rogue, which I count in its favour. Players who grew up on it recognise the shape before they've read a single message.
The grid does the constraining. Inside a cell the room can be anything, and because the cells never overlap you never write a single line of collision checking. BSP trees get you more varied layouts, cellular automata get you caves, and I'll write about both. But if I have seven days, I take the grid.
Where it falls short
The grid shows. After twenty floors you can feel the nine cells, and a room always sits at roughly the same nine places. Bigger games vary the grid size per level, or merge two cells into one wide room now and then, and that already hides most of it.
The other thing is corridors crossing corridors. Two L shaped passages can intersect, which Rogue didn't care about and your pathfinding won't either. Your tile renderer might, if corridor tiles look different from room floor at a junction.
Neither of those matters in a seven day game. If you're starting a roguelike this week, start here and replace it when the grid starts to bother you.
Read more here:
- Rogue's source on GitHub,
rooms.candpassages.care the two files that matter - Basic BSP dungeon generation on RogueBasin, the alternative to try when the grid starts to show