Exercise

First Duel: Player vs. Patterned Enemy

Module 5 · Exercise 1 · ~1.5 hours · core capstone work

You're going to put the pieces together: ActionQueue drives the turn flow, an EnemyStrategy picks moves, the intent appears on screen before the enemy acts, and Bleed/Strength status effects resolve correctly. When you're done, you have a game that looks and feels like a turn-based card game even if the art is a gray rectangle.

Target behavior

  1. New duel starts. Enemy shows an intent icon above its portrait ("Enemy will attack for 5").
  2. Player plays a word, hits submit, damage number floats off the enemy.
  3. Player hits End Turn. Enemy turn starts. Intent resolves — sword swings, damage number off the player.
  4. Enemy updates intent for next turn.
  5. Repeat until someone reaches 0 HP.

Steps

1Combatant base class. Build the Combatant from Lesson 3. Make Player and Enemy nodes extend it (they're both combatants — DRY this up).

2ActionQueue in DuelController. Add var queue := ActionQueue.new() to DuelController. Replace inline state logic with enqueues:

func _on_state_entered(s: State) -> void:
    match s:
        State.PLAYER_DRAW:
            queue.enqueue(DrawAction.new(player, 7 - player.hand.card_count()))
            await queue.idle
            transition_to(State.PLAYER_PLAY)
        State.RESOLVING:
            # player's word already queued its damage via _on_word_submitted
            await queue.idle
            transition_to(State.ENEMY_TURN)
        State.ENEMY_TURN:
            enemy.start_turn()  # ticks statuses
            var actions := enemy.strategy.act({ "player": player, "rng": Global.rng, "self": enemy })
            for a in actions:
                queue.enqueue(a)
            await queue.idle
            # Decide next intent
            enemy.plan_next()
            if enemy.hp <= 0: transition_to(State.VICTORY)
            elif player.hp <= 0: transition_to(State.DEFEAT)
            else: transition_to(State.PLAYER_DRAW)

3Intent UI. Above the enemy sprite, add a TextureRect + Label. On each enemy.plan_next(), update them based on enemy.strategy.intent(context). At duel start, call plan_next once to seed the first intent.

4Damage popups. When a Combatant takes damage, spawn a floating Label over it that animates upward and fades out. Minimal polish, huge game-feel impact:

# scripts/damage_popup.gd (attached to a Label)
extends Label

func show_damage(amount: int, color: Color = Color.RED) -> void:
    text = str(amount)
    modulate = color
    var t := create_tween()
    t.set_parallel()
    t.tween_property(self, "position:y", position.y - 60, 0.6)
    t.tween_property(self, "modulate:a", 0.0, 0.6).set_delay(0.2)
    t.chain().tween_callback(queue_free)

Connect Combatant.damaged → spawn a popup at the Combatant's position.

5Goblin enemy. Create enemies/goblin.tres:

Paste into your Main scene's enemy slot.

6Bleed card. Add a CardData with a BleedApplyEffect in its effects. When played, the word deals normal damage AND applies 2 stacks of Bleed to the enemy.

class_name BleedApplyEffect
extends CardEffect

@export var amount: int = 2

func apply(context: Dictionary) -> void:
    var bleed := BleedEffect.new()
    bleed.stacks = amount
    context.target.status_effects.append(bleed)

Craft a special CardData venom_tile.tres that's a "V" tile with this effect. Add one to the starter deck.

7Ship it, playtest. Play five full duels. Lose one intentionally to test DEFEAT state. Win one to test VICTORY. Note what feels bad — intent unclear? Damage numbers too small? Enemy pattern too predictable? Write the notes in your progress journal. You'll revisit in Module 6.

Stretch goals

Checkpoint At this point, you have a playable turn-based word/card duel with status effects, readable enemy intents, and damage feedback. This is a shippable vertical slice. Consider recording a ~30s gameplay clip — it'll be motivating to look back at in month 3 when you're deep in polish.