Deferred death: damage and destroy only mark minions; the Death Creation Step removes them when the outermost Phase ends, and Death Phases resolve Deathrattles in order of play until no new deaths occur. Deathrattle summons use the board position the Death Event remembered. Harness adds destroy(target). Expansion Drop: Twisting Nether as content only. test_02_mortally_wounded_still_triggers no longer asserts the Acolyte is still on the board: from Module 3 on it leaves at the end of the Sequence, and verify-references runs Module 2's tests against the Module 3 reference. Co-Authored-By: Claude Opus 5.5 <[email protected]>
24 KiB
Module 2: The action queue, depth-first
In Module 0 the naive engine got Whirlwind wrong: Acolyte of Pain took its damage and drew a card before Loot Hoarder had even been hit. In this module your engine gets it right. You add heroes, Health, Attack and Armor, triggered abilities, and an event log. Above all, you add the piece that decides when each thing happens: an action queue that resolves one player action depth-first.
You need your Module 1 workspace with all four Module 1 tests passing, under git.
Observe
Three cards (Armorsmith, Acolyte of Pain, Whirlwind):
- Armorsmith, 2-mana 1/4: "Whenever a friendly minion takes damage, gain 1 Armor."
- Acolyte of Pain, 3-mana 1/4: "Whenever this minion takes damage, draw a card."
- Whirlwind, 1-mana spell: "Deal 1 damage to ALL minions."
You played Armorsmith, then Acolyte of Pain. You cast Whirlwind. Two things are true in the real game, and the naive engine of Module 0 got both wrong.
All the damage happens first. For area damage, "all of the Damage/Healing Events are created in play order", and only then do the reactions resolve. The rulebook's Frothing Berserker example: "all the damage occurs, then each 'On Damage' Event is resolved based on the order of play." (Advanced rulebook). So Armorsmith and Acolyte are both damaged before either of them reacts. Armorsmith reacts twice, once for each damaged friendly minion (itself included), and Acolyte once: 2 Armor and 1 card.
A minion at 0 Health still reacts. Damage does not kill a minion on the spot. The rulebook calls a minion at 0 or less Health mortally wounded: "mortally wounded and pending destroy are ONLY converted into dead once the outermost Phase ends!" Its Flamestrike example: "the Armorsmiths will be mortally wounded but remain in play, triggering on each Damage Event" (same source). An Acolyte with 1 Health left still draws from the Whirlwind that leaves it at 0.
Now a third case, with two fictional test cards:
- Test Echo, 1/5: "Whenever a minion takes damage, deal 1 damage to the enemy hero."
- Test Reactor, 1/5: "Whenever a hero takes damage, gain +1 Attack."
You played Test Echo, then Armorsmith, then Test Reactor. Armorsmith takes 1 damage. Echo and Armorsmith both respond. Echo is older, so it goes first and damages the enemy hero, and Reactor responds to that. Does Reactor grow before or after Armorsmith gains its Armor?
The rulebook answers: after Echo's damage, a newly raised event "immediately begins exploring its consequences, before resolving those remaining for the current trigger/Event." It names the strategy: "The process of resolving an event is similar to a depth-first search" (same source). Reactor grows first. Armorsmith's Armor comes last.
Predict
Write your answers down before you open the discussion, for example in notes/02.md in your workspace.
- In the Echo / Armorsmith / Reactor case, write the order of the three triggers, and say what a breadth-first engine (one that finishes every reaction to the first damage before starting any reaction to the second) would do instead.
- While Echo's damage is resolving, Reactor triggers. Suppose Reactor's effect summoned a new Armorsmith. Should that new Armorsmith react to the damage Armorsmith took at the start? Why or why not?
- Your engine resolves a trigger by calling the card's effect. Should the effect change the world directly (
world.damage(hero, 1)), or return a description of what it wants done? What does each choice make easy or hard?
Discussion
-
Echo, Reactor, Armorsmith. Echo resolves fully, and fully includes the hero damage it caused and Reactor's reaction to that damage. Only then does Armorsmith, the next entry in the original Queue, resolve. A breadth-first engine would run Echo, then Armorsmith, then Reactor: it would finish the first "layer" of reactions before looking at the second. The rulebook calls out the difference: "(If multiple events could be partially resolved, that would be breadth-first)" (Advanced rulebook).
-
No. Rule 2a fills the Queue "with all triggers that can respond, in order of play", and Rule 2c locks it: "A Queue becomes immutable once Hearthstone starts to resolve the first entry in it." (same source). The new Armorsmith did not exist when the Queue for that damage was filled. In code: build the whole list of responding triggers first, then walk it. Don't walk the board while resolving.
-
Returning a description (an action) is what makes the queue possible. If effects mutate the world directly, the engine never sees "a damage happened" as a thing it can queue, order or react to. Each card would have to call the trigger machinery itself, and sooner or later one would forget. If an effect returns
Damage { targets: [enemy hero], amount: 1 }, the engine performs it in one place, and that one place creates the Damage Events and fills the next Queue. Every card gets the same rules for free.
Spec
From the course repository, copy the module's tests into your workspace:
node tools/start-module.mjs 02 ~/my-engine
It prints the harness functions this module adds. They are listed in full in course/harness.md (M2 line, and the GameEvent section):
| TypeScript | Rust |
|---|---|
hero(player) |
hero(&self, player: Player) -> Id |
dealDamage(target, amount) |
deal_damage(&mut self, target: Id, amount: i32) |
cast(card, player?, target?) |
cast(&mut self, card: &str, player: Player, target: Option<Id>) |
health(id), attack(id), armor(player) |
health(&self, id) -> i32, attack(...), armor(&self, player) -> i32 |
log(), clearLog() |
log(&self) -> Vec<GameEvent>, clear_log(&mut self) |
log() returns every event since the game started or since the last clearLog(), in the order they happened. Events are tagged by type: Summoned, Damaged{target, amount}, Triggered{source, trigger}, Drew{player, id}, Fatigue{player}, ArmorGained{player, amount}, StatsChanged{id, attack, health}, SpellCast{card} (plus Destroyed and DeathPhase, which arrive in Module 3). In Rust, define pub enum GameEvent in src/harness.rs with the shape given in course/harness.md. Every trigger in this module reports trigger: "damaged".
Each player has a hero with 30 Health, in play from the start. hero(player) returns its id; board(player) still lists minions only. dealDamage and cast each run one complete Sequence: the action and every reaction it causes.
The four tests (full statements in course/02-action-queue/scenarios.md):
test_02_aoe_damage_then_triggers: Armorsmith then Acolyte of Pain, decks of 5Test Filler, Whirlwind. BothDamagedevents (Armorsmith first) come before anyTriggeredevent; Armorsmith triggers twice and Acolyte once; 2 Armor; 1 card in hand.test_02_mortally_wounded_still_triggers: an Acolyte at 1 Health (afterdealDamage(acolyte, 3)) still draws from Whirlwind and ends at 0 Health. The test does not ask where the Acolyte ends up: from Module 3 on, it leaves the board once the Whirlwind's reactions are done.test_02_depth_first_nesting: Test Echo, Armorsmith, Test Reactor;dealDamage(armorsmith, 1). Triggers in order Echo, Reactor, Armorsmith; Reactor'sStatsChangedbeforeArmorGained.test_02_empty_deck_draw_is_fatigue: an Acolyte with an empty deck takes 1 damage. The log is exactlyDamaged,Triggered,Fatigue, and nothing else changes.
The first test does not check how Armorsmith's two triggers and Acolyte's one are interleaved. The rulebook pins the big picture (all damage, then every reaction in order of play), and the reference engine's choice is in the Build hints, but the course's sources don't state that detail for this case clearly enough to test it.
The depth-first test is the heart of the module. triggered_sources / triggeredSources is a small helper at the top of the test file that lists the source of each Triggered event in log order.
#[test]
fn test_02_depth_first_nesting() {
let mut g = new_game(GameOpts::default());
let e = g.summon("Test Echo", 0);
let s = g.summon("Armorsmith", 0);
let r = g.summon("Test Reactor", 0);
g.clear_log();
g.deal_damage(s, 1);
let log = g.log();
assert_eq!(triggered_sources(&log), vec![e, r, s]);
let reactor_grew = log
.iter()
.position(|ev| matches!(ev, GameEvent::StatsChanged { id, .. } if *id == r))
.unwrap();
let armor_gained = log
.iter()
.position(|ev| matches!(ev, GameEvent::ArmorGained { .. }))
.unwrap();
assert!(reactor_grew < armor_gained);
assert_eq!(g.attack(r), 2);
assert_eq!(g.armor(0), 1);
assert_eq!(g.health(g.hero(1)), 29);
}
test("test_02_depth_first_nesting", () => {
const g = newGame();
const e = g.summon("Test Echo", 0);
const s = g.summon("Armorsmith", 0);
const r = g.summon("Test Reactor", 0);
g.clearLog();
g.dealDamage(s, 1);
const log = g.log();
expect(triggeredSources(log)).toEqual([e, r, s]);
const reactorGrew = log.findIndex((ev) => ev.type === "StatsChanged" && ev.id === r);
const armorGained = log.findIndex((ev) => ev.type === "ArmorGained");
expect(reactorGrew).toBeGreaterThanOrEqual(0);
expect(reactorGrew).toBeLessThan(armorGained);
expect(g.attack(r)).toBe(2);
expect(g.armor(0)).toBe(1);
expect(g.health(g.hero(1))).toBe(29);
});
Add stubs for the new harness functions and run cargo test or npm test. The four Module 2 tests fail and the Module 1 tests still pass. That is RED.
Build
Make the four tests pass without breaking Module 1's. The rules for where code goes still hold:
- The queue, triggers, damage, drawing and the log are generic machinery:
src/engine/. - Armorsmith, Acolyte of Pain, Whirlwind, Test Echo and Test Reactor are cards:
src/content/. The engine must not name them. The coupling audit checks this. src/harnessstays thin.dealDamageandcasteach hand one action to the engine.
A card's behaviour can be ordinary code in this module: a function in src/content/ that the engine calls. Module 5 turns those functions into data. What matters now is that a card's code only describes what it wants done, and the engine decides when it happens.
Don't build deaths. A minion at 0 Health stays on the board and keeps reacting, which is what test_02_mortally_wounded_still_triggers relies on. Module 3 adds the step that removes it, at the end of the Sequence.
Hint 1 — nudge
Whirlwind is one action that creates two Damage Events. Do all of the damage first, collecting the events as you go, and only then ask "who responds?". When a response itself deals damage, that damage is a new action, and it goes through the very same function. Which programming technique handles "the same function, nested inside itself, finishing before the caller carries on"?
Hint 2 — concept
- Action: a plain value describing something the engine can do: damage these targets, draw for this player, gain Armor, gain Attack, cast this spell. Card effects return a list of actions. They never change the world themselves.
- Perform: the engine's one function that carries out an action. It changes the world, records events in the log and, for damage, collects the Damage Events it created.
- Trigger: a card ability with a condition (this minion / a friendly minion / any minion / a hero takes damage) and an effect.
- Phase and Queue: after a damage action, build a list of
(listener, trigger)pairs: for each Damage Event in order, each minion in play in order of play, each of its triggers whose condition matches. Build the whole list before resolving any of it; that is the locked Queue. - Depth-first: resolve the Queue in order. For each entry, call the effect and perform each returned action right away. A damage action inside opens its own Phase and resolves it completely before the outer loop moves on. Plain recursion does exactly this.
- Order of play for area damage: sort the targets by order of play before damaging them. Heroes are in play from the start, so give them an order of play too.
Hint 3 — pseudo-code
runSequence(action):
perform(action)
// Module 3 processes deaths here, once the outermost action is done
perform(action):
match action:
Damage(targets, amount):
events = []
for t in targets sorted by orderOfPlay:
t.health -= amount; log Damaged(t, amount)
events.push(Damaged(t, amount))
react(events)
Draw(p):
if deck(p) empty: log Fatigue(p)
else: move top card to hand; log Drew(p, id)
GainArmor(p, n): armor[p] += n; log ArmorGained(p, n)
GainAttack(id, n): attack[id] += n; log StatsChanged(id, ...)
Cast(card, p, target):
log SpellCast(card)
for a in card.onCast(ctx(owner = p, target)): perform(a)
react(events): // one Phase
queue = []
for e in events: // already in order of play
for m in minionsInPlay sorted by orderOfPlay:
for trig in m.card.triggers:
if responds(trig.on, m, e): queue.push((m, trig))
for (m, trig) in queue: // the queue is locked now
log Triggered(m, "damaged")
for a in trig.effect(ctx(owner = m.owner, source = m)):
perform(a) // depth-first
responds(on, m, Damaged(t)):
selfDamaged -> t == m
friendlyMinionDamaged -> t is a minion and owner(t) == owner(m)
anyMinionDamaged -> t is a minion
heroDamaged -> t is a hero
With this queue order, Whirlwind on Armorsmith and Acolyte gives Armorsmith (its own damage), Armorsmith (Acolyte's damage), Acolyte. The test accepts any interleaving of those three.
When your tests pass, compare with the reference engine in course/02-action-queue/reference/rust/src/engine/ or course/02-action-queue/reference/ts/src/engine/. The resolver is queue.rs / queue.ts; the content library is a list of card definitions, one short effect each.
How the real game differs The course queues every trigger strictly in order of play. The real game has exceptions: Redemption "always Queues last", Poisonous "resolves in a queue with higher priority", and when a minion takes combat damage "the defender ALWAYS triggers first, regardless of order of play". Some area spells (the rulebook lists Lightning Storm, Elemental Destruction, Dark Iron Skulker and Lightbomb) resolve each Damage Event as soon as it is created instead of creating them all first (Advanced rulebook). None of these are modelled in this course. Within the course itself: Fatigue is only an event and does nothing else; Armor is only a number, and nothing in this module damages a hero that has Armor; the game has no death step until Module 3.
Name it
The pattern is an action queue resolved depth-first. Card code describes what it wants as actions. One engine function performs them. Each Phase fills a Queue of responding triggers in order of play, locks it, and resolves each entry completely, its nested reactions included, before the next. The Liquid Fire Make a CCG series, which says its design "was based on the documentation in the advanced rulebook", states both rules as design goals: "All reactions occur based on the order of play of the responding cards." and "All reactions are resolved in a depth-first order (children nodes before sibling nodes)." (Jon Parham, Make a CCG – Action System).
In the reference engine, this is the Phase:
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
let mut queue = Vec::new();
for event in events {
for listener in world.minions_in_play() {
for trigger in &world.card(world.card_name(listener)).triggers {
if responds(world, trigger.on, listener, event) {
queue.push((listener, *trigger));
}
}
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (source, trigger) in queue {
world.record(Event::Triggered {
source,
on: trigger.on,
});
let ctx = Ctx {
world,
owner: world.owner_of(source),
source: Some(source),
target: None,
};
let actions = (trigger.effect)(&ctx);
// Depth-first: each action, and every reaction it causes, finishes before the next.
for action in actions {
perform(world, action);
}
}
}
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
const queue: [EntityId, Trigger][] = [];
for (const event of events) {
for (const listener of world.minionsInPlay()) {
for (const trigger of world.card(world.cardName(listener)).triggers) {
if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger]);
}
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (const [source, trigger] of queue) {
world.record({ type: "Triggered", source, on: trigger.on });
const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source));
// Depth-first: each action, and every reaction it causes, finishes before the next.
for (const action of actions) perform(world, action);
}
}
Two things to notice. The call stack is the depth-first order: perform calls react, which calls perform for a nested action, and the outer loop only continues once that returns. And run_sequence / runSequence is a single entry point for every player action. That is where Module 3 will add death processing, "once the outermost Phase ends".
This is not the queue you may know from other games. Robert Nystrom's Event Queue chapter, the classic description of the general pattern, says "You only need a queue when you want to decouple something in time" and offers "A common rule to avoid this is to avoid sending events from within code that's handling one" (Game Programming Patterns, Event Queue). A card game breaks that rule on purpose: reactions cause reactions. It survives by fixing the order explicitly (order of play) and the depth (finish the child before the sibling).
Run the coupling audit again: node tools/coupling-audit.mjs ~/my-engine should print coupling-audit: ok. Your engine now holds damage conditions like "a friendly minion" and "a hero"; those are rules vocabulary. "Armorsmith" is not.
Expansion Drop
A new card arrives: Frothing Berserker, 3-mana 2/4, "Whenever a minion takes damage, gain +1 Attack." (Frothing Berserker). The rulebook used it as its example for area damage: "all the damage occurs, then each 'On Damage' Event is resolved based on the order of play." (Advanced rulebook).
The constraint, as always: do not edit anything under src/engine/. This time the harness doesn't need to change either. Only src/content/ should.
- Commit your workspace and note the commit:
git add -A && git commit -m "module 2 done". - Copy the expansion test for your track from
course/02-action-queue/expansion/tests/rust/test_02x.rsorcourse/02-action-queue/expansion/tests/ts/test_02x.test.tsinto your workspace'stests/. Run it and watch it fail. - Add Frothing Berserker to your content.
- When the tests pass, run the check from the course repository:
node tools/expansion-check.mjs ~/my-engine HEAD
The test, test_02x_frothing_counts_each_damage, summons Frothing Berserker and a Test Filler for player 0, then a Test Filler for player 1, and casts Whirlwind. Three minions take damage (Frothing itself and the enemy's minion included), so Frothing gains +3 Attack from three separate triggers. The full scenario is in course/02-action-queue/expansion/README.md.
Discussion: what this drop measures
If triggers in your engine are generic (a condition from a small vocabulary plus an effect that returns actions), Frothing Berserker is one entry in your content library:
// Expansion Drop: "Whenever a minion takes damage, gain +1 Attack."
CardDef::minion("Frothing Berserker", 2, 4).on(TriggerOn::AnyMinionDamaged, |c| {
vec![Action::GainAttack {
id: c.minion(),
amount: 1,
}]
}),
// Expansion Drop: "Whenever a minion takes damage, gain +1 Attack."
minion("Frothing Berserker", 2, 4, [
{ on: "anyMinionDamaged", effect: (c) => [{ type: "gainAttack", id: c.minion(), amount: 1 }] },
]),
That is the reference answer in course/02-action-queue/expansion/solution/. The drop is fair: Test Echo already needed "any minion takes damage" and Test Reactor already needed "gain Attack", so a passing Module 2 engine has both pieces. Frothing combines them.
Shapes that fail this drop:
- The engine decides who reacts by name (
if card == "Armorsmith" …). The audit should already have caught that. If it slipped through as a flag (gainsArmorOnFriendlyDamage), Frothing needs a new flag, which is an engine change. - Triggers are only checked for the damaged minion itself ("did this minion take damage?"). Armorsmith and Test Echo force you past that, but an engine that special-cased them can still lack a general "any minion" listener.
- Damage and reactions are interleaved per target. Frothing still ends with +3, so this test passes. But Module 0's Whirlwind test, and Module 3's deaths, will not. If your Module 2 tests pass, you don't have this bug.
If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1.
Further reading
- Jon Parham, Make a CCG – Action System (The Liquid Fire, 2017). The most readable worked implementation of a Hearthstone-style reaction tree, in C#. Read
SortActions: reactions are sorted bypriorityfirst andorderOfPlayonly as the tie-breaker, which is where an engine would put Poisonous or Redemption. Also note the loop at the end, the "death reaper", which runs "to take care of any mortally wounded cards" after the root action. That is Module 3. - Robert Nystrom, Event Queue (Game Programming Patterns). The general pattern, and its warnings. Read the sections on feedback loops and on the world state having changed by the time an event is handled: "you have to be careful not to assume the current state of the world reflects how the world was". Your ids from Module 1 are part of the answer.
- Alchyr, Action Queue (BasicMod wiki, Slay the Spire modding). A short practical page about a different game's queue, where the modder chooses where a new action goes: "In a card's use: add to bottom. In an action's update: add to top." Compare it with the depth-first rule: "add to top" is how Slay the Spire makes a reaction happen before the next sibling.
- Advanced rulebook, hearthstone.wiki.gg: Rule 2 (Queues), the depth-first section, and Rule DH1 on area damage.