The deck-hand-discard loop is the circulatory system of a card game. Build it correctly once and you'll reuse the same pattern in every future project. Build it sloppily and your late-game enemies will "shuffle" in ways that make players uninstall.
A standard roguelike-deckbuilder flow:
draw pile (shuffled, face-down)
โโโ top card โ hand
hand (face-up, playable)
โโโ played card โ discard pile
discard pile (face-up, grows during combat)
โโโ when draw pile empty: shuffle discard into draw
Three arrays, three state transitions. Keep them as arrays of CardData (data), not Card (visual nodes). Nodes are constructed when a card moves into the hand and destroyed when it leaves combat.
class_name Deck
extends RefCounted
var draw_pile: Array[CardData] = []
var hand: Array[CardData] = []
var discard_pile: Array[CardData] = []
func _init(starting_deck: Array[CardData]) -> void:
draw_pile = starting_deck.duplicate()
draw_pile.shuffle()
func draw(n: int = 1) -> Array[CardData]:
var drawn: Array[CardData] = []
for i in n:
if draw_pile.is_empty():
_reshuffle()
if draw_pile.is_empty():
break # no cards anywhere
var card := draw_pile.pop_back()
hand.append(card)
drawn.append(card)
return drawn
func play(card: CardData) -> void:
hand.erase(card)
discard_pile.append(card)
func discard_hand() -> void:
discard_pile.append_array(hand)
hand.clear()
func _reshuffle() -> void:
draw_pile = discard_pile.duplicate()
discard_pile.clear()
draw_pile.shuffle()
The logic is pure โ no Node, no Control, no visual concerns. That's deliberate. Testable in isolation, reusable across scene changes, survives save/load trivially. Extends RefCounted so Godot cleans it up automatically.
Deck is the store. Hand (the HBoxContainer) is a view that asks the store for cards and renders them.A roguelike lives or dies on reproducibility. Two players should get the same run from the same seed. Standard advice applies:
# At run start
Global.run_seed = randi()
seed(Global.run_seed) # seeds the global RNG
# or, better, keep a per-system RNG:
Global.deck_rng = RandomNumberGenerator.new()
Global.deck_rng.seed = Global.run_seed
Then use Global.deck_rng.randi_range(...) explicitly wherever deck shuffling happens. Multiple independent RNGs (deck, enemy AI, loot) keep each system reproducible even if other systems change their call patterns.
func shuffle_with(rng: RandomNumberGenerator) -> void:
# Fisher-Yates, deterministic given the rng state
var n := draw_pile.size()
for i in range(n - 1, 0, -1):
var j := rng.randi_range(0, i)
var tmp = draw_pile[i]
draw_pile[i] = draw_pile[j]
draw_pile[j] = tmp
Array.shuffle() uses the global RNG, which is fine early but becomes a pain when you want replay or daily-seeded modes. Switching to explicit RNGs is cheap if you do it before you ship.
A Scrabble tile bag has 100 tiles with specific frequencies. For Lexicon Duel you'll want something similar โ enough vowels to build words, rare letters with high point values. The English Scrabble distribution:
const SCRABBLE_TILES := {
"A": 9, "B": 2, "C": 2, "D": 4, "E": 12, "F": 2, "G": 3, "H": 2,
"I": 9, "J": 1, "K": 1, "L": 4, "M": 2, "N": 6, "O": 8, "P": 2,
"Q": 1, "R": 6, "S": 4, "T": 6, "U": 4, "V": 2, "W": 2, "X": 1,
"Y": 2, "Z": 1,
}
const LETTER_VALUES := {
"A": 1, "B": 3, "C": 3, "D": 2, "E": 1, "F": 4, "G": 2, "H": 4,
"I": 1, "J": 8, "K": 5, "L": 1, "M": 3, "N": 1, "O": 1, "P": 3,
"Q": 10, "R": 1, "S": 1, "T": 1, "U": 1, "V": 4, "W": 4, "X": 8,
"Y": 4, "Z": 10,
}
static func build_starting_deck() -> Array[CardData]:
var deck: Array[CardData] = []
for letter in SCRABBLE_TILES:
for i in SCRABBLE_TILES[letter]:
var card := CardData.new()
card.letter = letter
card.value = LETTER_VALUES[letter]
deck.append(card)
return deck
100 cards is a lot for a run. For Lexicon Duel start smaller โ maybe a 30-tile starter deck the player upgrades between duels. Classic deckbuilder loop: smaller decks give tighter synergy.
If draw takes hand over the cap, options:
Lexicon Duel should probably cap at 7 (Scrabble rack size โ a cultural anchor players will recognize). Going over feels messy on a phone screen.
When you visually draw a card, there's a delay โ the card flies from the deck to the hand. The data should reflect the immediate state, but the view should animate. Standard pattern:
# In your HandView script
func add_card_visual(card_data: CardData) -> void:
var card := CARD_SCENE.instantiate()
card.data = card_data
add_child(card)
_animate_from_deck(card)
func _animate_from_deck(card: Control) -> void:
var deck_pos := get_parent().get_node("DeckVisual").global_position
var target := card.global_position
card.global_position = deck_pos
var tween := create_tween()
tween.tween_property(card, "global_position", target, 0.25).set_trans(Tween.TRANS_CUBIC)
Tweens are covered in Module 6 in depth. For now, just know the pattern: data updates first, visuals animate to match.
| Concern | Lives in |
|---|---|
| Draw/discard/reshuffle logic | Deck (RefCounted, pure) |
| Which cards are selected for a word | Hand (UI node) or its script |
| Current word + value | Derived from Hand.selected โ don't store separately |
| Per-run deck state (persistent between duels) | Global.run_data.player_deck |
| Starting letter frequencies | Resource file, not code constants |
scripts/deck.gd with the Deck class above._ready, replace the hard-coded STARTING_LETTERS with var deck := Deck.new(Deck.build_starting_deck()), then deck.draw(7).