Procedural Level Generation with AI
Store each room as a small data record with doors, size, and a weight, then let a seed pick from that list and throw out any room that would overlap.
This is a different slice from our earlier procedural generation post: that one placed same-size rooms on a grid with a random walk in Unity and Godot, while this one is about the template list itself and how a seed chooses from it, in plain Python.
Nothing here is an AI tool. It is a short list you write by hand and about fifty lines of code you own.
The earlier post covered why seeds matter and how to make them repeatable. This one assumes you already accept that, and moves on to a different question: how do you get rooms that are not all the same shape?
The answer is templates. A template is a description of one kind of room. Your generator does not invent rooms. It picks from your list.
Why templates
A grid of identical cells is easy to place but looks like graph paper. Hand-built levels look better but take time you may not have.
Templates sit between the two. You design a handful of room shapes once. The seed decides which ones appear, how often, and where they connect. Every level is different, but every room is one you approved.
That also gives you a single place to tune. If the levels feel cramped, you change the list, not the generator.
A room is data
Each template needs four things:
- A name, so you can debug it and map it to art later.
- A size, as width and height in tiles.
- Doors, as a side and an offset along that side.
- A weight, which controls how often the seed picks it.
Here is a starting list:
ROOM_TEMPLATES = [
{"name": "closet", "w": 3, "h": 3, "weight": 5, "doors": [("W", 1), ("E", 1)]},
{"name": "hall", "w": 7, "h": 3, "weight": 4, "doors": [("W", 1), ("E", 1), ("S", 3)]},
{"name": "hub", "w": 5, "h": 5, "weight": 2, "doors": [("N", 2), ("S", 2), ("W", 2), ("E", 2)]},
{"name": "vault", "w": 6, "h": 6, "weight": 1, "doors": [("N", 3)]},
]
A door like ("E", 1) sits on the east wall, one tile down from the top. The offset should be inside the wall’s length, so keep it below the room’s height for east and west doors and below its width for north and south doors.
Weights are relative, not percentages. A closet with weight 5 shows up about five times as often as a vault with weight 1, among templates that fit. You will tune these numbers by playing, not by math.
The vault has a single door. That makes it a dead end, which is often what you want for a treasure room.
Where a door meets the world
To join two rooms, a door on one must line up with a matching door on the other. East meets west. North meets south.
Each door needs a point in the room’s own coordinates. Use x to the right and y down:
OPPOSITE = {"N": "S", "S": "N", "E": "W", "W": "E"}
def door_point(t, door):
side, o = door
return {"N": (o, 0), "S": (o, t["h"]), "W": (0, o), "E": (t["w"], o)}[side]
To attach a new room, take the world position of the open door, subtract the new room’s matching door point, and you have the new room’s corner. The two rooms then share a wall edge with their doors on the same tile.
The weighted pick
Python’s random module has a choices() function that takes weights. The Python docs say only some parts of the module are guaranteed to stay the same across versions, and random() is one of them. If you want a seed to give the same level on a future Python, build the pick from random() yourself:
def weighted_pick(rng, items):
roll = rng.random() * sum(t["weight"] for t in items)
for t in items:
roll -= t["weight"]
if roll < 0:
return t
return items[-1]
It adds up the weights, rolls a number in that range, and walks the list until the roll runs out. Heavier templates cover more of the range, so they win more often.
Only pass in templates that can connect. If the open door faces east, the new room needs a west door. Filter first, then pick.
The reject rule
A picked room may land on top of one you already placed. The rule is short: if it overlaps, drop it and roll again.
Two rectangles overlap when each one starts before the other ends, on both axes:
def overlaps(a, b):
return (a["x"] < b["x"] + b["w"] and b["x"] < a["x"] + a["w"] and
a["y"] < b["y"] + b["h"] and b["y"] < a["y"] + a["h"])
The strict less-than matters. Rooms that only share an edge do not count as overlapping, and sharing an edge is how doors connect.
The generator
Now put it together. Start from one room, keep a queue of open doors, and try to fill each one:
import random
def generate(seed, max_rooms=12, tries_per_door=4):
rng = random.Random(seed)
start = ROOM_TEMPLATES[2]
placed = [{"t": start, "x": 0, "y": 0, "w": start["w"], "h": start["h"]}]
open_doors = [(placed[0], d) for d in start["doors"]]
while open_doors and len(placed) < max_rooms:
room, door = open_doors.pop(0)
px, py = door_point(room["t"], door)
wx, wy = room["x"] + px, room["y"] + py
need = OPPOSITE[door[0]]
fits = [t for t in ROOM_TEMPLATES if any(s == need for s, _ in t["doors"])]
for _ in range(tries_per_door):
t = weighted_pick(rng, fits)
entry = next(d for d in t["doors"] if d[0] == need)
ex, ey = door_point(t, entry)
cand = {"t": t, "x": wx - ex, "y": wy - ey, "w": t["w"], "h": t["h"]}
if any(overlaps(cand, p) for p in placed):
continue
placed.append(cand)
open_doors += [(cand, d) for d in t["doors"] if d != entry]
break
return placed
random.Random(seed) gives you a generator you own, separate from anything else in your program. Every draw comes from it, in the same order, so the same seed rebuilds the same level.
Each open door gets a few tries. If every try overlaps, the door is skipped. Treat a skipped door as a wall when you build the level. Doors left in the queue when you hit max_rooms become walls too.
The start room is the hub because it has four doors. Starting from a closet gives you a thin line of rooms.
Check it
Print the result for one seed:
for r in generate(48213):
print(r["t"]["name"], r["x"], r["y"])
Run it twice and compare. The lists should match line for line.
Then test the reject rule across many seeds. Loop over a few hundred, compare every pair of placed rooms with overlaps(), and assert that none do. If that check ever fails, the bug is in your door points or your overlap test, not in the seed.
Tune by changing the list
Play a few seeds and change one number at a time:
- Too many tiny rooms? Lower the closet weight.
- Levels end too fast? Add a door to a common template.
- Treasure never appears? Raise the vault weight a little.
- Rooms keep failing to fit? Raise
tries_per_dooror add a smaller template.
When you add a new template, add it to the end of the list. Changing the order or the weights changes what existing seeds produce, so note the change if players share seeds.
Stop here
You now have rooms as data, a seed that picks them by weight, and a rule that drops any room that would overlap. Turning each placed record into tiles or scenes in your engine is the next step, and it is engine code, not generator code. Keep the two apart so you can test this file on its own.