Lesson 28

Roguelike

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.

WHAT TO NOTICE WHILE YOU PLAY

TURNS
Nothing moves until you do. Every enemy takes exactly one step when you take one step, so time here is counted in turns, not seconds. Bump into a wall and nothing moves at all: you never took your step, so nobody takes theirs.
NO CLOCK
There is no _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.
THE MAP
Die, then look at the walls. A brand-new dungeon, built on the spot, and your old one is gone for good.
ONE LIFE
No respawn and no checkpoint. The run counter in the corner is the ONLY thing that survives your death.

GAMES THAT WORK THIS WAY

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.

GAMES THAT ONLY LOOK LIKE THIS

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.

HOW A DUNGEON IS BUILT

_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.

New code in this lesson

None of the earlier lessons use these. Each one shows the line where it appears here.

Vector2i

A Vector2 that only holds whole numbers. Just right for squares on a grid.

game.gd
var player_cell: Vector2i

_unhandled_input(event)

Godot runs this for every key press or click that a button or other UI did not already use.

game.gd
func _unhandled_input(event: InputEvent) -> void:

has()

True when a list holds that value, or a dictionary holds that name.

game.gd
if enemy_cells.has(player_cell):

sign()

Just which way a number points: -1, 0 or 1.

game.gd
var step := Vector2i(signi(away.x), 0)

clear()

Empties a list or a dictionary.

game.gd
wall_cells.clear()

while

A loop that keeps running the lines under it for as long as the condition stays true.

game.gd
while wall_cells.size() < WALL_COUNT:

append()

Adds an item to the end of a list.

game.gd
wall_cells.append(cell)

erase()

Removes an item from a list, or a name and its value from a dictionary.

game.gd
wall_cells.erase(walk)

continue

Skips the rest of this time round the loop and jumps to the next one.

game.gd
continue

randi_range()

A random whole number between two numbers, with both ends included.

game.gd
return Vector2i(randi_range(0, grid_size - 1), randi_range(0, grid_size - 1))

The scenes

■ UI node · ■ 2D node · % unique name · .tscn instanced scene

game.tscn
  • Game Node2D game.gd
  • FloorTiles Node2D
  • Board Node2D
  • Player Sprite2D
  • HUD CanvasLayer hud.tscn
hud.tscn
  • HUD CanvasLayer hud.gd
  • MarginContainer
  • RunLabel Label %
  • MessageLabel Label %

FileSystem

  • game.tscn
  • game.gd
  • hud.tscn
  • hud.gd

The code

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 square