"AI" in a turn-based card game is rarely actual AI — it's a pattern: a sequence or weighted table of moves. The best deckbuilder AI is legible. Players see what the enemy will do next turn ("intent") and plan around it. Slay the Spire made this a genre standard for good reason. Your word-duel opponent should do the same.
We're going to build AI like we built card effects: a base Resource class with an act() method, and subclasses for each archetype. Designer-friendly, data-driven, no hardcoded enemies.
class_name EnemyStrategy
extends Resource
# Return the Action(s) the enemy wants to take this turn.
# Context has: self (enemy), player, turn_number, rng
func act(_context: Dictionary) -> Array[Action]:
push_warning("Strategy subclass did not override act()")
return []
# Preview of next turn's intent, shown on UI.
# Return a small struct: { "type": "attack", "amount": 5 }
func intent(_context: Dictionary) -> Dictionary:
return {"type": "unknown"}
The baseline. The enemy has a "dictionary" of words it can play (a small hand-curated list — 50 common 3-to-5-letter words is plenty for v1). Each turn, pick a random valid word, compute damage, attack.
class_name RandomWordStrategy
extends EnemyStrategy
@export var word_pool: Array[String] = ["CAT", "DOG", "BIRD", "FISH", "TREE", "WORD"]
@export var damage_multiplier: float = 1.0
func act(context: Dictionary) -> Array[Action]:
var rng: RandomNumberGenerator = context.rng
var word: String = word_pool[rng.randi() % word_pool.size()]
var dmg := int(Scoring.word_damage(word) * damage_multiplier)
return [DamageAction.new(context.player, dmg, word)]
func intent(context: Dictionary) -> Dictionary:
# Peek the next word so the player sees "Enemy will play WORD for X".
# But this means we've already decided. Store the decision.
if not context.has("planned"): return {"type": "attack", "amount": 3}
return {"type": "attack", "amount": context.planned.damage}
The enemy has a hand of letter tiles like the player. Each turn, it finds the highest-scoring valid word it can make from its tiles and plays it. This is actually a fun AI — optimal play but bounded by the tile randomness.
class_name GreedyWordStrategy
extends EnemyStrategy
var hand: Array[String] = [] # populated by controller
func act(context: Dictionary) -> Array[Action]:
var best := _find_best_word(hand)
if best.is_empty():
return [] # pass
var dmg := Scoring.word_damage(best)
return [DamageAction.new(context.player, dmg, best)]
func _find_best_word(tiles: Array[String]) -> String:
var perms := _all_permutations_up_to(tiles, 6) # cap length 6 for perf
var best := ""
var best_score := 0
for p: String in perms:
if Dictionary.is_valid(p):
var s := Scoring.word_damage(p)
if s > best_score:
best_score = s
best = p
return best
Generating all permutations up to length 6 on a 7-tile hand is fine performance (~14k checks × ~10µs = manageable). Cap length based on target device.
A fixed cycle of moves. Turn 1: buff. Turn 2: small attack. Turn 3: big attack. Turn 4: shield. Repeat. This is Slay the Spire's Jaw Worm. Minimal AI, high readability, fun to play around.
class_name PatternedStrategy
extends EnemyStrategy
@export var pattern: Array[ScriptedMove] = []
var turn_index: int = 0
func act(context: Dictionary) -> Array[Action]:
var move: ScriptedMove = pattern[turn_index % pattern.size()]
turn_index += 1
return move.to_actions(context)
func intent(context: Dictionary) -> Dictionary:
var move: ScriptedMove = pattern[turn_index % pattern.size()]
return move.intent
# scripts/scripted_move.gd
class_name ScriptedMove
extends Resource
@export var damage: int = 0
@export var block: int = 0
@export var intent: Dictionary = {} # {"type": "attack", "amount": 8}
func to_actions(context: Dictionary) -> Array[Action]:
var out: Array[Action] = []
if damage > 0:
out.append(DamageAction.new(context.player, damage))
if block > 0:
out.append(BlockAction.new(context.self_enemy, block))
return out
Now you can define an enemy entirely in the Inspector: drag a PatternedStrategy into the enemy's strategy slot, populate its pattern array with three ScriptedMove resources. Zero code per enemy.
Show the player what the enemy will do next turn. A Label above the enemy with the icon + number:
# In enemy_view.gd
@onready var intent_icon: TextureRect = $IntentIcon
@onready var intent_label: Label = $IntentLabel
func update_intent(intent: Dictionary) -> void:
match intent.get("type"):
"attack":
intent_icon.texture = preload("res://icons/sword.png")
intent_label.text = str(intent.amount)
"block":
intent_icon.texture = preload("res://icons/shield.png")
intent_label.text = str(intent.amount)
"buff":
intent_icon.texture = preload("res://icons/sparkle.png")
intent_label.text = ""
_:
intent_icon.texture = preload("res://icons/question.png")
intent_label.text = "?"
The enemy decides next turn's move at the end of the current turn, displays the intent, and executes on its next turn. This one-turn lookahead is what makes deckbuilders strategic instead of reactive.
Rather than writing separate enemies per difficulty, scale the existing strategy. EnemyData has max_hp and a strategy. A harder version is the same strategy with higher HP and a damage multiplier. You rarely need more than that until late game.
For Lexicon Duel v1, make all enemies PatternedStrategy with hand-designed sequences. It's 5x less work than GreedyWord and the gameplay is tighter.
EnemyStrategy, ScriptedMove, PatternedStrategy classes.@export var strategy: EnemyStrategy to your EnemyData resource.enemies/goblin.tres with a 3-move pattern: small hit, block, big hit.enemy.strategy.act(context), enqueue the returned actions, await idle.