Procedural Dungeon

rooms and corridors on a TileMap.

▶ Run in browser

Tags: 2d tilemap procgen dungeon collision

generate_dungeon(width, height, seed) is a plain function you can lift into a game. It seeds a random.Random, tries a number of random rectangles, keeps each one that leaves a one-cell wall gap to every room already placed, and joins it to the previously kept room with an L-shaped corridor. Because a room is only ever linked to one that is already connected, the whole floor is one reachable region with no repair pass, and the same seed always gives the same dungeon. The result is a (height, width) numpy grid of FLOOR and WALL plus the room list.

The grid values are the tile ids of a two-tile atlas built with numpy and TileSet.from_atlas_array, so TileMap.get_layer(0).set_cells(grid) draws the whole map in one vectorised call, which is what makes regenerating instant.

The playfield is a fixed design resolution, the map plus the status strip below it, scaled to fit the window, so the same seed shows the same whole map at any window size.

The player collides against the grid rather than against physics bodies: can_stand checks that every cell under the player’s box is floor, and the move is tried one axis at a time so a blocked axis slides along the wall.

Controls: WASD, the arrow keys or the left stick move. R, a click or a tap rolls a new seed; [ and ] step to the previous and next seed.

Source

  1# /// simvx
  2# tags = ["2d", "tilemap", "procgen", "dungeon", "collision"]
  3# web = { width = 960, height = 540 }
  4# ///
  5"""Procedural Dungeon: rooms and corridors on a TileMap.
  6
  7`generate_dungeon(width, height, seed)` is a plain function you can lift into a
  8game. It seeds a `random.Random`, tries a number of random rectangles, keeps
  9each one that leaves a one-cell wall gap to every room already placed, and
 10joins it to the previously kept room with an L-shaped corridor. Because a room
 11is only ever linked to one that is already connected, the whole floor is one
 12reachable region with no repair pass, and the same seed always gives the same
 13dungeon. The result is a `(height, width)` numpy grid of `FLOOR` and `WALL`
 14plus the room list.
 15
 16The grid values are the tile ids of a two-tile atlas built with numpy and
 17`TileSet.from_atlas_array`, so `TileMap.get_layer(0).set_cells(grid)` draws
 18the whole map in one vectorised call, which is what makes regenerating
 19instant.
 20
 21The playfield is a fixed design resolution, the map plus the status strip
 22below it, scaled to fit the window, so the same seed shows the same whole map
 23at any window size.
 24
 25The player collides against the grid rather than against physics bodies:
 26`can_stand` checks that every cell under the player's box is floor, and the
 27move is tried one axis at a time so a blocked axis slides along the wall.
 28
 29Controls: WASD, the arrow keys or the left stick move. R, a click or a tap
 30rolls a new seed; `[` and `]` step to the previous and next seed.
 31"""
 32
 33import random
 34from dataclasses import dataclass
 35
 36import numpy as np
 37
 38from simvx.core import (
 39    Anchor,
 40    Axis2D,
 41    CanvasLayer,
 42    ContentScaleMode,
 43    Controller,
 44    Input,
 45    Key,
 46    Label,
 47    Node2D,
 48    Pointer,
 49    Sprite2D,
 50    TileMap,
 51    TileSet,
 52    on_input,
 53)
 54from simvx.graphics import App
 55
 56TILE = 16
 57GRID_W, GRID_H = 60, 32
 58HUD_HEIGHT = 28  # the status strip below the map
 59FLOOR, WALL = 0, 1  # grid values and tile ids alike: the atlas is built in this order
 60PLAYER_SIZE = 12
 61PLAYER_HALF = 5.0  # half-extent of the collision box, a little inside the sprite
 62PLAYER_SPEED = 150.0
 63
 64
 65@dataclass(frozen=True)
 66class Room:
 67    x: int
 68    y: int
 69    w: int
 70    h: int
 71
 72    @property
 73    def centre(self) -> tuple[int, int]:
 74        return self.x + self.w // 2, self.y + self.h // 2
 75
 76    def near(self, other: Room) -> bool:
 77        """True if the rooms overlap or touch, counting a one-cell margin."""
 78        return (
 79            self.x - 1 < other.x + other.w
 80            and other.x < self.x + self.w + 1
 81            and self.y - 1 < other.y + other.h
 82            and other.y < self.y + self.h + 1
 83        )
 84
 85
 86@dataclass(frozen=True)
 87class Dungeon:
 88    grid: np.ndarray  # (height, width) uint8 of FLOOR / WALL
 89    rooms: list[Room]
 90    seed: int
 91
 92
 93def generate_dungeon(
 94    width: int, height: int, seed: int, *, attempts: int = 40, room_min: int = 4, room_max: int = 8
 95) -> Dungeon:
 96    """Place non-touching rooms at random and join each to the previous one."""
 97    rng = random.Random(seed)
 98    grid = np.full((height, width), WALL, dtype=np.uint8)
 99    rooms: list[Room] = []
100    for _ in range(attempts):
101        w, h = rng.randint(room_min, room_max), rng.randint(room_min, room_max)
102        room = Room(rng.randint(1, width - w - 1), rng.randint(1, height - h - 1), w, h)
103        if any(room.near(r) for r in rooms):
104            continue
105        grid[room.y : room.y + room.h, room.x : room.x + room.w] = FLOOR
106        if rooms:
107            carve_corridor(grid, room.centre, rooms[-1].centre, rng)
108        rooms.append(room)
109    return Dungeon(grid, rooms, seed)
110
111
112def carve_corridor(grid: np.ndarray, a: tuple[int, int], b: tuple[int, int], rng: random.Random) -> None:
113    """Carve an L-shaped corridor from `a` to `b`, turning at a random corner."""
114    (ax, ay), (bx, by) = a, b
115    corner = (bx, ay) if rng.random() < 0.5 else (ax, by)
116    for (x0, y0), (x1, y1) in ((a, corner), (corner, b)):
117        grid[min(y0, y1) : max(y0, y1) + 1, min(x0, x1) : max(x0, x1) + 1] = FLOOR
118
119
120def can_stand(grid: np.ndarray, x: float, y: float, half: float = PLAYER_HALF) -> bool:
121    """True if a box of half-extent `half` centred on world point (x, y) covers only floor."""
122    x0, x1 = int((x - half) // TILE), int((x + half) // TILE)
123    y0, y1 = int((y - half) // TILE), int((y + half) // TILE)
124    h, w = grid.shape
125    if x0 < 0 or y0 < 0 or x1 >= w or y1 >= h:
126        return False
127    return bool(np.all(grid[y0 : y1 + 1, x0 : x1 + 1] == FLOOR))
128
129
130def make_atlas() -> np.ndarray:
131    """Two noisy tiles side by side: dark floor (id 0), then blue-grey wall (id 1) with mortar edges."""
132    rng = np.random.default_rng(11)
133    atlas = np.full((TILE, TILE * 2, 4), 255, dtype=np.uint8)
134    for tile_id, base in ((FLOOR, (52, 46, 42)), (WALL, (96, 104, 132))):
135        noise = rng.integers(-10, 11, size=(TILE, TILE, 1))
136        atlas[:, tile_id * TILE : (tile_id + 1) * TILE, :3] = np.clip(np.array(base) + noise, 0, 255)
137    # Mortar on the wall tile: its top row and its left column.
138    atlas[0, TILE:, :3] = (60, 66, 88)
139    atlas[:, TILE, :3] = (60, 66, 88)
140    return atlas
141
142
143def make_player_texture() -> np.ndarray:
144    """A warm disc with a dark outline."""
145    img = np.zeros((PLAYER_SIZE, PLAYER_SIZE, 4), dtype=np.uint8)
146    yy, xx = np.mgrid[0:PLAYER_SIZE, 0:PLAYER_SIZE]
147    d = np.hypot(xx - (PLAYER_SIZE - 1) / 2, yy - (PLAYER_SIZE - 1) / 2)
148    img[d <= PLAYER_SIZE / 2 - 0.5] = (40, 28, 10, 255)
149    img[d <= PLAYER_SIZE / 2 - 2.0] = (255, 205, 70, 255)
150    return img
151
152
153class DungeonDemo(Node2D):
154    input_context = {
155        "move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK],
156        "new_seed": [Key.R, Pointer.PRIMARY],
157        "prev_seed": [Key.LEFT_BRACKET],
158        "next_seed": [Key.RIGHT_BRACKET],
159    }
160
161    def on_ready(self):
162        self.tree.content_scale_mode = ContentScaleMode.FIT
163        self.tree.content_scale_size = (GRID_W * TILE, GRID_H * TILE + HUD_HEIGHT)
164        atlas = make_atlas()
165        tile_set = TileSet.from_atlas_array(atlas, width=atlas.shape[1], height=atlas.shape[0], tile_size=(TILE, TILE))
166        self.tilemap = self.add_child(TileMap(name="Dungeon", tile_set=tile_set, cell_size=(TILE, TILE)))
167        self.player = self.add_child(
168            Sprite2D(name="Player", texture=make_player_texture(), width=PLAYER_SIZE, height=PLAYER_SIZE)
169        )
170        self.status = self.add_child(CanvasLayer()).add_child(
171            Label(anchor=Anchor.BOTTOM_WIDE, inset=(12, 4), size=(0, 22), font_size=15, autoshrink=True)
172        )
173        self.regenerate(seed=1)
174
175    def regenerate(self, seed: int) -> None:
176        """Build the dungeon for `seed`, draw it, and drop the player in the first room."""
177        self.dungeon = generate_dungeon(GRID_W, GRID_H, seed)
178        self.tilemap.get_layer(0).set_cells(self.dungeon.grid)
179        cx, cy = self.dungeon.rooms[0].centre
180        self.player.position = ((cx + 0.5) * TILE, (cy + 0.5) * TILE)
181        self.status.text = f"Seed {seed}   rooms {len(self.dungeon.rooms)}   " + HELP
182
183    @on_input("new_seed")
184    def _new_seed(self, _event):
185        self.regenerate(random.randrange(1_000_000))
186
187    @on_input("prev_seed")
188    def _prev_seed(self, _event):
189        self.regenerate(self.dungeon.seed - 1)
190
191    @on_input("next_seed")
192    def _next_seed(self, _event):
193        self.regenerate(self.dungeon.seed + 1)
194
195    def on_update(self, dt: float):
196        step = Input.value("move") * PLAYER_SPEED * dt
197        x, y = self.player.position
198        # One axis at a time, so a wall on one axis still lets the player slide along the other.
199        if step.x and can_stand(self.dungeon.grid, x + step.x, y):
200            x += step.x
201        if step.y and can_stand(self.dungeon.grid, x, y + step.y):
202            y += step.y
203        self.player.position = (x, y)
204
205
206# Example help overlay: not part of the technique shown.
207HELP = "R / click: new seed   [ / ]: step seed   WASD / arrows: move"
208
209
210if __name__ == "__main__":
211    App(title="Procedural Dungeon", width=960, height=540).run(DungeonDemo())