One life, one random dungeon. Everything moves only when YOU move. Die, and a brand-new dungeon is built on the spot with nothing carried over.
Loading the game engine. This takes a moment the first time.
Play it first, then read this. Same warrior, same arrow keys. Only the RULES changed, and the rules are what a game style is.
_process() in this whole file. _process() runs every frame, and this game has nothing to do between your keypresses, so it waits instead of running.Shattered Pixel Dungeon and the Pokémon Mystery Dungeon games play by these exact rules: a grid, a turn that passes only when you move, a fresh random dungeon every run, and a death that keeps nothing. Minecraft's hardcore mode is the same bargain without the grid, since dying there takes the whole world with it.
Hades, Spelunky and The Binding of Isaac also end the run when you die and deal you a fresh random map for the next one, so from across the room they look like the same game. Two things give them away. They run in real time instead of turns, and every death hands you something permanent to keep. That second one is the whole difference, and it makes them roguelites. The next lesson is theirs.
_new_dungeon() scatters twelve walls at random, then walks a line from the start to the exit and erases anything in the way, so every dungeon can be finished no matter what the dice said. Building a level from rules instead of placing it by hand is called procedural generation.
None of the earlier lessons use these. Each one shows the line where it appears here.
A Vector2 that only holds whole numbers. Just right for squares on a grid.
var player_cell: Vector2iGodot runs this for every key press or click that a button or other UI did not already use.
func _unhandled_input(event: InputEvent) -> void:True when a list holds that value, or a dictionary holds that name.
if enemy_cells.has(player_cell):Just which way a number points: -1, 0 or 1.
var step := Vector2i(signi(away.x), 0)Empties a list or a dictionary.
wall_cells.clear()A loop that keeps running the lines under it for as long as the condition stays true.
while wall_cells.size() < WALL_COUNT:Adds an item to the end of a list.
wall_cells.append(cell)Removes an item from a list, or a name and its value from a dictionary.
wall_cells.erase(walk)Skips the rest of this time round the loop and jumps to the next one.
continueA random whole number between two numbers, with both ends included.
return Vector2i(randi_range(0, grid_size - 1), randi_range(0, grid_size - 1))■ UI node · ■ 2D node · % unique name · .tscn instanced scene
extends Node2D
## How many tiles wide and tall the dungeon is.
@export var grid_size: int = 8
const TILE: int = 64
const WALL_COUNT: int = 12
const ENEMY_COUNT: int = 3
# Where the board's top-left corner sits, so an 8x8 board of 64-pixel tiles
# lands in the middle of the 1152 x 648 screen.
const BOARD_ORIGIN := Vector2(320, 68)
# The whole dungeon is just these lists of grid cells. A Vector2i is a
# Vector2 that only holds whole numbers, which is all a grid needs.
var player_cell: Vector2i
var exit_cell: Vector2i
var wall_cells: Array[Vector2i] = []
var enemy_cells: Array[Vector2i] = []
# One square per enemy, in the same order as enemy_cells, so moving an
# enemy's cell can also move its picture.
var enemy_squares: Array[ColorRect] = []
var run_number: int = 0
@onready var floor_tiles: Node2D = $FloorTiles
@onready var board: Node2D = $Board
@onready var grid_player: Sprite2D = $Player
@onready var hud: RoguelikeHUD = $HUD
func _ready() -> void:
_build_floor()
_new_dungeon()
# Turn-based means input drives EVERYTHING. One keypress, one turn.
# "is_action_pressed()" on the event ignores the key repeating while held,
# so holding an arrow down does not sprint you across the dungeon.
func _unhandled_input(event: InputEvent) -> void:
var dir := Vector2i.ZERO
if event.is_action_pressed("move_left"):
dir = Vector2i.LEFT
elif event.is_action_pressed("move_right"):
dir = Vector2i.RIGHT
elif event.is_action_pressed("move_up"):
dir = Vector2i.UP
elif event.is_action_pressed("move_down"):
dir = Vector2i.DOWN
if dir != Vector2i.ZERO:
_take_turn(dir)
func _take_turn(dir: Vector2i) -> void:
var target := player_cell + dir
# Off the board or into a wall: the turn never happens.
if not _is_open(target):
return
player_cell = target
grid_player.position = _cell_to_pixel(player_cell)
# The warrior picture only faces right. Walking left mirrors it.
if dir.x != 0:
grid_player.flip_h = dir.x < 0
# Walking INTO an enemy is just as fatal as being walked into.
if enemy_cells.has(player_cell):
_die()
return
if player_cell == exit_cell:
hud.flash("Escaped! A new dungeon is built.")
_new_dungeon()
return
_move_enemies()
if enemy_cells.has(player_cell):
_die()
# Each enemy takes one step toward the player: along whichever axis it is
# furthest away on, and if something blocks that step, it stands still.
func _move_enemies() -> void:
for i: int in enemy_cells.size():
var cell := enemy_cells[i]
var away := player_cell - cell
var step := Vector2i(signi(away.x), 0)
if absi(away.y) > absi(away.x):
step = Vector2i(0, signi(away.y))
var target := cell + step
# Enemies do not stack, but the player's cell is always fair game.
if target == player_cell or (_is_open(target) and not enemy_cells.has(target)):
enemy_cells[i] = target
enemy_squares[i].position = _square_position(target)
func _die() -> void:
hud.flash("Caught! Nothing survives. New dungeon.")
_new_dungeon()
# Permadeath in one function: everything from the old run is thrown away
# and rebuilt from dice rolls. Only run_number is left standing.
func _new_dungeon() -> void:
run_number += 1
hud.set_run(run_number)
for child: Node in board.get_children():
child.queue_free()
wall_cells.clear()
enemy_cells.clear()
enemy_squares.clear()
player_cell = Vector2i(0, grid_size - 1)
exit_cell = Vector2i(grid_size - 1, 0)
# Scatter walls anywhere that is not the start or the exit.
while wall_cells.size() < WALL_COUNT:
var cell := _random_cell()
if cell != player_cell and cell != exit_cell and not wall_cells.has(cell):
wall_cells.append(cell)
# Carve the guaranteed path: walk from start to exit, x first then y,
# and erase any wall standing on the way. Solvable every time.
var walk := player_cell
while walk != exit_cell:
if walk.x != exit_cell.x:
walk.x += signi(exit_cell.x - walk.x)
else:
walk.y += signi(exit_cell.y - walk.y)
wall_cells.erase(walk)
for cell: Vector2i in wall_cells:
_make_square(cell, Color(0.4, 0.4, 0.4), 2)
_make_square(exit_cell, Color(0.3, 0.75, 0.35), 8)
# Enemies start on open cells, and never right next to the start.
while enemy_cells.size() < ENEMY_COUNT:
var cell := _random_cell()
if not _is_open(cell) or cell == exit_cell or enemy_cells.has(cell):
continue
if _steps_between(cell, player_cell) < 3:
continue
enemy_cells.append(cell)
enemy_squares.append(_make_square(cell, Color(0.85, 0.25, 0.25), 10))
grid_player.position = _cell_to_pixel(player_cell)
grid_player.flip_h = false
# The checkerboard floor. Built once: unlike the walls, it never changes.
func _build_floor() -> void:
for x: int in grid_size:
for y: int in grid_size:
var tile := ColorRect.new()
var light := (x + y) % 2 == 0
tile.color = Color(0.16, 0.16, 0.2) if light else Color(0.12, 0.12, 0.16)
tile.position = BOARD_ORIGIN + Vector2(Vector2i(x, y) * TILE)
tile.size = Vector2(TILE, TILE)
floor_tiles.add_child(tile)
func _random_cell() -> Vector2i:
return Vector2i(randi_range(0, grid_size - 1), randi_range(0, grid_size - 1))
func _is_inside(cell: Vector2i) -> bool:
return cell.x >= 0 and cell.y >= 0 and cell.x < grid_size and cell.y < grid_size
func _is_open(cell: Vector2i) -> bool:
return _is_inside(cell) and not wall_cells.has(cell)
# How many king-moves apart two cells are. Used to keep enemies from
# spawning within a couple of steps of the player.
func _steps_between(a: Vector2i, b: Vector2i) -> int:
return maxi(absi(a.x - b.x), absi(a.y - b.y))
# Grid cell to pixels: the CENTER of the cell, where a sprite sits.
func _cell_to_pixel(cell: Vector2i) -> Vector2:
return BOARD_ORIGIN + (Vector2(cell) + Vector2(0.5, 0.5)) * TILE
# Grid cell to pixels: the top-left corner, where a ColorRect starts.
func _square_position(cell: Vector2i, margin: int = 10) -> Vector2:
return BOARD_ORIGIN + Vector2(cell * TILE) + Vector2(margin, margin)
# Make it, fill it in, add_child(). The Enemy Spawner three-step, only with
# coloured squares instead of enemy scenes.
func _make_square(cell: Vector2i, color: Color, margin: int) -> ColorRect:
var square := ColorRect.new()
square.color = color
square.position = _square_position(cell, margin)
square.size = Vector2(TILE - margin * 2, TILE - margin * 2)
board.add_child(square)
return squareclass_name RoguelikeHUD
extends CanvasLayer
@onready var run_label: Label = %RunLabel
@onready var message_label: Label = %MessageLabel
@onready var home_button: Button = %HomeButton
func _ready() -> void:
home_button.pressed.connect(_on_home_pressed)
## Called BY game.gd each time a new dungeon is built.
func set_run(run_number: int) -> void:
run_label.text = "Run %d" % run_number
## Called BY game.gd on a death or an escape. The message
## clears itself after a moment, unless a newer one has replaced it.
func flash(text: String) -> void:
message_label.text = text
var timer: SceneTreeTimer = get_tree().create_timer(2.0)
timer.timeout.connect(func() -> void:
if message_label.text == text:
message_label.text = "")
# Loads the lesson list screen, which replaces this whole scene.
func _on_home_pressed() -> void:
get_tree().change_scene_to_file("res://shared/ui/lesson_select.tscn")