Procedural dungeon¶
rooms and corridors on a TileMap
â–¶ Run in browserTags: 2d tilemap procgen dungeon collision
Places non-overlapping rooms at random, links each to the previous one with an L-shaped corridor, and renders the result as wall and floor tiles. Generation is a pure function of the seed, so the same number always yields the same dungeon. A player dot walks the map with grid-based wall collision. Controls: WASD / arrows move, R rolls a new seed, [ / ] step the seed, Esc quits. Headless self-check: uv run python examples/features/2d/procgen_dungeon.py –test
What it demonstrates¶
A self-contained, deterministic dungeon generator (
generate_dungeon) you can lift straight into a game: random.Random(seed) in, grid + room list out.Reachability by construction: every room is carved, then connected to the one placed before it, so the whole floor is one region.
TileMapLayer.set_cells(): the entire grid rewritten in one vectorised call, which is what makes instant regeneration cheap.
Collision against the grid, not against physics bodies: the player’s box is tested per axis with
can_stand, the pattern roguelikes and grid games use.
Run: uv run python examples/features/2d/procgen_dungeon.py
Source¶
1"""Procedural dungeon: rooms and corridors on a TileMap
2
3Places non-overlapping rooms at random, links each to the previous one with an
4L-shaped corridor, and renders the result as wall and floor tiles. Generation
5is a pure function of the seed, so the same number always yields the same
6dungeon. A player dot walks the map with grid-based wall collision.
7Controls: WASD / arrows move, R rolls a new seed, [ / ] step the seed, Esc quits.
8Headless self-check: uv run python examples/features/2d/procgen_dungeon.py --test
9
10# /// simvx
11# tags = ["2d", "tilemap", "procgen", "dungeon", "collision"]
12# web = { root = "DungeonDemo", width = 960, height = 540 }
13# ///
14
15## What it demonstrates
16- A self-contained, deterministic dungeon generator (`generate_dungeon`) you
17 can lift straight into a game: random.Random(seed) in, grid + room list out.
18- Reachability by construction: every room is carved, then connected to the
19 one placed before it, so the whole floor is one region.
20- TileMapLayer.set_cells(): the entire grid rewritten in one vectorised call,
21 which is what makes instant regeneration cheap.
22- Collision against the grid, not against physics bodies: the player's box is
23 tested per axis with `can_stand`, the pattern roguelikes and grid games use.
24
25Run: uv run python examples/features/2d/procgen_dungeon.py
26"""
27
28from __future__ import annotations
29
30import random
31from collections import deque
32from dataclasses import dataclass
33
34import numpy as np
35
36from simvx.core import (
37 AnchorPreset,
38 CanvasLayer,
39 Input,
40 InputMap,
41 Key,
42 Label,
43 Node2D,
44 Sprite2D,
45 TileMap,
46 TileSet,
47 Vec2,
48)
49from simvx.graphics import App
50
51WIDTH, HEIGHT = 960, 540
52TILE = 16
53GRID_W, GRID_H = 60, 32 # 960 x 512 px of map; the last 28 px are the HUD strip
54
55FLOOR, WALL = 0, 1 # grid values; also the tile ids, atlas is built to match
56
57PLAYER_HALF = 5.0 # half-extent of the player's collision box, px
58PLAYER_SPEED = 150.0
59
60
61# -- Generator ----------------------------------------------------------------
62
63
64@dataclass(frozen=True)
65class Room:
66 """A carved room: interior rectangle in grid cells."""
67
68 x: int
69 y: int
70 w: int
71 h: int
72
73 @property
74 def centre(self) -> tuple[int, int]:
75 return self.x + self.w // 2, self.y + self.h // 2
76
77
78@dataclass(frozen=True)
79class Dungeon:
80 """Generator output: (H, W) uint8 grid of FLOOR/WALL plus the room list."""
81
82 grid: np.ndarray
83 rooms: list[Room]
84 seed: int
85
86
87def generate_dungeon(
88 width: int,
89 height: int,
90 seed: int,
91 *,
92 room_attempts: int = 40,
93 room_min: int = 4,
94 room_max: int = 8,
95) -> Dungeon:
96 """Generate a rooms-and-corridors dungeon, deterministic for a given seed.
97
98 Classic room-place-and-connect: try `room_attempts` random rectangles,
99 keep the ones that fit with a one-cell wall gap, and join each kept room
100 to the previously kept one with an L-shaped corridor (axis order chosen at
101 random). Connecting only ever to an already-connected room is what makes
102 the whole floor reachable without any post-hoc repair pass.
103 """
104 rng = random.Random(seed)
105 grid = np.full((height, width), WALL, dtype=np.uint8)
106 rooms: list[Room] = []
107
108 for _ in range(room_attempts):
109 w = rng.randint(room_min, room_max)
110 h = rng.randint(room_min, room_max)
111 room = Room(rng.randint(1, width - w - 1), rng.randint(1, height - h - 1), w, h)
112 # Reject anything that would touch an existing room (1-cell gap kept).
113 if any(
114 room.x - 1 < r.x + r.w
115 and room.x + room.w + 1 > r.x
116 and room.y - 1 < r.y + r.h
117 and room.y + room.h + 1 > r.y
118 for r in rooms
119 ):
120 continue
121 grid[room.y : room.y + room.h, room.x : room.x + room.w] = FLOOR
122 if rooms:
123 _carve_corridor(grid, room.centre, rooms[-1].centre, rng)
124 rooms.append(room)
125
126 return Dungeon(grid, rooms, seed)
127
128
129def _carve_corridor(grid: np.ndarray, a: tuple[int, int], b: tuple[int, int], rng: random.Random) -> None:
130 """Carve an L-shaped floor corridor between two cell centres."""
131 (ax, ay), (bx, by) = a, b
132 corner = (bx, ay) if rng.random() < 0.5 else (ax, by)
133 for x0, y0, x1, y1 in ((ax, ay, *corner), (*corner, bx, by)):
134 grid[min(y0, y1) : max(y0, y1) + 1, min(x0, x1) : max(x0, x1) + 1] = FLOOR
135
136
137def can_stand(grid: np.ndarray, x: float, y: float, half: float = PLAYER_HALF) -> bool:
138 """True if a box of half-extent `half` centred at world (x, y) is all floor."""
139 cx0, cx1 = int((x - half) // TILE), int((x + half) // TILE)
140 cy0, cy1 = int((y - half) // TILE), int((y + half) // TILE)
141 h, w = grid.shape
142 if cx0 < 0 or cy0 < 0 or cx1 >= w or cy1 >= h:
143 return False
144 return bool(np.all(grid[cy0 : cy1 + 1, cx0 : cx1 + 1] == FLOOR))
145
146
147def reachable_floor(grid: np.ndarray, start: tuple[int, int]) -> int:
148 """Count floor cells reachable from `start` via 4-neighbour flood fill."""
149 h, w = grid.shape
150 seen = np.zeros_like(grid, dtype=bool)
151 queue = deque([start])
152 seen[start[1], start[0]] = True
153 count = 0
154 while queue:
155 x, y = queue.popleft()
156 count += 1
157 for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
158 if 0 <= nx < w and 0 <= ny < h and not seen[ny, nx] and grid[ny, nx] == FLOOR:
159 seen[ny, nx] = True
160 queue.append((nx, ny))
161 return count
162
163
164# -- Rendering ----------------------------------------------------------------
165
166
167def _make_atlas() -> np.ndarray:
168 """Two 16 px tiles side by side: floor (id 0, dark) then wall (id 1, blue-grey)."""
169 rng = np.random.RandomState(11)
170 atlas = np.zeros((TILE, TILE * 2, 4), dtype=np.uint8)
171 for tid, base in ((FLOOR, (52, 46, 42)), (WALL, (96, 104, 132))):
172 noise = rng.randint(-10, 11, size=(TILE, TILE, 1))
173 tile = np.clip(np.array(base).reshape(1, 1, 3) + noise, 0, 255).astype(np.uint8)
174 x0 = tid * TILE
175 atlas[:, x0 : x0 + TILE, :3] = tile
176 atlas[:, x0 : x0 + TILE, 3] = 255
177 # A darker mortar line along each wall tile's edges reads as brickwork.
178 atlas[0, TILE:, :3] = atlas[:, TILE, :3] = (60, 66, 88)
179 return atlas
180
181
182def _make_player_texture(size: int = 12) -> np.ndarray:
183 """A warm disc with a dark outline."""
184 img = np.zeros((size, size, 4), dtype=np.uint8)
185 yy, xx = np.mgrid[0:size, 0:size]
186 d = np.hypot(xx - (size - 1) / 2, yy - (size - 1) / 2)
187 img[d <= size / 2 - 0.5] = (40, 28, 10, 255)
188 img[d <= size / 2 - 2.0] = (255, 205, 70, 255)
189 return img
190
191
192class DungeonDemo(Node2D):
193 """TileMap-rendered dungeon with a grid-collided player and live reseeding."""
194
195 def on_ready(self):
196 InputMap.add_action("move_left", [Key.A, Key.LEFT])
197 InputMap.add_action("move_right", [Key.D, Key.RIGHT])
198 InputMap.add_action("move_up", [Key.W, Key.UP])
199 InputMap.add_action("move_down", [Key.S, Key.DOWN])
200 InputMap.add_action("regen", [Key.R])
201 InputMap.add_action("seed_down", [Key.LEFT_BRACKET])
202 InputMap.add_action("seed_up", [Key.RIGHT_BRACKET])
203 InputMap.add_action("quit", [Key.ESCAPE])
204
205 self._tilemap = self.add_child(TileMap(name="Dungeon"))
206 self._tilemap.tile_set = TileSet.from_atlas_array(
207 _make_atlas(), width=TILE * 2, height=TILE, tile_size=(TILE, TILE)
208 )
209 self._tilemap.cell_size = (TILE, TILE)
210
211 self._player = self.add_child(Sprite2D(texture=_make_player_texture(), width=12, height=12, name="Player"))
212
213 hud = self.add_child(CanvasLayer(name="HUD", layer=CanvasLayer.Band.UI))
214 self._hud = hud.add_child(Label("", name="Status"))
215 self._hud.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
216 self._hud.margin_left = 12
217 self._hud.margin_right = 12
218 self._hud.margin_top = -26
219 self._hud.margin_bottom = -4
220 self._hud.font_size = 15.0
221
222 self._regenerate(seed=1)
223
224 def _regenerate(self, seed: int) -> None:
225 """Rebuild the dungeon for `seed` and drop the player in the first room."""
226 self._dungeon = generate_dungeon(GRID_W, GRID_H, seed)
227 # One bulk write replaces every cell: grid values ARE the tile ids.
228 self._tilemap.get_layer(0).set_cells(self._dungeon.grid.astype(np.int32))
229 cx, cy = self._dungeon.rooms[0].centre
230 self._player.position = Vec2((cx + 0.5) * TILE, (cy + 0.5) * TILE)
231 self._hud.text = (
232 f"Seed {seed} rooms {len(self._dungeon.rooms)} "
233 "R: new seed [ / ]: step seed WASD / arrows: move Esc: quit"
234 )
235
236 def on_update(self, dt: float):
237 if Input.is_action_just_pressed("quit"):
238 self.app.quit()
239 return
240 if Input.is_action_just_pressed("regen"):
241 self._regenerate(seed=random.randrange(1_000_000))
242 elif Input.is_action_just_pressed("seed_down"):
243 self._regenerate(seed=self._dungeon.seed - 1)
244 elif Input.is_action_just_pressed("seed_up"):
245 self._regenerate(seed=self._dungeon.seed + 1)
246
247 move = Input.get_vector("move_left", "move_right", "move_up", "move_down")
248 if move.x == 0 and move.y == 0:
249 return
250 # Per-axis moves so a blocked axis does not stop the other (wall sliding).
251 grid, pos = self._dungeon.grid, self._player.position
252 x, y = pos.x, pos.y
253 if move.x != 0 and can_stand(grid, x + move.x * PLAYER_SPEED * dt, y):
254 x += move.x * PLAYER_SPEED * dt
255 if move.y != 0 and can_stand(grid, x, y + move.y * PLAYER_SPEED * dt):
256 y += move.y * PLAYER_SPEED * dt
257 self._player.position = Vec2(x, y)
258
259
260# -- Self-check ---------------------------------------------------------------
261
262
263def _selftest() -> bool:
264 ok = True
265
266 def check(label: str, passed: bool, detail: str) -> None:
267 nonlocal ok
268 ok = ok and passed
269 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
270
271 d1 = generate_dungeon(GRID_W, GRID_H, seed=7)
272 d2 = generate_dungeon(GRID_W, GRID_H, seed=7)
273 check(
274 "same seed reproduces the dungeon",
275 bool(np.array_equal(d1.grid, d2.grid)) and d1.rooms == d2.rooms,
276 f"{len(d1.rooms)} rooms both times",
277 )
278
279 d3 = generate_dungeon(GRID_W, GRID_H, seed=8)
280 check("a different seed differs", not np.array_equal(d1.grid, d3.grid), "seed 7 vs 8")
281
282 border = np.concatenate([d1.grid[0], d1.grid[-1], d1.grid[:, 0], d1.grid[:, -1]])
283 check("the border stays solid wall", bool(np.all(border == WALL)), f"{len(border)} edge cells")
284
285 carved = all(np.all(d1.grid[r.y : r.y + r.h, r.x : r.x + r.w] == FLOOR) for r in d1.rooms)
286 check("every room interior is floor", carved, f"{len(d1.rooms)} rooms checked")
287
288 # Reachability across a spread of seeds: the flood fill from the first
289 # room must touch every floor cell, or a room was carved but never linked.
290 for seed in (1, 7, 42, 12345):
291 d = generate_dungeon(GRID_W, GRID_H, seed=seed)
292 floor_total = int(np.sum(d.grid == FLOOR))
293 reached = reachable_floor(d.grid, d.rooms[0].centre)
294 check(
295 f"seed {seed}: all floor is reachable",
296 reached == floor_total and len(d.rooms) >= 5,
297 f"{reached}/{floor_total} cells, {len(d.rooms)} rooms",
298 )
299
300 # Collision: a room centre is standable, the wall beside the map is not,
301 # and a spot flush against the border wall rejects the overlapping box.
302 cx, cy = d1.rooms[0].centre
303 check(
304 "the spawn point is standable",
305 can_stand(d1.grid, (cx + 0.5) * TILE, (cy + 0.5) * TILE),
306 f"room centre cell ({cx}, {cy})",
307 )
308 check("a wall cell blocks the player", not can_stand(d1.grid, TILE * 0.5, TILE * 0.5), "border cell (0, 0)")
309 check(
310 "a box overlapping a wall is blocked",
311 not can_stand(d1.grid, TILE + PLAYER_HALF - 1, (cy + 0.5) * TILE),
312 "flush against the west border",
313 )
314
315 print("SELFTEST:", "PASS" if ok else "FAIL")
316 return ok
317
318
319if __name__ == "__main__":
320 import sys
321
322 if "--test" in sys.argv:
323 sys.exit(0 if _selftest() else 1)
324 App(title="Procedural Dungeon", width=WIDTH, height=HEIGHT).run(DungeonDemo())