Files
systemic-game-design-course/course/10-spellgrid/lesson.md
T
Lucas WintherandClaude Opus 5.5 6f576940b7 fix: module 10 review round 1 (module-local SpellGrid cards, cast target check, CLI without panic hook)
- SpellGrid cards move to course/10-spellgrid/cards; start-module and
  verify-references stage course/cards plus module-local cards up to the module,
  so M5-M9 workspaces get exactly the pre-M10 card set.
- cast() rejects a chosen-target spell without a target (checkCast/checkPlay);
  new test_10_targeted_spell_needs_target.
- Rust CLI pre-validates instead of swapping the global panic hook.
- Catalog: CRIER noun; DEATHRATTLE/BATTLECRY note on lexicon vs catalog role.
- Lesson: design note on mid-movement reparse.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:11:09 +01:00

402 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Module 10: SpellGrid
You now have two engines. Part I's resolves cards: a Sequence of actions, triggers queued and resolved depth-first, deaths held back until the Death Creation Step, auras read from a snapshot, and cards as data. Part II's resolves a grid: words become rules, the rules are recompiled whenever the board changes, and a turn is a fixed list of phases. This module puts both in one small game of our own, **SpellGrid**, where minions with Health and Deathrattles stand on a grid next to word blocks.
The point is not a bigger engine. It is how little the two engines need to know about each other. SpellGrid joins them at three seams and nowhere else, and every test in this module checks one seam, or a level that needs all three.
SpellGrid is the course's game, not Blizzard's or Hempuli's. Its cards are SpellGrid originals, marked `"game": "spellgrid"` in `course/catalog.json`, and its rules are the course's choices. Where this lesson says what Hearthstone or Baba Is You does, it cites the source an earlier module already used.
You need your Module 9 workspace with all Module 1–9 tests passing, under git.
## Observe
Two rules from two games, both met before.
From Part I. Baron Rivendare: "Your minions trigger their Deathrattles twice." ([Baron Rivendare](https://hearthstone.wiki.gg/wiki/Baron_Rivendare)). Module 4 built the machinery that makes that true, from the [Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook):
- "Baron Rivendare's effect, being an aura, only appears and disappears between Phases when Aura Update (Other) runs".
- When the outermost Phase ends, "Hearthstone does an Aura Update (Health/Attack), then does the Death Creation Step", and an Aura Update (Other) follows the step.
- "Cards that trigger effects multiple times, like Brann Bronzebeard, Baron Rivendare and Drakkari Enchanter, check before an Event starts."
So in Module 4's Twisting Nether case, Baron and a Loot Hoarder destroyed together leave play in the same Death Creation Step, the Aura Update after it finds no Baron, and the Hoarder's Deathrattle resolves once.
From Part II. Module 7's `NOT`, from the Baba Is You Wiki:
- "ROCK IS NOT PUSH makes ROCK NOT PUSHable, even if ROCK IS PUSH exists elsewhere." ([NOT](https://babaiswiki.fandom.com/wiki/NOT))
- "BABA IS NOT YOU prevents BABA from having the property YOU, overriding any BABA IS YOU rule." ([IS](https://babaiswiki.fandom.com/wiki/IS))
And Module 9's turn, from [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations): "Deletion occurs after each step that removes objects, but all objects destroyed in the same step are destroyed simultaneously." The [Rule](https://babaiswiki.fandom.com/wiki/Rule) page: "Rules parse when any parsable object changes position or is destroyed, or an object is transformed to text."
Now the fused game. Nothing below is from a real game; it is what SpellGrid does, and what this module's tests check.
1. A grid holds the words `DEATHRATTLE IS DOUBLE`, and a Powder Keg (1/1, noun `KEG`, "Deathrattle: Deal 1 damage to all minions.") stands on it next to a Bramble Wall (0/3, noun `BRAMBLE`). You cast Whirlwind. The keg dies, and its Deathrattle goes off **twice**: the words on the grid did what Baron does.
2. The words `KEG IS YOU AND MELT` and `LAVA IS HOT`. You walk the keg onto the lava. It melts, but it doesn't just vanish from the grid: it dies in Part I's Death Creation Step, and its Deathrattle hits the bramble.
3. A Rune Scribe ("Deathrattle: Write WIN on this tile.") stands on the empty end of `BABA IS`. It dies. `WIN` appears where it stood, and `BABA IS WIN` is a rule before anything else happens.
## Predict
Write your answers down before you open the discussion, for example in `notes/10.md` in your workspace.
1. `DEATHRATTLE IS DOUBLE` is on the grid, and a Grave Warden is in play: a SpellGrid card with Baron's text, "Your minions trigger their Deathrattles twice." A Powder Keg dies. How many times does its Deathrattle trigger: two, three or four?
2. Now `DEATHRATTLE IS NOT DOUBLE` is on the grid too, and the Warden is still in play. How many times?
3. A Grave Warden (1 Health) and a Powder Keg (1 Health) die to the same Whirlwind. A Tin Knight with 3 Health is `YOU`. Does the knight survive the turn?
4. The scribe from Observe dies because a turn melted it in lava. `BABA IS YOU` is on the board. Is the level won in that same turn, or the next one?
5. Which of the two engines should know that the word `DEATHRATTLE` means Deathrattles?
<details><summary>Discussion</summary>
1. **Two.** The rule's multiplier and the Warden's are the same kind of data, a trigger multiplier with `stacking: "max"`, so together they double and no more. Module 4 needed `stacking` because Hearthstone's doublers don't stack ("max") while the course's Test Chorus does ("additive"). SpellGrid's choice is that a rule on the grid is one more doubler like Baron, so it takes "max" too. Had it been "additive", the Warden and the rule would give three activations (1 + 1 + 1), not four: the course's additive stacking adds each multiplier's extra activations.
2. **Once.** In Module 7, `X IS NOT Y` overrides `X IS Y` wherever it is written. SpellGrid gives the negated rule the same reach: `DEATHRATTLE IS NOT DOUBLE` caps Deathrattles at one activation, whatever multiplies them, card or rule. The rule `DEATHRATTLE IS DOUBLE` is cancelled by Module 7's resolution pass and never becomes a modifier; the Warden's aura is cancelled by the cap. Both are SpellGrid's choices, not a real game's.
3. **Yes, with 1 Health.** Whirlwind deals 1 to everything: the knight goes to 2, the keg and the Warden to 0. They leave play in the same Death Creation Step. The Aura Update runs after the step, the Warden's aura is gone, and the keg's Deathrattle deals 1 once: the knight goes to 1. This is Module 4's Twisting Nether case with damage instead of a draw. An engine that reads the auras before the dead leave play doubles the Deathrattle, and the knight dies. Level 3 is built on that.
4. **The same turn.** The scribe's death is a Sequence inside the turn's hazards phase. When the Sequence ends, `WIN` is written and the rules are reparsed, before the next hazard and before the win check. An engine that waits for the turn's own parse finds `BABA IS WIN` only after the win check, and wins a turn late.
5. **Neither.** Part I knows triggers; Part II knows words. A table in content says that the noun `DEATHRATTLE` names the trigger `deathrattle` and that the property `DOUBLE` means "twice, `max`". The code between the engines reads the table. The Expansion Drop adds `BATTLECRY` with two lines of data.
</details>
## Spec
From the course repository, copy the module's tests and levels into your workspace:
```sh
node tools/start-module.mjs 10 ~/my-engine
```
It copies `tests/test_10.*`, SpellGrid's cards into `cards/`, and the three levels into `levels/`. The tests load them from there.
### The harness
One constructor, `newSpellGrid(level, cards)` (Rust: `new_spell_grid(level, cards)`), returning a `SpellGrid`. It has every Part II function (`place`, `rules`, `activeRules`, `hasProperty`, `move`, `posOf`, `kindsAt`, `step`, `won`, `lost`), and:
| TypeScript | Rust |
|---|---|
| `cast(card, target?): void` | `cast(&mut self, card: &str, target: Option<Id>)` |
| `play(card, x, y): Id` | `play(&mut self, card: &str, x: i32, y: i32) -> Id` |
| `health(id)`, `hero(player)`, `nameOf(id)` | `health`, `hero`, `name_of` |
| `minionAt(x, y): Id \| null` | `minion_at(&self, x: i32, y: i32) -> Option<Id>` |
| `hand(): string[]` | `hand(&self) -> Vec<String>` |
| `log()`, `clearLog()` | `log`, `clear_log` |
| `setChooser(choose)` | `set_chooser(...)` |
| `withNaiveAuraTiming()` | `with_naive_aura_timing(&mut self)` |
- **The level format** gains `cards: [{ card, x, y }]` and `hand: [card]` (`course/schema/level.schema.json`). Each entry of `cards` is a minion in play for player 0 *and* a grid object at (x, y), whose kind is its card's `"noun"` in lower case: Powder Keg has `"noun": "KEG"`, so it stands on the grid as a `keg`, and `KEG IS YOU` moves it.
- **One id space.** A minion's id is its grid object's id. `posOf(keg)`, `hasProperty(keg, "MELT")` and `health(keg)` take the same id, and `log()` reports it. A hero or a card in hand gets an id no grid object has.
- **`cast(card, target?)`** casts a spell from player 0's hand, as one Sequence; `target` is a minion's id. **`play(card, x, y)`** plays a minion from the hand onto an empty tile, as one Sequence (Module 4's `play`: it enters the board, then its Battlecry), and returns its id. Both are errors for a card that isn't in the hand.
- **Casting is not a turn.** `won()` only changes in `step`.
- **`withNaiveAuraTiming()`** is a test hook for one test: it swaps the two steps at the end of the outermost Phase, so the Aura Update runs before the Death Creation Step.
The three seams:
1. **Rules become modifiers.** A rule in force `DEATHRATTLE IS DOUBLE` is a trigger multiplier like Baron's (`times: 2`, `stacking: "max"`), for both players' minions. `DEATHRATTLE IS NOT DOUBLE` caps Deathrattles at one activation, whatever multiplies them. A rule takes effect at the next Aura Update, like a minion's aura.
2. **Hazards mark, the death step removes.** A minion that a turn destroys (a hazard, `OPEN`/`SHUT`, `X IS NOT X`) is marked pending destroy, in one Sequence, and Module 3's Death Creation Step removes it. Its Deathrattle resolves.
3. **Grid changes reparse.** When a Sequence ends, the text its effects asked for is written (`spawnText`: "write the word on this minion's tile"), the minions that died leave the grid, and the rules are parsed again, before anything else.
SpellGrid's cards, in `course/10-spellgrid/cards/`. `start-module 10` copies them into your `cards/` with the course's other cards; earlier modules never see them.
| Card | Type | Stats | Noun | Text |
|---|---|---|---|---|
| Powder Keg | minion, 2 | 1/1 | KEG | Deathrattle: Deal 1 damage to all minions. |
| Bramble Wall | minion, 1 | 0/3 | BRAMBLE | |
| Thorn Hedge | minion, 1 | 0/2 | HEDGE | |
| Grave Warden | minion, 3 | 1/1 | WARDEN | Your minions trigger their Deathrattles twice. |
| Ember Moth | minion, 1 | 1/1 | MOTH | Taunt. Deathrattle: Deal 2 damage to the enemy hero. |
| Rune Scribe | minion, 2 | 1/1 | SCRIBE | Deathrattle: Write WIN on this tile. |
| Tin Knight | minion, 2 | 1/3 | KNIGHT | |
| Town Crier | minion, 1 | 1/1 | CRIER | Battlecry: Deal 1 damage to the enemy hero. (The Expansion Drop's.) |
| Spark | spell, 1 | | | Deal 1 damage to a minion. |
| Graft | spell, 2 | | | Assemble a Deathrattle minion. |
Whirlwind is Module 3's Hearthstone card. New words: the nouns `KEG`, `BRAMBLE`, `HEDGE`, `WARDEN`, `MOTH`, `SCRIBE`, `KNIGHT`, `DEATHRATTLE`, and the property `DOUBLE`.
> **How the real game differs**
> - SpellGrid is not a real game, and its cards are not Hearthstone cards. Grave Warden is Baron Rivendare's text on a 1-Health body, so that it dies to the same Whirlwind as the minion it would double. Baron is 1/7 ([Baron Rivendare](https://hearthstone.wiki.gg/wiki/Baron_Rivendare)).
> - Graft is Fleshshape's Assemble without the buff ([Fleshshape](https://hearthstone.wiki.gg/wiki/Fleshshape): "Assemble a Deathrattle minion. Give all minions in your hand +2/+2."). As in Module 5, the chooser makes every choice that Hearthstone makes at random, and the pool is every Deathrattle minion in `cards/`, not Hearthstone's curated pool ([Assemble](https://hearthstone.wiki.gg/wiki/Assemble)).
> - Baba Is You has no `DEATHRATTLE` or `DOUBLE`. They are SpellGrid's words.
### The tests
The full statements are in `course/10-spellgrid/scenarios.md`.
- `test_10_spatial_rule_feeds_interceptor`: seam 1. Placing `DOUBLE` completes `DEATHRATTLE IS DOUBLE`; a Spark on the keg then gives two Deathrattles.
- `test_10_not_cancels_spatial_modifier`: Predict question 2. One Deathrattle, with the Warden in play.
- `test_10_grid_and_card_doublers_take_max`: Predict question 1. Two, not four.
- `test_10_hazard_feeds_death_step`: seam 2. The log is exactly `Destroyed`, `DeathPhase`, `Triggered`, `Damaged`.
- `test_10_spawned_text_triggers_reparse`: seam 3. `BABA IS WIN` is a rule straight after the cast.
- `test_10_targeted_spell_needs_target`: Spark without a target is an error, and the card stays in the hand.
- `test_10_level_1_solution`, `test_10_level_2_solution`, `test_10_level_3_solution`: each level, played from a script to `won()`.
- `test_10_level_3_naive_would_differ`: level 3 with `withNaiveAuraTiming()`. Same inputs, and the knight dies.
### The levels
Three puzzles in `course/10-spellgrid/levels/`. Capitals are text, lower case are objects, `#` is a wall. You can play each one (see [Play it](#play-it)).
**Level 1, "Double-Tap".** The bramble (3 Health, `STOP`) is the only way to the flag. Whirlwind does 1 and the keg's Deathrattle 1 more: one short. Push `DOUBLE` up through the gap to finish `DEATHRATTLE IS DOUBLE` first. Hand: Whirlwind.
```text
BABA IS YOU FLAG IS WIN WALL IS STOP
BRAMBLE IS STOP DEATHRATTLE IS . . . .
# # # # # . # # #
. . . . . DOUBLE . . .
keg . . . . baba . . .
. . . . . # # # #
. . . . . bramble flag # #
```
**Level 2, "Spliced Corpse".** `BABA IS` ends on a lava tile, and `MOTH IS PUSH AND MELT` is written, but there is no moth. Graft one: a moth's noun with a Deathrattle that writes `WIN`. Play it, and push it into the lava. Hand: Graft.
```text
BABA IS YOU . LAVA IS HOT
MOTH IS PUSH AND MELT . .
WALL IS STOP . . . .
BABA IS lava . . baba .
. . . . . . .
. . . . . . .
```
**Level 3, "Simultaneous Paradox".** You are the Tin Knight (3 Health). The hedge (2 Health, `STOP`) blocks the flag. The keg and the Grave Warden are walled off. One Whirlwind is the whole solution, if your engine reads the auras at the right moment. Hand: Whirlwind.
```text
KNIGHT IS YOU . FLAG IS WIN
WALL IS STOP . HEDGE IS STOP
# # # # # # #
. . . # # # #
knight . . . hedge flag #
. keg warden . # # #
```
## Build
Make the ten tests pass, and keep Modules 1–9 green. The shape to aim for:
```text
Part I: the card engine Part II: the grid engine
+-----------------------------+ +------------------------------+
| World, Sequence, Death | | Board, Lexicon, parse, |
| Creation Step, auras, | | resolve, TURN, hazards |
| activations | | |
+-----------------------------+ +------------------------------+
^ | ^ | ^ ^
(1) | (3) | events | (2) destroy | rules | (2) | (3) write text,
auras | v | Sequence v in force| victims | remove the dead,
+----------------------------------------------------------------------------+
| SpellGrid (the only code that knows both) |
| (1) rules in force --> rule auras --> Aura Update |
| (2) a turn's victims --> minions: one Destroy Sequence; others: removed |
| (3) Sequence ends --> TextSpawned: write; Destroyed: remove; reparse |
+----------------------------------------------------------------------------+
```
- **Neither engine imports the other.** Part I never names a tile; Part II never names a card or a Health. Everything that crosses goes through the code in the middle, and only in the three ways in the diagram. If you catch yourself adding `if this object is a minion` to the grid's hazards, or `where is this minion on the grid` to the card engine, the seam is in the wrong place.
- **Seam 1: rules are aura data.** After every reparse, read the rules in force. Each rule whose subject is a trigger word and whose complement is a multiplier word becomes an aura, the same data as Baron's. Hand them to Part I as a list of auras that no minion carries, and let the next Aura Update put them in the snapshot beside the minions' auras. `activations` stays the one routine that counts.
- **Negation is a cap.** A negated rule can't be cancelled by resolution, because it *is* the canceller. Make it a modifier of its own kind that limits the count, and let `activations` apply it last.
- **Which words are triggers is data.** Put the table (`DEATHRATTLE` → `deathrattle`, `DOUBLE` → twice, `max`) in content.
- **Seam 2: a destroy hook.** Module 9's turn removes whatever movement, conversion and hazards destroy. Give the turn a function to call with the victims instead, with "remove them" as the default. SpellGrid's function removes text and things, and sends minions to Part I as one Sequence that marks them all pending destroy. That keeps Module 9's "all objects destroyed in the same step are destroyed simultaneously": they share one Death Creation Step.
- **Seam 3: freeze the grid during a Sequence.** Part I has no grid, so `spawnText` can't write one. It records an event, `TextSpawned`, and the code in the middle reads the Sequence's events when it ends: write each word on its minion's tile (the minion is still on the grid, because nothing leaves the grid mid-Sequence), then remove each minion with a `Destroyed` event, then reparse. Reparsing here, inside the turn, is what makes level 2 win in the same step.
- **The test hook is not engine content.** Module 9 made the turn a list of phases. Do the same for the end of the outermost Phase: a list `[Death Creation Step, Aura Update]`, read from the world. The harness hook sets the reversed list. The engine has no "naive" branch; the order is data, and only a test changes it.
<details><summary>Hint 1 — nudge</summary>
Write the code in the middle as one file that owns a world and a level, and keeps a map from grid ids to entities. Then write three small functions, one per seam, and call them from `place`, `step`, `cast` and `play`. Start with seam 1: `test_10_spatial_rule_feeds_interceptor` needs nothing else.
</details>
<details><summary>Hint 2 — concept</summary>
- **Ids.** Create the minion in Part I and the object in Part II together, and record both ways: grid id → entity, entity → grid id. Give every other entity (heroes, hand cards) an id from the board's counter that no object will have, so one number never means two things. Translate the log's entity ids into grid ids in the harness.
- **Seam 1.** `ruleAuras(activeRules, table)`: for each rule, look up the subject in the trigger table and the complement in the multiplier table; positive gives `triggerMultiplier`, negated gives `triggerCap` with `times: 1`. Store the list in the world, run an Aura Update. In `activations`, collect the caps per trigger and return `min(cap, max + extra)`.
- **Seam 2.** `Level.step(dir, destroy)`. In SpellGrid's `destroy`, split the ids: not a card → remove now; a card → collect. If any cards were collected, bring the rule auras up to date (this turn's movement may have changed the rules), then run one `destroy` Sequence for all of them.
- **Seam 3.** Note the log's length, run the Sequence, take the new events. `TextSpawned { source, word }` → place `text:WORD` on the source's tile. `Destroyed { id }` → remove its object. Then reparse, then seam 1 again.
- **Playing a card.** `play(card, x, y)` places the object first, then runs Module 4's `play` action: a Battlecry that writes text needs a tile.
</details>
<details><summary>Hint 3 — pseudo-code</summary>
```text
ruleAuras(rules, table):
auras = []
for r in rules:
trigger = table.triggerWords[r.subject]; mult = table.multiplierWords[r.complement]
if trigger and mult:
auras += r.negated ? cap(trigger, 1) : multiplier(trigger, mult.times, mult.stacking)
return auras
activations(owner, on):
max = 1; extra = 0; cap = infinity
for a in snapshot where a.owner == owner and a.trigger == on:
cap: cap = min(cap, a.times)
max-stacking multiplier: max = max(max, a.times)
additive multiplier: extra += a.times - 1
return min(cap, max + extra)
END_OF_PHASE = [deathCreation, auraUpdate] // the harness hook reverses it
destroy(level, ids): // seam 2, handed to level.step
for id in distinct ids still on the board:
if id is a minion: minions += entity(id) else: level.board.remove(id)
if minions: syncRules(); sequence(Destroy(minions))
sequence(action): // seam 3
from = log.length; runSequence(world, action)
for e in log[from..]: if e is TextSpawned: place text:e.word on tile of e.source
for e in log[from..]: if e is Destroyed: remove object of e.id
level.reparse(); syncRules()
syncRules(): // seam 1
world.setRuleAuras(ruleAuras(level.activeRules(), table)); auraUpdateOther(world)
```
</details>
### Play it
The reference ships a command line, kept thin over the harness. From a workspace with `cards/` and `levels/`:
```sh
cargo run --bin spellgrid -- levels/l1-double-tap.json # Rust
npx tsx src/cli.ts levels/l1-double-tap.json # TypeScript
```
`w`, `a`, `s`, `d` play a turn; `c <card> [x y]` casts a spell (at the minion on that tile, if given) or plays a minion (onto that empty tile); `q` quits. Graft asks for its base, donor and half; answer with a number or a name. Writing your own is optional: the course's tests don't call it.
## Name it
The pattern is an **integration seam**: two subsystems that each keep their own model, joined by a narrow, explicit interface that translates between the models, in one place. Each seam carries one kind of thing, in one direction, at one moment. Rules go from the grid to the cards as aura data, at an Aura Update. Victims go from the grid to the cards as one destroy Sequence, during the turn's phase. Grid changes go from the cards to the grid as events, when a Sequence ends.
Two earlier patterns do most of the work. The **event log** from Module 2 is what lets Part I ask for a grid change without having a grid: it records `TextSpawned` and the seam reads it later. Robert Nystrom's description of an event queue fits: "This decouples the sender from the receiver both statically and in time." ([Game Programming Patterns: Event Queue](https://gameprogrammingpatterns.com/event-queue.html)). And the **interceptor** from Module 4, one routine that counts activations from data, is what lets a rule on the grid double a Deathrattle without a line of new resolution code. The rule is compiled into the same data Baron is:
```rust
/// Seam 1: the auras the rules in force give. `T IS M` (T a trigger word, M a
/// multiplier word) multiplies T's triggers; `T IS NOT M` caps them at one activation,
/// so it cancels a card's multiplier too, as `X IS NOT Y` cancels every `X IS Y`.
pub fn rule_auras(rules: &[Rule], words: &SeamWords) -> Vec<Aura> {
let mut auras = Vec::new();
for rule in rules {
let (Some(&trigger), Some(&m)) = (
words.trigger_words.get(&rule.subject),
words.multiplier_words.get(&rule.complement),
) else {
continue;
};
auras.push(if rule.negated {
Aura::TriggerCap { trigger, times: 1 }
} else {
Aura::TriggerMultiplier {
trigger,
times: m.times,
stacking: m.stacking,
}
});
}
auras
}
```
```ts
/**
* Seam 1: the auras the rules in force give. `T IS M` (T a trigger word, M a multiplier
* word) multiplies T's triggers; `T IS NOT M` caps them at one activation, so it
* cancels a card's multiplier too, as `X IS NOT Y` cancels every `X IS Y`.
*/
export function ruleAuras(rules: readonly puzzle.Rule[], words: SeamWords): Aura[] {
const auras: Aura[] = [];
for (const rule of rules) {
const trigger = words.triggerWords[rule.subject];
const multiplier = words.multiplierWords[rule.complement];
if (trigger === undefined || multiplier === undefined) continue;
auras.push(
rule.negated
? { type: "triggerCap", trigger, times: 1 }
: { type: "triggerMultiplier", trigger, ...multiplier },
);
}
return auras;
}
```
The question this module had to settle: **how does a rule on the grid combine with a card?** The reference's answer is that a grid modifier is data of the same kind, with the same `stacking` field, so the rules for combining are Module 4's and nothing new. `DOUBLE` is `stacking: "max"` because it reads like Baron, and two Barons don't quadruple. That makes Warden + `DEATHRATTLE IS DOUBLE` two activations, not four. A word with `"additive"` in the table would add instead; the engine wouldn't change. Negation is the one new piece of data, a cap, because `NOT` in Module 7 overrides wherever it is written, and the card side had no way to say "never more than once".
Level 3's trap is the end-of-Phase order, and the reference writes that order down as data, like Module 9's turn:
```rust
/// The end of the outermost Phase, in Hearthstone's order: the dead leave play first,
/// then the auras are read again, so a minion that just died multiplies nothing in
/// the Death Phase that follows.
pub const END_OF_PHASE: [EndOfPhaseStep; 2] =
[EndOfPhaseStep::DeathCreation, EndOfPhaseStep::AuraUpdate];
```
```ts
/**
* The end of the outermost Phase, in Hearthstone's order: the dead leave play first,
* then the auras are read again, so a minion that just died multiplies nothing in
* the Death Phase that follows.
*/
export const END_OF_PHASE: readonly EndOfPhaseStep[] = ["deathCreation", "auraUpdate"];
```
`withNaiveAuraTiming()` in the harness sets the same two steps reversed. The naive engine is not a branch in the engine; it is one different list, and the test shows what that list costs: the knight dies.
> **How the real game differs**
> - Hearthstone has no grid, and Baba Is You has no Health or Deathrattles. Everything about how they combine is SpellGrid's design.
> - Hearthstone's end of the outermost Phase has an Aura Update (Health/Attack) before the Death Creation Step ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The course has no Health/Attack auras, so `END_OF_PHASE` leaves it out, as Module 4 did.
> - Baba Is You reparses "when any parsable object changes position or is destroyed" ([Rule](https://babaiswiki.fandom.com/wiki/Rule)). SpellGrid reparses after each Sequence whether or not it changed the grid. That is cheaper to reason about and gives the same rules.
> - A minion that a conversion turns into something else (`KEG IS ROCK`) keeps its card in the reference: the object changes kind, the minion stays in play. No test or level does this, and a real design would have to decide.
> - A minion that a card effect summons (Harvest Golem's Damaged Golem) enters play but not the grid. No SpellGrid card summons.
> - Module 8 planned every `YOU` object's move "under the rules in force before the first". In SpellGrid, a minion destroyed during movement (`OPEN`/`SHUT`) dies in a Sequence on the spot, and seam 3 reparses when it ends, so the `YOU` objects that move after it use the new rules. The reference accepts this on purpose: a Sequence's grid changes always reparse at once, wherever the Sequence runs, and no level or test depends on the difference. A design that wanted Module 8's rule exactly would hold the reparse until movement ends.
## Expansion Drop
A new trigger word: `BATTLECRY`. With it, `BATTLECRY IS DOUBLE` on the grid does what Brann Bronzebeard's "Your Battlecries trigger twice." does ([Brann Bronzebeard](https://hearthstone.wiki.gg/wiki/Brann_Bronzebeard)), the Module 4 Expansion Drop. The test plays a Town Crier ("Battlecry: Deal 1 damage to the enemy hero.", already in your `cards/`) under the rule.
The constraint, as always: **do not edit anything under `src/engine/`**. The harness doesn't change either.
1. Commit your workspace and note the commit: `git add -A && git commit -m "module 10 done"`.
2. Copy the expansion test for your track from `course/10-spellgrid/expansion/tests/rust/test_10x.rs` or `course/10-spellgrid/expansion/tests/ts/test_10x.test.ts` into your workspace's `tests/`. Run it and watch it fail.
3. Teach SpellGrid the word.
4. When the tests pass, run the check from the course repository:
```sh
node tools/expansion-check.mjs ~/my-engine HEAD
```
The full scenario, `test_10x_battlecry_is_double`, is in `course/10-spellgrid/expansion/README.md`.
<details><summary>Discussion: what this drop measures</summary>
If the seam reads a table, the drop is two lines of data and no code: `{ "name": "BATTLECRY", "role": "noun" }` in `words.json`, so the parser can read it, and `"BATTLECRY": "battlecry"` in `seams.json`, so the seam knows what it names. That is the reference answer, in `course/10-spellgrid/expansion/solution/`. The Battlecry is doubled by Module 4's `activations`, which has counted Battlecries since Brann.
Shapes that fail this drop:
- **`if rule == "DEATHRATTLE IS DOUBLE"` in the seam.** The seam then knows one rule, and `BATTLECRY` is a second branch in `src/engine/`. Make the words data, as a normal commit, then redo the drop.
- **The seam writes the count, not an aura.** If the grid rule sets a "Deathrattles fire twice" flag that the Deathrattle code reads, Battlecries need their own flag, in the engine. Compile rules into the same aura data cards use.
- **Minions played straight onto the grid, skipping Part I's `play`.** Then the Battlecry never triggers at all, rule or no rule.
If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1.
</details>
## Further reading
- The Hearthstone Wiki's [Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook), on Aura Updates and the Death Creation Step: the source of Level 3's answer, and of Module 4's.
- The Baba Is You Wiki's [NOT](https://babaiswiki.fandom.com/wiki/NOT), [Rule](https://babaiswiki.fandom.com/wiki/Rule) and [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations) pages: the other half of each seam.
- Robert Nystrom, [Game Programming Patterns: Event Queue](https://gameprogrammingpatterns.com/event-queue.html). Its warning, "you have to be careful not to assume the current state of the world reflects how the world was", is why SpellGrid writes a dead minion's text before it takes the minion off the grid.
- Celia Wagar, [Card Game Design as Systems Architecture](https://critpoints.net/2023/05/26/card-game-design-as-systems-architecture/): "Trigger conditions are lifecycle hooks for cards." Read it after this module, as a designer's view of the same course.
### The last question
Module 4 had Baron Rivendare modify Deathrattles. Module 7 had `BABA IS NOT YOU` modify `BABA IS YOU`. Both are one rule changing what another rule does. Compare them: where does each modifier live, when is it read, what does it change, and what happens when two of them disagree? Then say what SpellGrid had to decide to put them in one game.
<details><summary>Discussion</summary>
Both are **rules about rules**, and both engines keep them out of the rule they modify. Baron's text doesn't touch the Deathrattle: Module 4's `activations` reads the aura snapshot when the queued Deathrattle starts and runs it twice. `BABA IS NOT YOU` doesn't touch `BABA IS YOU`: Module 7's resolution pass reads every parsed rule and drops the ones a negation cancels. In both, the modified rule is the same data as before, and one routine decides.
The differences are where the design is:
- **When the modifier is read.** Baron is read from a snapshot taken at fixed points (after a Death Creation Step, on a summon), so a Baron that dies mid-Phase still counts until the next update, and one that dies in the same step as the Deathrattle doesn't count. A Baba rule is read after a reparse, and the course reparses at fixed points in the turn. Both engines answer "when is the world looked at?" with a schedule, not with "whenever someone asks". Level 3 and Module 9's lava test are the same lesson, one per engine.
- **What it changes.** Baron changes *how many times* something happens: a number, combined with other numbers by `stacking`. `NOT` changes *whether* a rule is in force: a yes or no, and no wins. That is why, in one game, the grid's negation became a cap: a yes/no from Part II meeting a count in Part I.
- **Who wins a disagreement.** Two Barons don't stack (`max`); `X IS NOT Y` beats any number of `X IS Y`. Hearthstone has no "not doubled" card to disagree with Baron, so SpellGrid had to choose, and chose Baba's answer: negation beats every multiplier, from cards or from the grid.
What SpellGrid had to decide: that a rule on the grid is the same kind of data as a card's aura (so `stacking` applies, and Warden + `DOUBLE` is two); that it enters the snapshot at the same moments as a card's (so it obeys the Aura Update schedule); and that `NOT` from the grid caps card multipliers too. None of those came from either game. Each is one line of data or one line in `activations`, and each is a test.
</details>