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 3: Deferred death
At the end of Module 2, a minion at 0 Health stays on the board forever. That was deliberate: it still has to react to the damage that put it there. In this module your engine decides when the dead leave play and what happens next. It adds one checkpoint at the end of every Sequence that removes all the dead at once, then resolves their Deathrattles, then checks again.
You need your Module 2 workspace with all Module 1 and Module 2 tests passing, under git.
Observe
Three cards (Loot Hoarder, Harvest Golem, Unstable Ghoul):
- Loot Hoarder, 2-mana 2/1: "Deathrattle: Draw a card."
- Harvest Golem, 3-mana 2/3: "Deathrattle: Summon a 2/1 Damaged Golem."
- Unstable Ghoul, 2-mana 1/3: "Taunt. Deathrattle: Deal 1 damage to all minions."
Damage does not kill on the spot. Module 2 already used this: a minion at 0 or less Health is mortally wounded, and it stays in play and keeps triggering. The rulebook says when that ends: "mortally wounded and pending destroy are ONLY converted into dead once the outermost Phase ends!" Phases nest (every reaction opens one inside the Phase that caused it), "but only the outermost Phase ending initiates the Death Creation Step." (Advanced rulebook).
The Death Creation Step removes everyone at once. It "Looks for all mortally wounded (0 or less Health)/pending destroy (hit with a destroy effect) Entities", then "sends them from play to the graveyard and creates Death Events for each." (same source). Pending destroy is the second way to die: a destroy effect such as Twisting Nether doesn't touch Health, it marks the minion, and the step collects it with the rest.
Then a Death Phase. "Then if any Deaths occurred, a Death Phase starts, resolving Death Events in order of play." Deathrattles are queued there, oldest first, and resolve depth-first like any Module 2 trigger. A Death Phase is itself an outermost Phase, so when it ends the step runs again: "This continues until there are no new Deaths." (same source).
Summons remember where the dead stood. A Deathrattle summon appears "in the board position the Death Event remembered from when it was created." (same source).
The rulebook's own example uses Unstable Ghoul. With three Ghouls dying in one Phase and an Acolyte of Pain on the board, the Acolyte "is hit, triggered and draws three cards" before it dies (same source): every Ghoul's damage lands, every Acolyte trigger resolves, and only then does the next Death Creation Step take the Acolyte.
Now a smaller chain. You played Harvest Golem, then Unstable Ghoul. The Golem has taken 2 damage and has 1 Health left. You deal 3 damage to the Ghoul.
Predict
Write your answers down before you open the discussion, for example in notes/03.md in your workspace.
- Walk through the Golem and Ghoul case event by event. How many Death Phases are there? Where on the board does the Damaged Golem appear? Does the Ghoul's Deathrattle damage the Ghoul?
- You played Loot Hoarder, then Armorsmith ("Whenever a friendly minion takes damage, gain 1 Armor."), and cast Whirlwind. How much Armor do you gain? When does the Hoarder leave the board: before or after the Armor?
- Two Loot Hoarders die to the same Whirlwind. Does the first Hoarder's card get drawn before the second Hoarder has left the board? Name a card for which the answer would change the outcome.
- Your Module 2 engine has one entry point per player action. Where should "remove the dead" live, and why not inside
damage?
Discussion
-
Two. Damage the Ghoul (0 Health). The outermost Phase ends; the Death Creation Step removes the Ghoul: Death Phase 1. Its Deathrattle damages all minions, which by now means only the Golem (and anything else still in play). The Ghoul has already left play, so its own Deathrattle doesn't hit it. The Golem drops to 0 but stays: it is only mortally wounded, and this Death Phase is still running. When Death Phase 1 ends, the step runs again, removes the Golem, and Death Phase 2 resolves its Deathrattle. The Damaged Golem appears where the Golem stood when its Death Event was created, not at the right end.
-
2 Armor. Whirlwind creates both Damage Events (Hoarder, then Armorsmith), Armorsmith reacts to each, and only after that whole Phase does the step remove the Hoarder. The rulebook's Flamestrike example is the same shape: "the Armorsmiths will be mortally wounded but remain in play, triggering on each Damage Event" (Advanced rulebook).
-
No. The step removes both Hoarders and creates both Death Events before the Death Phase resolves either Deathrattle. It matters for any Deathrattle that looks at the board. Unstable Ghoul is one: two Ghouls dying together never hit each other, because both have left play before either Deathrattle resolves. If your engine removed and resolved them one at a time, the second Ghoul would take the first one's damage while still on the board.
-
In the Sequence's entry point, after the action and all its reactions have resolved.
damageruns inside nested Phases: if it removed the minion, the minion would miss the reactions it is still owed (Module 2's mortally wounded Acolyte), and a removal there would happen mid-Phase, in the middle of somebody else's Queue. One checkpoint at the end of the outermost Phase means one place where minions leave play.
Spec
From the course repository, copy the module's tests into your workspace:
node tools/start-module.mjs 03 ~/my-engine
This module adds one harness function (the M3 line in course/harness.md):
| TypeScript | Rust |
|---|---|
destroy(target) |
destroy(&mut self, target: Id) |
destroy runs one Sequence whose only action marks target pending destroy. It deals no damage and does not move the minion itself; the Death Creation Step at the end of that Sequence does.
Two GameEvents, already in your GameEvent type since Module 2, now get recorded:
Destroyed{id}: the Death Creation Step movedidfrom play to the graveyard. One per minion, in order of play.DeathPhase{round}: a Death Phase begins.roundcounts from 1 within one Sequence.
A Deathrattle reports Triggered{source, trigger: "deathrattle"}, with the dead minion as source.
The four tests (full statements in course/03-death-step/scenarios.md) all compare the exact log:
test_03_removed_only_after_sequence: Loot Hoarder then Armorsmith, Whirlwind.Destroyed(Hoarder)comes after both Armorsmith triggers; thenDeathPhase 1, the Hoarder's Deathrattle and its draw.test_03_chained_death_phases: Harvest Golem at 1 Health, Unstable Ghoul, Test Reactor;dealDamage(ghoul, 3). Two Death Phases, and the Damaged Golem appears at the Golem's old position, left of the Reactor.test_03_simultaneous_removal: two Loot Hoarders die to Whirlwind. BothDestroyedevents come before either Deathrattle; the Deathrattles resolve in order of play in one Death Phase.test_03_destroy_marks_pending:destroyon a full-Health Harvest Golem. It leaves through the Death Creation Step with its Health untouched.
Test Reactor is the fictional "Whenever a hero takes damage, gain +1 Attack." minion from Module 2. No hero takes damage here, so it only stands on the board and gives the Damaged Golem something to land left of.
The chain test is the heart of the module:
#[test]
fn test_03_chained_death_phases() {
let mut g = new_game(GameOpts::default());
let golem = g.summon("Harvest Golem", 0);
let ghoul = g.summon("Unstable Ghoul", 0);
let r = g.summon("Test Reactor", 0);
g.deal_damage(golem, 2);
g.clear_log();
g.deal_damage(ghoul, 3);
let d = g.board(0)[0];
assert_eq!(
g.log(),
vec![
damaged(ghoul, 3),
GameEvent::Destroyed { id: ghoul },
GameEvent::DeathPhase { round: 1 },
triggered(ghoul, "deathrattle"),
damaged(golem, 1),
damaged(r, 1),
GameEvent::Destroyed { id: golem },
GameEvent::DeathPhase { round: 2 },
triggered(golem, "deathrattle"),
GameEvent::Summoned { id: d },
]
);
assert_eq!(g.name_of(d), "Damaged Golem");
assert_eq!(g.board(0), vec![d, r]);
assert_eq!(g.zone_of(ghoul), Zone::Graveyard);
assert_eq!(g.zone_of(golem), Zone::Graveyard);
assert_eq!(g.health(r), 4);
}
test("test_03_chained_death_phases", () => {
const g = newGame();
const golem = g.summon("Harvest Golem", 0);
const ghoul = g.summon("Unstable Ghoul", 0);
const r = g.summon("Test Reactor", 0);
g.dealDamage(golem, 2);
g.clearLog();
g.dealDamage(ghoul, 3);
const d = g.board(0)[0];
expect(g.log()).toEqual([
{ type: "Damaged", target: ghoul, amount: 3 },
{ type: "Destroyed", id: ghoul },
{ type: "DeathPhase", round: 1 },
{ type: "Triggered", source: ghoul, trigger: "deathrattle" },
{ type: "Damaged", target: golem, amount: 1 },
{ type: "Damaged", target: r, amount: 1 },
{ type: "Destroyed", id: golem },
{ type: "DeathPhase", round: 2 },
{ type: "Triggered", source: golem, trigger: "deathrattle" },
{ type: "Summoned", id: d },
]);
expect(g.nameOf(d)).toBe("Damaged Golem");
expect(g.board(0)).toEqual([d, r]);
expect(g.zoneOf(ghoul)).toBe("graveyard");
expect(g.zoneOf(golem)).toBe("graveyard");
expect(g.health(r)).toBe(4);
});
damaged and triggered are small helpers at the top of the Rust test file that build those two events.
Add a destroy stub and run cargo test or npm test. The four Module 3 tests fail; Modules 1 and 2 still pass. That is RED.
Build
Make the four tests pass without breaking Modules 1 and 2. The rules for where code goes still hold:
- The death step, the Death Phase loop, pending destroy, "summon at a position" and the Deathrattle trigger condition are generic machinery:
src/engine/. - Loot Hoarder, Harvest Golem, Damaged Golem and Unstable Ghoul are cards:
src/content/. The engine must not name them. "Deathrattle" is rules vocabulary, like "a friendly minion takes damage" was in Module 2. src/harnessstays thin.destroyhands one action to the engine, exactly asdealDamagedoes.
test_02_mortally_wounded_still_triggers must keep passing. It now ends with the Acolyte in the graveyard, which that test doesn't check: what it checks, that a mortally wounded minion still draws, is still true.
Hint 1 — nudge
Your Module 2 runSequence has a comment where deaths go. After perform returns, every Queue of the Sequence has been resolved, so that line is the end of the outermost Phase. What should happen there? And what must happen again after the Deathrattles you resolve there have finished?
Hint 2 — concept
- Marks, not removals. Damage lowers Health. A destroy action sets a
pendingDestroyflag. Neither moves anything. - Death Creation Step: one function. Collect every minion in play, in order of play, that has Health ≤ 0 or
pendingDestroy. Read each one's board index first, then move them all to the graveyard and recordDestroyedfor each. Return their Death Events, each holding the id and the remembered position. - Death Phase: if the step returned anything, record
DeathPhase{round}and hand the Death Events to the samereactyou wrote in Module 2. A Deathrattle is a trigger whose condition is "the Death Event is mine". The dead minion is no longer in play, soreactmust also consider the dead minion itself as a listener for its own Death Event. - Loop: after the Death Phase, run the step again. Stop when it returns nothing.
- Position: an effect needs to know where to summon. Pass the remembered position along with the trigger (in the reference it's a field of the effect's context), and give the summon action an optional board index.
Hint 3 — pseudo-code
runSequence(action):
perform(action)
deaths = endOfOutermostPhase()
round = 0
while deaths not empty:
round += 1
log DeathPhase(round)
react(deaths) // the Module 2 Phase, unchanged in shape
deaths = endOfOutermostPhase()
endOfOutermostPhase(): // Module 4 adds aura updates around this
return deathCreationStep()
deathCreationStep():
dying = [m for m in minionsInPlay (order of play) if m.health <= 0 or m.pendingDestroy]
events = [Destroyed(m, boardIndex(m)) for m in dying] // positions first
for e in events:
move e.id to graveyard; log Destroyed(e.id)
return events
perform(action):
...
Destroy(targets): for t in targets: t.pendingDestroy = true
Summon(card, p, at): create card on p's board at index min(at, len), or the right end
react(events):
for e in events:
listeners = minionsInPlay, plus e.id if e is Destroyed // sorted by order of play
for m in listeners, for trig in m.triggers:
if responds(trig.on, m, e): queue.push((m, trig, e))
for (m, trig, e) in queue:
log Triggered(m, label(trig.on))
position = e.position if e is m's Death Event else boardIndex(m)
for a in trig.effect(ctx(owner = m.owner, source = m, position)):
perform(a)
responds(deathrattle, m, Destroyed(id)) -> id == m
When your tests pass, compare with the reference engine in course/03-death-step/reference/rust/src/engine/ or course/03-death-step/reference/ts/src/engine/. The step is death.rs / death.ts; the loop is run_sequence / runSequence in queue.rs / queue.ts.
Two decisions in the reference are worth naming, because the rulebook raises them and this module's cards don't force them:
- A queued trigger whose source has left play. In this engine that cannot happen to anything but a Deathrattle. Nothing leaves play mid-Phase, and the Death Creation Step runs only after the outermost Phase, when every Queue has been emptied. Deathrattles are the deliberate exception: their source has always left play by the time they resolve, and they resolve anyway. A minion that is only mortally wounded is still in play and still triggers (Module 2's Acolyte). The course's sources don't say what happens to a queued trigger whose source is removed some other way mid-Phase. No card in this course does that, so the engine doesn't re-check a source before resolving its trigger. That is a course simplification.
- "A trigger cannot be nested inside itself" (Advanced rulebook). The reference has no guard for this yet. No card in this module can nest a trigger inside itself: a Ghoul whose Deathrattle kills another Ghoul doesn't nest anything, because the second Ghoul's Deathrattle waits for the next Death Phase. Two Module 2-style cards that deal damage whenever a minion takes damage would recurse forever, and the rule is the cure. Add it when a card needs it.
How the real game differs
- The Death Creation Step is one of several steps at the end of the outermost Phase: "After the outermost Phase ends, Hearthstone does an Aura Update (Health/Attack), then does the Death Creation Step", and an Aura Update (Other) follows it (Advanced rulebook). The course has no auras until Module 4, which adds both updates around the step.
- The rulebook says "negative (damaging/destroying/hindering/misdirecting) triggers ignore mortally wounded and pending destroy characters", while "AoE effects count mortally wounded" (same source). The course's area effects count them, which matches; nothing in the course is a targeted negative trigger, so the first rule is not modelled.
- A Death Phase queues "Deathrattles, on-Death Secrets and on-Death triggered effects" (same source). The course has only Deathrattles.
- "Feign Death and similar effects summon minions at the right side of the board, not the minion's position" (same source). The course has no such effect; every Deathrattle summon uses the remembered position.
- Unstable Ghoul has Taunt. The course has no combat, so Taunt does nothing here. Heroes never die in the course either: no game ends.
Name it
The pattern is a deferred state-based check. Effects only change state (Health, a pending-destroy flag). Whether that state means "dead" is decided at one defined checkpoint, for everything at once, and the check repeats until it finds nothing.
Magic: The Gathering has the same rule under that name. The Comprehensive Rules, rule 704: "State-based actions are game actions that happen automatically whenever certain conditions (listed below) are met." The game "performs all applicable state-based actions simultaneously as a single event", and "If any state-based actions are performed as a result of a check, the check is repeated". One of the conditions (704.5f): "If a creature has toughness 0 or less, it's put into its owner's graveyard." That is a Death Creation Step and its loop, in a different game.
In the reference engine, the end of a Sequence:
/// Run one Sequence: a player action and everything it causes.
pub fn run_sequence(world: &mut World, action: Action) {
perform(world, action);
// The outermost Phase has ended. Each Death Phase is itself an outermost
// Phase, so the end-of-Phase steps run again after it, until nothing dies.
let mut deaths = end_of_outermost_phase(world);
let mut round = 0;
while !deaths.is_empty() {
round += 1;
world.record(Event::DeathPhase { round });
react(world, &deaths);
deaths = end_of_outermost_phase(world);
}
}
/// The steps that run only when the outermost Phase ends; returns the Death Events.
/// Module 4 adds an Aura Update (Health/Attack) before the Death Creation Step
/// and an Aura Update (Other) after it, here.
fn end_of_outermost_phase(world: &mut World) -> Vec<Event> {
death_creation_step(world)
}
/** Run one Sequence: a player action and everything it causes. */
export function runSequence(world: World, action: Action): void {
perform(world, action);
// The outermost Phase has ended. Each Death Phase is itself an outermost
// Phase, so the end-of-Phase steps run again after it, until nothing dies.
let deaths = endOfOutermostPhase(world);
for (let round = 1; deaths.length > 0; round++) {
world.record({ type: "DeathPhase", round });
react(world, deaths);
deaths = endOfOutermostPhase(world);
}
}
/**
* The steps that run only when the outermost Phase ends; returns the Death Events.
* Module 4 adds an Aura Update (Health/Attack) before the Death Creation Step
* and an Aura Update (Other) after it, here.
*/
function endOfOutermostPhase(world: World): Event[] {
return deathCreationStep(world);
}
Notice what didn't change: a Death Phase is the Module 2 react, fed Death Events instead of Damage Events. A Deathrattle is one more trigger condition. Depth-first resolution, order of play and the locked Queue all carry over for free.
Fireplace, a Hearthstone simulator in Python, has the same checkpoint. In fireplace/game.py (commit 47a2572), action_end checks if not self._action_stack:, meaning the outermost action block has just closed, and only then refreshes auras and calls process_deaths.
Run the coupling audit again: node tools/coupling-audit.mjs ~/my-engine should print coupling-audit: ok. Your engine now knows "Deathrattle", "summon at a position" and "destroy". Those are rules vocabulary. "Harvest Golem" is not.
Expansion Drop
A new card arrives: Twisting Nether, 8-mana spell, "Destroy all minions and locations." (Twisting Nether).
How the real game differs Locations are a card type this course doesn't have. Here, Twisting Nether destroys all minions.
The constraint, as always: do not edit anything under src/engine/. 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 3 done". - Copy the expansion test for your track from
course/03-death-step/expansion/tests/rust/test_03x.rsorcourse/03-death-step/expansion/tests/ts/test_03x.test.tsinto your workspace'stests/. Run it and watch it fail. - Add Twisting Nether 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_03x_twisting_nether_destroys_all, summons Loot Hoarder and Harvest Golem for player 0 and a Test Filler for player 1, then casts Twisting Nether. All three are destroyed in one Death Creation Step, before either Deathrattle resolves, with their Health untouched. The Damaged Golem arrives afterwards and survives: the destroy marked only the minions that were in play when it resolved. The full scenario is in course/03-death-step/expansion/README.md.
Discussion: what this drop measures
If destroy is an action your effects can return, Twisting Nether is one entry in your content library:
// Expansion Drop: "Destroy all minions and locations." The course has no locations.
CardDef::spell("Twisting Nether", |c| {
vec![Action::Destroy {
targets: c.world.minions_in_play(),
}]
}),
// Expansion Drop: "Destroy all minions and locations." The course has no locations.
spell("Twisting Nether", (c) => [{ type: "destroy", targets: c.world.minionsInPlay() }]),
That is the reference answer in course/03-death-step/expansion/solution/. The drop is fair: the harness's destroy already needed a destroy action, and Whirlwind already showed how a spell targets every minion. Twisting Nether combines them.
Shapes that fail this drop:
destroylives in the harness only. If the harness marks the minion itself instead of handing the engine an action, a card can't return "destroy", and Twisting Nether needs a new engine action.- Destroy removes immediately. If the destroy action moves each minion to the graveyard and resolves its Deathrattle on the spot, the Hoarder's draw happens before the Golem is destroyed, and the log order is wrong.
- The death step only looks at Health. Pending-destroy minions at full Health are never collected.
If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1.
Further reading
- Advanced rulebook, hearthstone.wiki.gg: the glossary entries for Sequence, Phase and Death, and the passages on what runs when the outermost Phase ends (the Death Creation Step and the two Aura Updates). Read the Unstable Ghoul / Acolyte of Pain example in full.
- Wizards of the Coast, Magic: The Gathering Comprehensive Rules, rule 704 (state-based actions). Short, precise, and written for a game with far more ways to die. Read 704.3 for the loop and skim 704.5 for the list of conditions.
- Fireplace,
fireplace/game.pyat commit47a2572: readaction_endandprocess_deaths, around lines 127–192. Compare where it callsrefresh_auras()with the Module 4 seam in yourendOfOutermostPhase. - Jon Parham, Make a CCG – Action System (The Liquid Fire, 2017). The "death reaper" loop that runs "to take care of any mortally wounded cards" after the root action, previewed in Module 2, is this module's loop.