Exercise

Game State Machine for Lexicon Duel

Module 3 ยท Exercise 1 ยท ~1 hour ยท core capstone work

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.

Target structure

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.

Steps

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:

  1. SETUP โ†’ PLAYER_DRAW โ†’ PLAYER_PLAY in the log
  2. Tap 3 cards. They show as "current=CAT (value=5)" in log
  3. Hit Submit. DuelController logs damage, transitions to RESOLVING โ†’ ENEMY_TURN โ†’ PLAYER_DRAW โ†’ PLAYER_PLAY
  4. Enemy deals 3 damage per turn. Repeat until someone hits 0.
  5. VICTORY or DEFEAT state logs the outcome.

You just have the full loop of the game. Everything from here is decoration.

What you just built

None of this is specific to Lexicon Duel. Every turn-based game you ever build will look architecturally like this.

Stretch goals

Save this moment

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.