Deck, Hand, Discard: Card Mechanics

Module 4 ยท Lesson 1 ยท ~40 min ยท Godot 4.x

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.

Three piles + a discard reshuffle

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.

This is Redux's "state lives in a pure store; views render from it." Deck is the store. Hand (the HBoxContainer) is a view that asks the store for cards and renders them.

Determinism via seeds

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.

Letter distributions for a word game

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.

Hand cap and forced discards

If draw takes hand over the cap, options:

  1. Hard cap: don't draw past N. Simple, easy to communicate.
  2. Forced discard: player picks which to drop. More strategic, more UI.

Lexicon Duel should probably cap at 7 (Scrabble rack size โ€” a cultural anchor players will recognize). Going over feels messy on a phone screen.

Animation-aware draws

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.

What belongs where

ConcernLives in
Draw/discard/reshuffle logicDeck (RefCounted, pure)
Which cards are selected for a wordHand (UI node) or its script
Current word + valueDerived from Hand.selected โ€” don't store separately
Per-run deck state (persistent between duels)Global.run_data.player_deck
Starting letter frequenciesResource file, not code constants

Do this now

  1. Create scripts/deck.gd with the Deck class above.
  2. In your Main's _ready, replace the hard-coded STARTING_LETTERS with var deck := Deck.new(Deck.build_starting_deck()), then deck.draw(7).
  3. Spawn cards into the hand from the drawn array.
  4. Check the Deck Simulator tool โ€” it visualizes how draw/shuffle/discard behaves. Tune your intuition before committing to a design.