- 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]>
33 KiB
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). Module 4 built the machinery that makes that true, from the 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)
- "BABA IS NOT YOU prevents BABA from having the property YOU, overriding any BABA IS YOU rule." (IS)
And Module 9's turn, from Order of Operations: "Deletion occurs after each step that removes objects, but all objects destroyed in the same step are destroyed simultaneously." The 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.
- A grid holds the words
DEATHRATTLE IS DOUBLE, and a Powder Keg (1/1, nounKEG, "Deathrattle: Deal 1 damage to all minions.") stands on it next to a Bramble Wall (0/3, nounBRAMBLE). You cast Whirlwind. The keg dies, and its Deathrattle goes off twice: the words on the grid did what Baron does. - The words
KEG IS YOU AND MELTandLAVA 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. - A Rune Scribe ("Deathrattle: Write WIN on this tile.") stands on the empty end of
BABA IS. It dies.WINappears where it stood, andBABA IS WINis 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.
DEATHRATTLE IS DOUBLEis 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?- Now
DEATHRATTLE IS NOT DOUBLEis on the grid too, and the Warden is still in play. How many times? - 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? - The scribe from Observe dies because a turn melted it in lava.
BABA IS YOUis on the board. Is the level won in that same turn, or the next one? - Which of the two engines should know that the word
DEATHRATTLEmeans Deathrattles?
Discussion
- 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 neededstackingbecause 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. - Once. In Module 7,
X IS NOT YoverridesX IS Ywherever it is written. SpellGrid gives the negated rule the same reach:DEATHRATTLE IS NOT DOUBLEcaps Deathrattles at one activation, whatever multiplies them, card or rule. The ruleDEATHRATTLE IS DOUBLEis 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. - 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.
- The same turn. The scribe's death is a Sequence inside the turn's hazards phase. When the Sequence ends,
WINis 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 findsBABA IS WINonly after the win check, and wins a turn late. - Neither. Part I knows triggers; Part II knows words. A table in content says that the noun
DEATHRATTLEnames the triggerdeathrattleand that the propertyDOUBLEmeans "twice,max". The code between the engines reads the table. The Expansion Drop addsBATTLECRYwith two lines of data.
Spec
From the course repository, copy the module's tests and levels into your workspace:
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 }]andhand: [card](course/schema/level.schema.json). Each entry ofcardsis 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 akeg, andKEG IS YOUmoves it. - One id space. A minion's id is its grid object's id.
posOf(keg),hasProperty(keg, "MELT")andhealth(keg)take the same id, andlog()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;targetis a minion's id.play(card, x, y)plays a minion from the hand onto an empty tile, as one Sequence (Module 4'splay: 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 instep. 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:
- Rules become modifiers. A rule in force
DEATHRATTLE IS DOUBLEis a trigger multiplier like Baron's (times: 2,stacking: "max"), for both players' minions.DEATHRATTLE IS NOT DOUBLEcaps Deathrattles at one activation, whatever multiplies them. A rule takes effect at the next Aura Update, like a minion's aura. - 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. - 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).
- Graft is Fleshshape's Assemble without the buff (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).- Baba Is You has no
DEATHRATTLEorDOUBLE. 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. PlacingDOUBLEcompletesDEATHRATTLE 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 exactlyDestroyed,DeathPhase,Triggered,Damaged.test_10_spawned_text_triggers_reparse: seam 3.BABA IS WINis 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 towon().test_10_level_3_naive_would_differ: level 3 withwithNaiveAuraTiming(). 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).
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.
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.
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.
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:
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 minionto the grid's hazards, orwhere is this minion on the gridto 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.
activationsstays 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
activationsapply 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
spawnTextcan'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 aDestroyedevent, 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.
Hint 1 — nudge
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.
Hint 2 — concept
- 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 givestriggerMultiplier, negated givestriggerCapwithtimes: 1. Store the list in the world, run an Aura Update. Inactivations, collect the caps per trigger and returnmin(cap, max + extra). - Seam 2.
Level.step(dir, destroy). In SpellGrid'sdestroy, 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 onedestroySequence for all of them. - Seam 3. Note the log's length, run the Sequence, take the new events.
TextSpawned { source, word }→ placetext:WORDon 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'splayaction: a Battlecry that writes text needs a tile.
Hint 3 — pseudo-code
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)
Play it
The reference ships a command line, kept thin over the harness. From a workspace with cards/ and levels/:
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). 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:
/// 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
}
/**
* 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:
/// 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];
/**
* 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). The course has no Health/Attack auras, so
END_OF_PHASEleaves it out, as Module 4 did.- Baba Is You reparses "when any parsable object changes position or is destroyed" (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
YOUobject'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 theYOUobjects 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), 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.
- Commit your workspace and note the commit:
git add -A && git commit -m "module 10 done". - Copy the expansion test for your track from
course/10-spellgrid/expansion/tests/rust/test_10x.rsorcourse/10-spellgrid/expansion/tests/ts/test_10x.test.tsinto your workspace'stests/. Run it and watch it fail. - Teach SpellGrid the word.
- When the tests pass, run the check from the course repository:
node tools/expansion-check.mjs ~/my-engine HEAD
The full scenario, test_10x_battlecry_is_double, is in course/10-spellgrid/expansion/README.md.
Discussion: what this drop measures
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, andBATTLECRYis a second branch insrc/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.
Further reading
- The Hearthstone Wiki's 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, Rule and Order of Operations pages: the other half of each seam.
- Robert Nystrom, Game Programming Patterns: Event Queue. 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: "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.
Discussion
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.NOTchanges 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 Ybeats any number ofX 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.