Turn Managers and Action Queues

Module 5 ยท Lesson 1 ยท ~35 min ยท Godot 4.x

In Module 3 you built a state machine where ENEMY_TURN did enemy-things in a single synchronous call. That works for one enemy with one action. When an enemy plays a card, triggers a status effect, deals damage over time, and draws a reply โ€” all with visible animations โ€” you need an action queue. Actions are data. They're queued, resolved one at a time, animated between.

Why not just call functions in sequence?

Because animations take time. Consider:

func enemy_turn() -> void:
    enemy.play_card(card_a)   # should animate for 0.4s
    enemy.play_card(card_b)   # should NOT start until card_a finishes
    player.take_damage(3)
    apply_bleed()
    end_turn()

Without awaits, these all fire in one frame โ€” animations overlap, state gets confused, the player sees garbage. You can sprinkle awaits everywhere, but then every new action has to know about timing. Cleaner: make actions a queue, let a single worker drain them.

The action queue pattern

# scripts/action.gd โ€” base class
class_name Action
extends RefCounted

func execute() -> void:
    push_error("Action subclass did not override execute()")

# Override this if you need post-animation async.
# Return true to indicate "wait until some signal fires".
# Or just call await inside execute() and the queue will wait.
# scripts/actions/damage_action.gd
class_name DamageAction
extends Action

var target: Node
var amount: int
var source_name: String

func _init(t: Node, a: int, src: String = "") -> void:
    target = t
    amount = a
    source_name = src

func execute() -> void:
    target.take_damage(amount)
    Events.damage_dealt.emit(target, amount)
    # take_damage internally plays the hit animation and awaits it
    if target.has_method("await_hit_animation"):
        await target.await_hit_animation()
# scripts/actions/draw_action.gd
class_name DrawAction
extends Action

var player: Node
var count: int

func _init(p: Node, c: int) -> void:
    player = p; count = c

func execute() -> void:
    for i in count:
        player.draw_one()
        await player.get_tree().create_timer(0.08).timeout  # staggered visual draw
# scripts/action_queue.gd โ€” the worker
class_name ActionQueue
extends RefCounted

signal idle

var _queue: Array[Action] = []
var _running := false

func enqueue(a: Action) -> void:
    _queue.append(a)
    if not _running:
        _run_next()

func _run_next() -> void:
    if _queue.is_empty():
        _running = false
        idle.emit()
        return
    _running = true
    var action := _queue.pop_front() as Action
    await action.execute()
    _run_next()

Usage:

var queue := ActionQueue.new()
queue.enqueue(DamageAction.new(player, 3, "Enemy"))
queue.enqueue(DrawAction.new(player, 2))
queue.enqueue(DamageAction.new(player, 1, "Bleed"))
await queue.idle  # wait until all resolved

Every action resolves in order. Each can await anything it needs. The rest of the system only needs to wait for the idle signal.

This is a message-passing actor with cooperative scheduling. In JS terms: a Promise.resolve().then(a).then(b).then(c), but with the ability to enqueue new items mid-flight โ€” more like a RabbitMQ worker on a single thread.

When actions spawn actions

An enemy's "Chain Lightning" deals damage, then triggers an extra strike. The damage action knows the extra strike:

class_name ChainLightningAction
extends Action

var targets: Array[Node]
var amount: int

func execute() -> void:
    for t in targets:
        await DamageAction.new(t, amount).execute()
        amount = max(1, amount - 1)  # falloff

Or, more idiomatically, the parent action enqueues children on the same queue:

func execute() -> void:
    for t in targets:
        queue.enqueue(DamageAction.new(t, amount))
        amount = max(1, amount - 1)

Both are fine. The inline version is easier to reason about; the enqueue version lets interrupts slip in between sub-actions. For Lexicon Duel, inline is plenty.

Status effects as actions

"Bleed 2" means: at the start of the target's turn, take 2 damage and decrement stacks. Trivially modeled:

# On turn start
for effect in target.status_effects:
    queue.enqueue(effect.on_turn_start(target))
class_name BleedEffect
extends StatusEffect

var stacks: int

func on_turn_start(target: Node) -> Action:
    var action := DamageAction.new(target, stacks, "Bleed")
    stacks -= 1  # decay one stack per turn
    return action

Status effects are Resources (remember Module 3 โ€” data-driven design). The gameplay system iterates them and enqueues the actions they return.

Interrupts and reactions

"When you play a word, enemy takes 1 extra damage from Weakness." This is a reaction โ€” the enemy-hp-mod happens right after the main damage, before anything else in the queue.

# When an action completes, let observers inject
signal action_finished(a: Action)

func _run_next() -> void:
    if _queue.is_empty(): _running = false; idle.emit(); return
    _running = true
    var action := _queue.pop_front() as Action
    await action.execute()
    action_finished.emit(action)
    _run_next()

A reaction system listens to action_finished, checks whether it matches a trigger, and enqueues the reaction at the front of the queue (_queue.push_front) so it resolves before the next pending action.

This gets complicated fast. If you find yourself building a full Magic: the Gathering stack for Lexicon Duel, stop โ€” you're over-engineering. For v1, status effects that tick on turn-start are enough depth.

Practical implementation for Lexicon Duel

Minimal turn structure:

  1. Player turn start: tick player's statuses (bleed, regen), draw up to 7.
  2. Player action: submit word OR end turn without playing.
  3. Resolve: compute damage, apply. Discard played cards.
  4. Enemy turn start: tick enemy statuses.
  5. Enemy action: choose word from AI strategy (next lesson), submit, resolve.
  6. Loop.

Every bullet above is one Action. Your DuelController stays simple โ€” it enqueues actions and waits for idle before transitioning.

Do this now

  1. Create scripts/action.gd, scripts/action_queue.gd per the patterns above.
  2. Create DamageAction, DrawAction subclasses.
  3. Refactor DuelController._on_state_entered(ENEMY_TURN) to enqueue a DamageAction instead of directly mutating player_hp. Await queue.idle, then transition.
  4. Nothing visually changes yet โ€” but you just bought yourself the ability to add 50 enemy behaviors without rewriting the turn logic.