Skip to content

Deck builder Game


Deck builder core

Published: Sun Jul 06 2025 20:40:46 GMT+0000 (Coordinated Universal Time)

Introduction

For this tutorial we will be setting up the core mechanics of a simple deck building game that can be expanded on. It will focus heavily on building the game via code.

If you copy and paste to get to the end it will take about 15 minutes.

Before starting, make sure you have completed the First Time Setup guide. That guide covers downloading the correct version of Godot, installing and enabling the Unseen addons, importing the template project, the hotkey reference sheet, and how to enable Step Logger if you need help troubleshooting.

A playable demo can be downloaded here:

https://ericbomb.itch.io/unseen-godot-toybox

Gameplay

  • The player will start with a deck of low-mid value cards.
  • The enemy will start with a deck of mid-high value cards.
  • Four cards from each deck will be dealt: enemy cards on top, player cards on bottom.
  • The player can pick one or two of their own cards, then target a card belonging to the enemy.
  • If the value of the picked cards is higher than the targeted card, the player can send them all to the discard pile.
  • If the selected cards are lower value than the targeted card, the player can sacrifice two of their cards to destroy the enemy card. All three cards are then removed from the game. This is the way to remove low-value cards from the deck.
  • Boss cards (cards above 10) cannot be sacrificed against — the player must defeat or purchase them. The game will stack the deck so they do not appear early and make the game unwinnable.
  • Once the deck is empty, the discard pile will be shuffled back into the player deck.
  • If the player has too few cards due to discards, 0-value cards will be shuffled in.
  • Play continues until the player defeats all boss cards or is unable to.

Player Cards

Description

The first thing we are going to create are buttons with a text attribute that function as the cards in our hand. The buttons will hold the card value and whether they are selected. When a card is used, its value will be cleared and a new value handed to it.

Most of the logic will be handled in the Game scene, but we still create the player cards as Button scenes with a pressed signal.

Instructions

Tip: To create any new scene the pattern is always: CTRL+N to open a new scene tab, CTRL+A to open the node creation menu, type to search for the node type, then press Enter to create it. After creating, press Enter again immediately to rename the node.

  1. Create a new scene: CTRL+N, then CTRL+A, select Button.
  2. Focus the Button in the Scene Tree (CTRL+1), then press Enter to rename it to PlayerCard.
  3. Attach a script to PlayerCard (CTRL+Equal Sign).

Code Snippet 1- player_card.gd

extends Button
#The player will always have a hand of 4, this button will represent one of the four cards given to the player
var value = 0 #This will be overwritten, but 0 will show if card was not dealt properly. If you use 0, give this a different value!
var selected:bool = false #If this card is picked

#When the game starts, room will set this.
var game 

func _ready():
	#making it so the cards are always one size. Makes it look better visually, and prevents clipping
	custom_minimum_size = Vector2(128,128) 
	
func _process(_delta: float) -> void:
	if selected == false:
		#If the card is not picked, we will have the value of the card, then the word owned.
		text = str(value) + " Owned"
	else:
		#If the card is picked, we will have the value of the card, then the word "selected"
		text = str(value) + " Selected"
#If you haven't, go to node tab and selected "pressed" then press "enter" to connect it here.
func _on_pressed() -> void:
	#if this card isn't selected
	if selected == false:
		#The game will keep track of what cards we have picked, we only will add this to that list if less than 2 are currently picked.
		if game.selected.size() < 2:
			selected = true
			#The game will have a function to add to its own array, so we just need to pass "self" which is the instance we have picked.
			game.add_selected(self)
	else:
		#Tell the game to handle removing from array.
		game.remove_selected(self)
		#No longer selected
		selected = false

The code already includes the signal connection. To attach it:

  1. Press CTRL+5 to go to the Signals tab.
  2. Tab to Pressed() and press Enter twice to connect the signal to the on_pressed() function.
  3. Press CTRL+S and save as default to finish the player cards.

Boss Cards

Description

We will do a similar task for the boss cards. Do not worry about the functions in the Game scene yet — those will be created soon. The boss card buttons follow the same pattern: un-selecting themselves and passing to the Game scene for processing.

Instructions

  1. Create a new scene: CTRL+N, then CTRL+A, select Button.
  2. Focus the Button in the Scene Tree (CTRL+1), then press Enter to rename it to BossCard.
  3. Attach a script to BossCard (CTRL+Equal Sign).

Code Snippet 2 - Boss cards

extends Button

#Value of the boss card
var value = 0
#If this is targetted or not, only one boss card can be targetted
var targetted = 0
#Going to store game room here
var game

func _ready():
	#Forcing size to be consistent to help visually and with focus movement.
	custom_minimum_size = Vector2(128,128)

func _process(_delta: float) -> void:
	#If the boss deck is almost out the player has almost won, so print "emtpy"
	if value == null:
		text = " Empty"
	#If not targetted, have value then enemy print
	elif targetted == 0:
		text = str(value) + " Enemy"
	#If targetted, print value then target. As player cards are now attacking this card.
	else:
		text = str(value) + " Target"

#If you haven't, go to node tab, and attached pressed() to here while BossCard is picked in scene.
func _on_pressed() -> void:
	if value != null:
		#If not targetted, make target
		if targetted == 0:
			#Add target will automatically remove previous target, so game will take care of it.
			game.add_target(self)
			targetted = 1
		else:
			#Set card to not be a target
			targetted = 0
			game.remove_target()

The code already includes the signal connection. To attach it:

  1. Press CTRL+5 to go to the Signals tab.
  2. Tab to Pressed() and press Enter twice to connect the signal to the on_pressed() function.
  3. Press CTRL+S and save as default to finish the boss cards.

Tip: We are attaching the signal now even though the target function in the Game scene does not exist yet. That is fine — it will be created in the next section.

Game Room

Description

The final scene is the game room. Most of the complex work happens in code, so read the comments carefully. We do need to set up the node structure in the Godot UI first.

The root node will be a Control node. Inside it we will place a VBoxContainer that forms a top-to-bottom list like this:

  • Boss cards in a horizontal container
  • Player cards in a horizontal container
  • Fight button

Pressing Up and Down on the keyboard moves between these rows, while Left and Right lets you explore the cards within each row. Most of the work happens in code, so the UI setup is straightforward.

Instructions

  1. Create a new scene: CTRL+N, then CTRL+A, select Control.
  2. Focus the Control in the Scene Tree (CTRL+1), then press Enter to rename it to Game.
  3. Go to the Inspector (CTRL+2). Navigate to the Layout category and set anchors_preset to Full Rect (option 8).

Tip: With UnseenInspector installed, press CTRL+Down/Up to jump between inspector categories to find Layout faster.

  1. Add a VBoxContainer as a child of Game: make sure Game is focused (CTRL+1), then CTRL+A. Rename it to Table.
  2. Add an HBoxContainer as a child of Table: focus Table (CTRL+1), then CTRL+A. Rename it to BossHand.
  3. Add another HBoxContainer as a child of Table: focus Table again (CTRL+1), then CTRL+A. Rename it to PlayerHand.
  4. Add a Button as a child of Table: focus Table (CTRL+1), then CTRL+A. Rename it to Fight.
  5. Focus Game (CTRL+1) and attach a script to it (CTRL+Equal Sign).

Code Snippet 3 - Game.gd

extends Control
#Loading the scenes for the cards.
const BOSS_CARD = preload("res://boss_card.tscn")
const PLAYER_CARD = preload("res://player_card.tscn")

#storing in variables for easy reference.
@onready var table: VBoxContainer = $Table
@onready var boss_hand: HBoxContainer = $Table/BossHand
@onready var player_hand: HBoxContainer = $Table/PlayerHand
@onready var fight: Button = $Table/Fight

#We will use these in place of 0,1,2,3,4, etc. later on so it's easier to read. But they all just mean a consistent number.
enum {BATTLE, SACRIFICE,DEFEAT,MORE,WON}

#Will be used to say how many cards per side to be dealt
var cards_per_side = 4
#This array will be what cards are in the player's deck. Cards will be added or removed as game goes on.
var player_deck:Array[int] = [1,1,1,2,2,2,3,3,4]
#When the player plays a card or "buys" a card, they go to the discard. Once the player_deck is empty, the discard gets shuffled into player deck.
var discard_pile:Array[int]  = []
#Array holding the boss deck, we want to make sure cards the player can deal with show up first, so we'll stack the deck a little.
var boss_deck = []
#These values will be shuffled and displayed first.
var boss_early_deck = [3,4,4,5,5,6,6,7,8]
#These values will not show up until the early deck values have all been played.
var boss_late_deck = [5,6,7,7,8,9,9,9,10,11,12,13]



#The player can have 2 cards picked at most whose value needs to equal or be greater than target boss card.
var max_selected = 2
#What values are currently picked
var selected:Array[Button]  = []
#Which of the boss's cards we are attempting to buy/defeat
var target = null
# We have a flag that will become true whenever the deck shuffles, but will remove when player picks any card!
var shuffled_recently = false

func _ready():
	#Use built in "shuffle" on the array for the three decks
	player_deck.shuffle()
	boss_early_deck.shuffle()
	boss_late_deck.shuffle()
	#Combine the early and late boss deck so won't get any late boss deck cards until early is played out.
	boss_deck = boss_early_deck + boss_late_deck
	
	#Dealing the starting hand for boss
	for i in range(cards_per_side):
		#Creating a boss card instance
		var b_card = BOSS_CARD.instantiate()
		#Removing value from boss deck and giving it to this card
		b_card.value = boss_deck.pop_front()
		#Putting the card in the hboxcontainer that is the cards of the boss
		boss_hand.add_child(b_card)
		#Telling boss button what the game room is.
		b_card.game = self
		
	#Dealing starting hand for player
	for i in range(cards_per_side):
		#Making the button
		var p_card = PLAYER_CARD.instantiate()
		#removing a value from the player_deck and giving it to the dealt card.
		p_card.value = player_deck.pop_front()
		#Adding it as a child.
		player_hand.add_child(p_card)
		#telling the button this is the game room.
		p_card.game = self
	#Now that hands are dealt, we set focus to fight button
	fight.grab_focus()

func _process(_delta):
	#before doing anything else, let's see if we won!
	if check_win():
		fight.text ="You've won! 
		The boss cards now are yours!
		Press to play again!"
	#The fight button is at the bottom and will tell the player what actions they can take and what will happen if the fight happens.
	elif shuffled_recently == true:
		fight.text = "Shuffled discard into deck!
		You can now draw previously defeated enemies!"
	elif selected.size() == 0 and target==null:
		#A fight can't happen, as no target, no cards picked.
		fight.text = "Pick one target and at least one of your cards"
	elif selected.size()>0 and target==null:
		#Fight can't happen, no boss cards are targetted
		fight.text = "Must select a target"
	elif selected.size()==0 and target!= null:
		#fight can't happen, there is a target, but none of player cards are picked to be used.
		fight.text = "Must select at least one of your cards"
	elif selected.size()>0 and target!= null:
		#Target and player cards are picked, we now will display if combat is allowed or what will happen if combat happens.
		#First, we need to caluclate that using this function
		var check = selected_check()
		match check:
			BATTLE:
				#This is a success, cards will be moved to discard and show up later.
				fight.text = "Attack! Used cards and target will go to discard pile to be reused."
			SACRIFICE:
				#This is the cards are destroyed, good way to get rid of low value cards to either remove a card you don't want to buy, or one you can't beat but is blocking you.
				fight.text = "Sacrifice! 
				Selected cards are lower than target. 
				All cards will be removed from game. 
				Only works on low value enemies."
			MORE:
				#displays if value is less than target, but only one card picked.
				fight.text = "Value too low and need two cards for sacrifice."
			DEFEAT:
				#You can't sascrifice against values 10+, they are the final bosses and must be defeated. So this is displayed.
				fight.text = "Surrender! You can not sacrifice against the boss cards! Values 10+"
			
#If not already done, go to "node" tab and pick "pressed" so this code runs when battle button is pressed.
func _on_fight_pressed() -> void:
	var check = selected_check()
	match check:
		BATTLE:
			#for each selected card
			while selected.size() > 0:
				#Add the value of the selected card to the discard pile
				discard_pile.append(selected[0].value)
				#Have the card be given a value from the deck.
				selected[0].value = player_deck.pop_front()
				#Unselect the card
				remove_selected(selected[0])
			#add the purcahsed/defeated boss card to discard pile
			discard_pile.append(target.value)
			#Give a new value from the boss deck to the defeated boss card.
			target.value = boss_deck.pop_front()
			#clear targetted card.
			remove_target()
		#Sacrifice removes the three cards from the game.
		SACRIFICE:
			#For each selected card, remove them from game and selection arrays
			while selected.size() > 0:
				if player_deck.is_empty():
					shuffle_discard_into_deck()
				selected[0].value = player_deck.pop_front()
				remove_selected(selected[0])
			target.value = boss_deck.pop_front()
			remove_target()
		WON:
			get_tree().reload_current_scene()
	#function to shuffle discard pile into player deck if needed.
	shuffle_discard_into_deck()

func check_win():
	for card in boss_hand.get_children():
		if card.value != null:
			return false
	return true
	
func shuffle_discard_into_deck():
	#Shuffles the discard then adds to the bottom of player deck when player deck is about to run out.
	if player_deck.size() < 3:
		discard_pile.shuffle()
		player_deck += discard_pile
		shuffled_recently = true
		discard_pile.clear()
	#If the player has sacrificed so many cards that after shuffling their deck is still tiny, we give them 2 free 0 cards.
	#Isn't that so nice of us?
	if player_deck.size() < 3:
		player_deck.append(0)
		player_deck.append(0)

#enum {BATTLE, SACRIFICE,DEFEAT,MORE,WON}
#Calculating what the outcome is of battle.
func selected_check():
	#If player won,game will restart when they press the button!
	if check_win():
		return WON
	var selected_sum =0
	var targ_val = target.value
	#if only one card picked, use that value.
	if selected.size() == 1:
		selected_sum = selected[0].value
	#If two cards picked, add the value together.
	elif selected.size() ==2:
		selected_sum = selected[0].value + selected[1].value
	
	#Returned if it's less than the targetted boss card and only one card is picked
	if selected.size() == 1 and selected_sum < targ_val:
		return MORE
	#Returned if two cards picked, target is less than 10, and player cards less than target
	elif selected_sum < targ_val and targ_val<10:
		return SACRIFICE
	#Returned if less than a final boss card
	elif selected_sum < targ_val and targ_val>=10:
		return DEFEAT
	#Picked cards will successfully purchase the targetted cards.
	elif selected_sum >= targ_val:
		return BATTLE

#Function called when a player card needs to be added.
func add_selected(card):
	#If there is room, add the instance to the list of selected player cards
	if selected.size() < max_selected:
		selected.append(card)
	#The player has taken an action, so our fight button should now give details!
	shuffled_recently = false

#Function called when a card needs to not be selected
func remove_selected(card):
	#Find which card is being removed in array.
	var removing = selected.find(card)
	if removing != -1:
		selected.remove_at(removing)
	#setting card to not be selected
	card.selected = 0
	
#Called when pressing to target a boss card.
func add_target(temp_target):
	#If something else is targetted, untarget it.
	if target!= null:
		target.targetted = 0
	#Set target to be the boss card that was passed
	target = temp_target
	#The player has taken an action, so our fight button should now give details!
	shuffled_recently = false

#hard clearing whatever boss card is targetted.
func remove_target():
	if target!= null:
		target.targetted = 0
	target = null

The code already includes the signal connection. To attach it:

  1. Focus Fight in the Scene Tree (CTRL+1).
  2. Press CTRL+5 to go to the Signals tab.
  3. Tab to Pressed() and press Enter twice to connect the signal to the on_fight_pressed() function.
  4. Press CTRL+S to save the scene.

Test and Tweak!

At this point all three scenes (PlayerCard, BossCard, and Game) should be created and fully connected. With the Game scene selected in the editor, press F5 to run the project.

Then try adjusting it! Some ideas:

  • If it is too easy to win, tweak the card value ranges or change a mechanic.
  • Make perfect-value captures increase a card’s value as a reward.
  • When a sacrifice is used, make all boss cards on the field increase in value.
  • Give 1-value cards a bonus when used in a capture so they are not immediately thrown away.
  • Add sound effects, card names, or additional card attributes. Since the arrays hold actual instances, expanding what the game checks and does with them is straightforward.

Thank you for doing the tutorial, have a great day!