You're going to wire the full duel loop: a DuelController state machine, a Hand node that owns cards and emits signals, an Events autoload as the bus. You won't have real combat yet โ just the flow skeleton โ but at the end of this exercise, you'll have the architecture every future feature fits into.
Main (Node2D)
โโโ DuelController (Node, script)
โโโ Player (Node)
โ โโโ Hand (HBoxContainer, UI/Root/Margins/...)
โ โโโ Deck (Node)
โโโ Enemy (Node)
โโโ (enemy data + deck)
Plus autoloads: Global, Events. Plus Resources: CardData, CardEffect, DamageEffect.
1Autoloads. If you haven't already, set up Global and Events per Module 3 Lesson 2. Events needs these signals:
signal word_submitted(word: String, total_value: int)
signal end_turn_pressed
signal damage_dealt(target: Node, amount: int)
signal duel_won(reward: int)
signal duel_lost
2DuelController. Create scripts/duel_controller.gd:
extends Node
class_name DuelController
enum State { SETUP, PLAYER_DRAW, PLAYER_PLAY, RESOLVING, ENEMY_TURN, VICTORY, DEFEAT }
var state: State = State.SETUP
@export var player_starting_hp: int = 30
@export var enemy_starting_hp: int = 15
var player_hp: int
var enemy_hp: int
@onready var player_hand: Node = get_node("../Player/Hand")
func _ready() -> void:
player_hp = player_starting_hp
enemy_hp = enemy_starting_hp
Events.word_submitted.connect(_on_word_submitted)
Events.end_turn_pressed.connect(_on_end_turn)
Events.damage_dealt.connect(_on_damage_dealt)
transition_to(State.PLAYER_DRAW)
func transition_to(new_state: State) -> void:
if new_state == state: return
print("[Duel] %s -> %s" % [State.keys()[state], State.keys()[new_state]])
state = new_state
_on_state_entered(new_state)
func _on_state_entered(s: State) -> void:
match s:
State.PLAYER_DRAW:
# refill hand to 7 โ placeholder until deck exists
await get_tree().create_timer(0.1).timeout
transition_to(State.PLAYER_PLAY)
State.PLAYER_PLAY:
pass # wait for word_submitted or end_turn
State.RESOLVING:
await get_tree().create_timer(0.5).timeout
transition_to(State.ENEMY_TURN)
State.ENEMY_TURN:
# TODO: enemy takes a swing
var enemy_dmg := 3
player_hp -= enemy_dmg
print("[Duel] enemy deals ", enemy_dmg, ". Player HP=", player_hp)
if player_hp <= 0:
transition_to(State.DEFEAT)
else:
transition_to(State.PLAYER_DRAW)
State.VICTORY:
Events.duel_won.emit(25)
State.DEFEAT:
Events.duel_lost.emit()
func _on_word_submitted(word: String, total_value: int) -> void:
if state != State.PLAYER_PLAY: return
print("[Duel] word=%s value=%d" % [word, total_value])
enemy_hp -= total_value
print("[Duel] enemy HP=", enemy_hp)
if enemy_hp <= 0:
transition_to(State.VICTORY)
else:
transition_to(State.RESOLVING)
func _on_end_turn(_arg = null) -> void:
if state != State.PLAYER_PLAY: return
transition_to(State.RESOLVING)
func _on_damage_dealt(target: Node, amount: int) -> void:
print("[Duel] %s took %d" % [target.name, amount])
3Hand logic. In scripts/hand.gd attached to your HBoxContainer:
extends HBoxContainer
class_name Hand
var selected_cards: Array[Card] = []
func _ready() -> void:
for child in get_children():
if child is Card:
child.card_tapped.connect(_on_card_tapped)
func _on_card_tapped(card: Card) -> void:
if card.selected:
if not selected_cards.has(card):
selected_cards.append(card)
else:
selected_cards.erase(card)
_print_current_word()
func _print_current_word() -> void:
var word := ""
var total := 0
for c in selected_cards:
word += c.data.letter
total += c.data.value
print("[Hand] current=%s (value=%d)" % [word, total])
func submit_word() -> void:
var word := ""
var total := 0
for c in selected_cards:
word += c.data.letter
total += c.data.value
if word.length() < 2:
print("[Hand] word too short")
return
Events.word_submitted.emit(word, total)
# clear selection
for c in selected_cards:
c.selected = false
c.modulate = Color.WHITE
selected_cards.clear()
4Submit button. Add a Button in your UI, labeled "Submit Word", anchored bottom-right, with a pressed signal connected to Hand.submit_word. (Or wire it via code in Main's _ready.)
5Run the whole loop. Hit F5. You should see:
You just have the full loop of the game. Everything from here is decoration.
if state != X: return) that keeps the state machine authoritative.None of this is specific to Lexicon Duel. Every turn-based game you ever build will look architecturally like this.
Deck Resource with an Array[CardData] and a draw(n) method. On PLAYER_DRAW, call deck.draw(7 - hand.get_child_count()) and spawn those cards into the hand.Events.damage_dealt.Commit this to git before moving on โ this is the project's architectural skeleton, and you'll want to diff against it later when systems grow complicated.