By the end of this exercise you have a playable duel: draw 7 letters from a 60-tile deck, form a valid word, deal damage, enemy hits back, repeat until one side's HP hits zero. Scoring is tuned for strategic word choice (length and rare letters matter). This is the game loop.
1Dictionary autoload. Download enable1.txt, save as res://data/words.txt, set up the Dictionary autoload from Lesson 3. Verify with print(Dictionary.is_valid("cat")) in _ready.
2Deck class. Create scripts/deck.gd from Lesson 1. Expose a static build_starting_deck() returning ~60 tiles with Scrabble frequencies scaled down (half frequencies, rounded up — you don't need 12 E's in a 60-card deck).
const STARTER_TILES := {
"A": 5, "B": 1, "C": 1, "D": 2, "E": 6, "F": 1, "G": 2, "H": 1,
"I": 5, "J": 1, "K": 1, "L": 2, "M": 1, "N": 3, "O": 4, "P": 1,
"R": 3, "S": 2, "T": 3, "U": 2, "V": 1, "W": 1, "Y": 1,
# no Q, X, Z in starter deck — they come from card rewards later
}
That's ~50 tiles. Add 10 wildcards later if you want. Ship the MVP deck first.
3Scoring formula. In scripts/scoring.gd:
class_name Scoring
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 word_damage(word: String) -> int:
if word.length() < 3: return 0
var base := 0
for c in word.to_upper():
base += LETTER_VALUES.get(c, 0)
# length bonus: 3-letter=0, 4=2, 5=4, 6=7, 7=11
var length_bonus := int((word.length() - 3) * (word.length() - 3) * 0.5 + (word.length() - 3))
return base + length_bonus
4Wire Hand to Deck. In Main's _ready:
var deck := Deck.new(Deck.build_starting_deck())
var initial: Array[CardData] = deck.draw(7)
for card_data in initial:
_spawn_card(card_data)
When a word is played, tell the deck which cards were used (play() moves them to discard), then draw fresh cards to refill.
5DuelController gets real. Update the Module 3 DuelController:
func _on_word_submitted(word: String, _total: int) -> void:
if state != State.PLAYER_PLAY: return
if not Dictionary.is_valid(word):
print("[Duel] invalid word: ", word)
return
var dmg := Scoring.word_damage(word)
print("[Duel] %s hits for %d" % [word, dmg])
enemy_hp -= dmg
# discard played cards
for card in player_hand.selected_cards:
deck.play(card.data)
player_hand.clear_selection()
player_hand.remove_played_cards()
if enemy_hp <= 0:
transition_to(State.VICTORY)
else:
transition_to(State.RESOLVING)
6PLAYER_DRAW draws from the deck.
State.PLAYER_DRAW:
var needed := 7 - player_hand.get_card_count()
var drawn: Array[CardData] = deck.draw(needed)
for card_data in drawn:
player_hand.add_card(card_data)
transition_to(State.PLAYER_PLAY)
7HP labels. Add two Labels to your UI — one for player HP, one for enemy HP. In _on_damage_dealt (or after each transition), update the text:
@onready var player_hp_label: Label = $UI/Root/PlayerHP
@onready var enemy_hp_label: Label = $UI/Root/EnemyHP
func _update_hp_labels() -> void:
player_hp_label.text = "You: %d" % player_hp
enemy_hp_label.text = "Enemy: %d" % enemy_hp
Call it after every HP change.
8Play it. F5. Try to win a duel. Notice: is the scoring too weak? Too strong? Are you running out of cards? Tune those numbers in isolation — they're all in Scoring.gd and Deck.STARTER_TILES. This is the feedback loop of game design.
A playable turn-based word duel. Everything that comes after — enemy AI, card rewards, map structure, polish — refines this loop. The core game is done in terms of mechanics. From here, you're making it feel like a product and making it deeper.