Lesson 30

Deck Builder

Your abilities are cards. Play a hand of three to cross the room, open the chest, and pick one of the three cards it offers to join your deck for every hand after.

Loading the game engine. This takes a moment the first time.

This is a sampler, not a build lesson: play it before you read it. Same warrior as always, but put your arrow keys away. In a deck builder your abilities are CARDS, and the whole style grew out of paper card games.

WHAT TO NOTICE WHILE YOU PLAY

  • You never press an arrow key. Every single move is a card leaving your hand. No cards, no moving: the deck IS the character.
  • Watch the two piles under the hand. Played cards go to the discard pile, and when the deck runs out, the discard pile is shuffled back in. Nothing is ever lost, it just comes around again.
  • Open a chest and the game stops to offer three cards. You keep ONE. Play on and your pick starts turning up in your hand. That choice is the "builder" half of the name: the prize in this style is never gold, it is a better deck, and you decide how it grows.
  • Falling in a gap costs your whole hand. Consequences in a card game are paid in cards.

HOW A CARD BECOMES A MOVE

Each button press runs one function: _walk(), _jump() or _break_wall(). An illegal move keeps the card in your hand and spends nothing. A legal one sends the card to the discard pile and refills the hand from the deck. The buttons themselves are rebuilt from the hand array after every play, exactly the way the lesson menu builds its buttons from the lesson list.

New code in this lesson

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

shuffle()

Puts a list in a random order, like shuffling a deck.

game.gd
deck.shuffle()

remove_at()

Removes the item at that spot in a list.

game.gd
hand.remove_at(index)

append_array()

Adds every item from another list onto the end of this one.

game.gd
discard.append_array(hand)

break

Leaves the loop straight away.

game.gd
break

assign()

Replaces everything in a typed list with the items from another list.

game.gd
pool.assign(CARDS.keys())

keys()

A list of every name stored in a dictionary.

game.gd
pool.assign(CARDS.keys())

slice()

A piece of a list, from one spot up to, but not including, another.

game.gd
choices.assign(pool.slice(0, 3))

custom_minimum_size

The smallest a UI node is allowed to shrink to.

game.gd
button.custom_minimum_size = Vector2(150, 90)

focus_mode

Whether a button can be selected with the keyboard. FOCUS_NONE turns that off.

game.gd
button.focus_mode = Control.FOCUS_NONE

duplicate()

A copy of a list, so changing the copy leaves the original alone.

game.gd
deck = discard.duplicate()

pop_front()

Takes the first item off a list and hands it back.

game.gd
hand.append(deck.pop_front())

The scenes

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

game.tscn
  • Game Node2D game.gd
  • Board Node2D
  • Player Sprite2D
  • UI CanvasLayer
  • HandBar HBoxContainer %
  • ChoiceBar HBoxContainer hidden %
  • PilesLabel Label %
  • MessageLabel Label %
  • HomeButton Button %

FileSystem

  • game.tscn
  • game.gd

The code

extends Node2D

const TILE: int = 80
const HAND_SIZE: int = 3

# The rooms, as little maps: "." floor, "#" wall, "~" gap, "C" chest.
const ROOMS: Array[String] = [
	".....~...C",
	"..#.~..#.C",
	".~.#..~.#C"
]

const CARDS := {
	"move2": {"title": "Move 2", "info": "Walk forward two tiles."},
	"move3": {"title": "Move 3", "info": "Walk forward three tiles."},
	"jump": {"title": "Jump", "info": "Leap over the tile ahead."},
	"break": {"title": "Break", "info": "Smash the wall ahead."}
}

# Where the row of tiles starts, so ten 80-pixel tiles sit centered on the
# 1152 x 648 screen.
const BOARD_ORIGIN := Vector2(176, 320)

var deck: Array[String] = ["move2", "move2", "move2", "jump", "break"]
var discard: Array[String] = []
var hand: Array[String] = []

# The current room, one cell per entry, copied from ROOMS so Break can
# actually change it.
var cells: Array[String] = []
var player_col: int = 0
var room_index: int = 0
# The three cards the open chest is offering. Empty means no chest is open.
var choices: Array[String] = []

@onready var board: Node2D = $Board
@onready var grid_player: Sprite2D = $Player
@onready var hand_bar: HBoxContainer = %HandBar
@onready var choice_bar: HBoxContainer = %ChoiceBar
@onready var piles_label: Label = %PilesLabel
@onready var message_label: Label = %MessageLabel
@onready var home_button: Button = %HomeButton

func _ready() -> void:
	home_button.pressed.connect(_on_home_pressed)
	deck.shuffle()
	_load_room(0)
	_draw_up()
	_rebuild_hand_bar()

func _on_card_pressed(index: int) -> void:
	var card: String = hand[index]
	var result: String = ""
	match card:
		"move2":
			result = _walk(2)
		"move3":
			result = _walk(3)
		"jump":
			result = _jump()
		"break":
			result = _break_wall()

	# An illegal play costs nothing: the card never leaves the hand.
	if result == "illegal":
		return

	discard.append(card)
	hand.remove_at(index)

	if result == "fell":
		# The fall costs the REST of the hand too, straight to the discard
		# pile. Harsh, but it also un-sticks a hand with no useful card.
		discard.append_array(hand)
		hand.clear()
	if result == "chest":
		# The chest interrupts play: the hand waits, greyed out, while you
		# pick your prize. _on_choice_pressed() picks the game back up.
		_draw_up()
		_rebuild_hand_bar()
		_offer_cards()
		return

	_draw_up()
	_rebuild_hand_bar()

# Walk forward one tile at a time. A wall ends the walk early; a gap ends
# the run entirely.
func _walk(steps: int) -> String:
	for i: int in steps:
		var next := player_col + 1
		if next >= cells.size() or cells[next] == "#":
			_say("A wall stops you.")
			break
		player_col = next
		if cells[next] == "~":
			_place_player()
			_say("You fell in the gap! Back to the start, new hand.")
			player_col = 0
			_place_player()
			return "fell"
		if cells[next] == "C":
			_place_player()
			return "chest"
	_place_player()
	return "ok"

# Leap over the tile ahead and land two tiles on, walls included. The
# landing tile is the only one that matters.
func _jump() -> String:
	var landing := player_col + 2
	if landing >= cells.size() or cells[landing] == "#":
		_say("Nowhere to land. The card stays in your hand.")
		return "illegal"
	player_col = landing
	_place_player()
	if cells[landing] == "~":
		_say("You leapt straight into the gap! Back to the start, new hand.")
		player_col = 0
		_place_player()
		return "fell"
	if cells[landing] == "C":
		return "chest"
	return "ok"

func _break_wall() -> String:
	var target := player_col + 1
	if target >= cells.size() or cells[target] != "#":
		_say("No wall ahead. The card stays in your hand.")
		return "illegal"
	cells[target] = "."
	_redraw_board()
	return "ok"

# The chest is a DRAFT, not a gift: it deals three different cards face
# up, and only the one you press joins your deck. The buttons are built
# exactly like the hand's, just wired to _on_choice_pressed() instead.
func _offer_cards() -> void:
	var pool: Array[String] = []
	pool.assign(CARDS.keys())
	pool.shuffle()
	choices.assign(pool.slice(0, 3))
	for i: int in choices.size():
		var card: String = choices[i]
		var button := Button.new()
		button.text = "%s\n%s" % [CARDS[card]["title"], CARDS[card]["info"]]
		button.custom_minimum_size = Vector2(150, 90)
		button.focus_mode = Control.FOCUS_NONE
		button.pressed.connect(_on_choice_pressed.bind(i))
		choice_bar.add_child(button)
	choice_bar.show()
	# Grey the hand out so choosing is the only move left.
	for child: Node in hand_bar.get_children():
		if child is Button:
			child.disabled = true
	# Straight onto the label with no timer: the prompt should stay up
	# until the choice is made.
	message_label.text = "The chest opens! Pick ONE card to add to your deck."

func _on_choice_pressed(index: int) -> void:
	var card: String = choices[index]
	choices.clear()
	for child: Node in choice_bar.get_children():
		child.queue_free()
	choice_bar.hide()
	# Into the DISCARD pile, not the hand: new cards join the cycle and
	# turn up a little later, like every deck builder does it.
	discard.append(card)
	_say("The %s card joins your deck!" % CARDS[card]["title"])
	_load_room((room_index + 1) % ROOMS.size())
	_draw_up()
	_rebuild_hand_bar()

# Refill the hand to three. When the deck runs dry, the discard pile is
# shuffled and BECOMES the deck. That loop is why every card you gain
# keeps coming back, and it is the engine of the whole style.
func _draw_up() -> void:
	while hand.size() < HAND_SIZE:
		if deck.is_empty():
			deck = discard.duplicate()
			deck.shuffle()
			discard.clear()
		if deck.is_empty():
			return
		hand.append(deck.pop_front())

# Throw the old buttons away and build one per card in the hand: make it,
# fill it in, add_child(). The same three steps the lesson menu uses for
# its buttons and the Enemy Spawner uses for enemies.
func _rebuild_hand_bar() -> void:
	for child: Node in hand_bar.get_children():
		child.queue_free()
	for i: int in hand.size():
		var card: String = hand[i]
		var button := Button.new()
		button.text = "%s\n%s" % [CARDS[card]["title"], CARDS[card]["info"]]
		button.custom_minimum_size = Vector2(150, 90)
		button.focus_mode = Control.FOCUS_NONE
		# ".bind(i)" glues this slot number onto the press, so the handler
		# knows which card was chosen.
		button.pressed.connect(_on_card_pressed.bind(i))
		hand_bar.add_child(button)
	piles_label.text = "Deck: %d    Discard: %d" % [deck.size(), discard.size()]

func _load_room(index: int) -> void:
	room_index = index
	cells.clear()
	for i: int in ROOMS[index].length():
		cells.append(ROOMS[index][i])
	player_col = 0
	_redraw_board()
	_place_player()

func _redraw_board() -> void:
	for child: Node in board.get_children():
		child.queue_free()
	for col: int in cells.size():
		var x := BOARD_ORIGIN.x + col * TILE
		match cells[col]:
			".":
				_make_rect(x + 2, BOARD_ORIGIN.y + 2, TILE - 4, Color(0.16, 0.16, 0.2))
			"#":
				# Walls stand a full tile taller than the floor.
				_make_rect(x + 2, BOARD_ORIGIN.y - TILE, TILE - 4, Color(0.4, 0.4, 0.4), TILE * 2)
			"~":
				_make_rect(x + 2, BOARD_ORIGIN.y + 2, TILE - 4, Color(0.03, 0.03, 0.05))
			"C":
				_make_rect(x + 2, BOARD_ORIGIN.y + 2, TILE - 4, Color(0.16, 0.16, 0.2))
				_make_rect(x + 20, BOARD_ORIGIN.y + 20, TILE - 40, Color(1, 0.85, 0))

func _make_rect(x: float, y: float, width: float, color: Color, height: float = -1.0) -> void:
	var rect := ColorRect.new()
	rect.color = color
	rect.position = Vector2(x, y)
	rect.size = Vector2(width, width if height < 0.0 else height)
	board.add_child(rect)

func _place_player() -> void:
	grid_player.position = Vector2(
		BOARD_ORIGIN.x + (player_col + 0.5) * TILE, BOARD_ORIGIN.y + TILE * 0.5
	)

func _say(text: String) -> void:
	message_label.text = text
	var timer: SceneTreeTimer = get_tree().create_timer(2.5)
	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")