Procedural dungeon¶

rooms and corridors on a TileMap

â–¶ Run in browser

Tags: 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())