I1: run-scenario and both SpellGrid CLIs check moves first and treat any other engine error as fatal (same text in both tracks); trigger blocks close on error (try/finally, catch_unwind in Rust); unknown summons rejected. I2, I3, M1-M7, T3, T9a-c, T10a-b. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
51 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 four seams and nowhere else, and every test in this module checks one seam, or a level that needs several.
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 Baron doesn't stack ("max": "Rivendare's ability is not duplicated or multiplied by having more than one Baron Rivendare in play", Baron Rivendare) 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 ten 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, stackAt, 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 four 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. - Summoned minions enter the grid. In the same pass, after the text and before the dead leave: a minion that another minion's ability summoned (Clay Golem's Deathrattle: "Summon a Clay Shard.") becomes a grid object on its summoner's tile, if its card has a
"noun". It gets the newest id, and the reparse that follows already names it. A minion with no noun (Harvest Golem's Damaged Golem), or one a spell summons, is in play but not on the grid.
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.) |
| Clay Golem | minion, 2 | 2/2 | GOLEM | Deathrattle: Summon a Clay Shard. |
| Clay Shard | minion, 1 | 1/1 | SHARD | |
| 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, GOLEM, SHARD, 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_summoned_minion_enters_grid: seam 4. The Clay Golem dies; its Clay Shard stands on the golem's tile, with the newest id, andSHARD IS PUSHnames it straight after the cast.test_10_summon_without_noun_stays_off_grid: seam 4's other half. A grafted Clay Golem with Harvest Golem's Deathrattle summons a Damaged Golem, which has no noun: it is in play, and the golem's tile is empty.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_4_solutiontotest_10_level_10_solution: levels 4 to 10, each played from the solver's shortest solution towon(), with one check on the way that the level's idea happened.test_10_level_3_naive_would_differ: level 3 withwithNaiveAuraTiming(). Same inputs, and the knight dies.
The levels
Ten puzzles in course/10-spellgrid/levels/. Capitals are text, lower case are objects (a minion by its noun), # is a wall, and + joins two objects on one tile. You can play each one (see Play it). Levels 1 to 3 are worked through below and in the tests. Levels 4 to 10 each turn one idea from the course into a puzzle; here you get the idea, not the solution (that is in the tests, if you want 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 . # # #
Level 4, "Crossed Out". DEATHRATTLE IS DOUBLE is already on the board, and a NOT sentence cancels it (Module 7). The hedge has 2 Health; the keg's Deathrattle does 1. Hand: Spark.
BABA IS YOU . FLAG IS WIN
HEDGE IS STOP . WALL IS STOP
# # # # # # #
. . baba . . # flag
DEATHRATTLE IS NOT DOUBLE . . hedge
IS . . . . . #
DOUBLE keg . . . . #
Level 5, "Sealed Keg". KEG IS FLAG would make a flag, but KEG IS KEG locks the keg (Module 7), and the lock is out of reach. What does KEG IS NOT KEG do to a lock? Hand: empty.
BABA IS YOU . WALL IS STOP
KEG IS KEG . KEG IS FLAG
FLAG IS WIN . . . .
# # # # # # #
KEG IS . KEG . . .
. keg . NOT . baba .
. . . . . . .
Level 6, "Melt Before Defeat". You are the Tin Knight. A keg stands on a shard that is DEFEAT, and the only way to the flag goes through that tile. Which hazard runs first, and what can a Deathrattle do in between? (Stress it question 1.) Hand: empty.
KNIGHT IS YOU . FLAG IS WIN
WALL IS STOP . SHARD IS DEFEAT
KEG IS PUSH AND MELT . .
# # # # # # #
LAVA IS . . . . .
. . HOT . # # #
knight . . keg+shard lava flag #
Level 7, "Clay Key". The door needs a key that is OPEN, and there is nothing to be one but the shard a Clay Golem leaves behind (seam 4). Hand: Spark, Spark.
BABA IS YOU . FLAG IS WIN
WALL IS STOP . SHARD IS PUSH
DOOR IS SHUT AND STOP . .
# # # # # # #
SHARD IS . . . # flag
. . OPEN . golem . door
. baba . . . # #
Level 8, "Nothing Dies". You need a shard, and nothing on this level can kill a minion. Graft can replace either half of a card's text (Module 5). Hand: Graft.
BABA IS YOU . .
WALL IS STOP . .
# # # # #
SHARD IS . WIN baba
. . . . .
Level 9, "Skull River". Two skulls deep, two rocks, and no PULL to bring a rock back: one push moves a whole chain (Module 8), and SINK takes whatever shares its tile at the end of the turn (Module 9). Hand: empty.
BABA IS YOU . WALL IS STOP
ROCK IS PUSH . FLAG IS WIN
SKULL IS DEFEAT . . . .
# # # # # # #
SKULL . # # skull skull #
IS baba rock rock skull skull flag
. SINK . # skull skull #
Level 10, "Friendly Fire". Whirlwind and the keg clear the hedge, as in level 3. But you are a Clay Shard with 1 Health. Hand: Whirlwind.
FLAG IS WIN . WALL IS STOP
HEDGE IS STOP . . . .
# # # # # # #
. . . . . # flag
SHARD IS YOU . . . hedge
KNIGHT . keg . . knight .
. shard . . . . .
Build
Make the nineteen 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)(4) | events | (2) destroy | rules | (2) | (3) write text,
auras | v | Sequence v in force| victims | (4) place summoned,
| | | | | | (3) 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 |
| (4) --> Summoned by a minion on the grid: place, if a noun |
| (3) --> 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 four 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. - Seam 4: summons read the same events. Part I records who summoned each minion (
Summonedcarries the summoner inside the engine; the course's log doesn't show it). In the same pass as seam 3, after the text and before the removals, place each summoned minion whose card has a noun on its summoner's tile. The dead are still on the grid at that point, so a Deathrattle's summoner is found where it died. Go through the events in order, so a minion summoned earlier in the same Sequence can itself be a summoner. A summoned minion that dies in the same Sequence is placed and then removed: it still gets an id from the board, as every minion does. Text comes first, so a minion summoned in a Sequence writes nothing in it. - 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 a small function per seam (seams 3 and 4 share one pass), 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. - Seam 4. When Part I records
Summoned, it can also record the minion whose trigger is resolving: that is the summoner (none for a spell). Between the text and the removals,Summoned { id, by }with abyon the grid and a card with a noun → place an object of that noun onby's tile and link it toid. - 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): // seams 3 and 4
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..]: // seam 4
if e is Summoned and e.by is on the grid and card(e.id) has a noun:
link(e.id, place noun on tile of e.by)
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. It reads cards/ and levels/ from the directory it runs in, so tools/play-spellgrid.mjs stages the reference with the course's cards and levels into a temporary directory and starts it. From the course repository:
node tools/play-spellgrid.mjs rust l1 # Rust: cargo run --bin spellgrid -- levels/l1-double-tap.json
node tools/play-spellgrid.mjs ts l1 # TypeScript: npx tsx src/cli.ts levels/l1-double-tap.json
Or play in a browser: the course website's Play SpellGrid page has all ten levels. It runs the course's newest TypeScript reference, copied unmodified, with the course's cards and these levels; the page only draws the grid and turns clicks and keys into harness calls (step, cast, play, the chooser). It shows the rules in force, each minion's Health and the log tree of your last input. SpellGrid has no undo (see Module 13), so the page's Undo replays every input but the last on a fresh level. The newest reference is a later module's (Module 12's today), not this one's. On these ten levels it plays the same game: none of them writes Module 11's conditions, SpellGrid has no input for Module 12's combat, and every later reference passes this module's level tests.
On the command line, the level is l1 to l10 (or a path to a level file of your own). --dir <dir> stages into a directory you choose and keeps it, and --stage-only prints the command without running it. The tool prints where it staged, so you can run the command there again.
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. After a command that ran Part I Sequences, it prints their events under Log: as an indented tree (logTree(), from Module 2). Writing your own is optional: the course's tests don't call it. The Rust and the TypeScript command lines print the same text for the same input, and node tools/diff-tracks.mjs checks that on random levels and scripts.
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. Summoned minions go the same way, in the same pass: a fourth seam on the same events, because it carries a different thing (a minion, not a word) and has its own question (which tile).
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 Summoned, and the seams read them 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.- Where a summoned minion stands is SpellGrid's choice: on the tile of the minion whose ability summoned it, even if that minion has just died there. A spell has no tile, so a minion a spell summons enters play but not the grid; so does a minion whose card has no noun (Harvest Golem's Damaged Golem). Neither game has minions on a grid to copy.
- 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.
The designer capstone asks for a larger drop of the same kind, from the designer's side: three cards and one word as data, five predictions about what they do, run with tools/run-scenario.mjs on this module's reference, and each surprise explained by a pattern the course named. It needs no engine of your own, and it has a worked example.
Stress it
Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.
- In one turn, a Powder Keg melts in lava and a Rune Scribe steps onto a skull that is
DEFEAT(both minions areYOU). Do their Deathrattles resolve in one Death Phase or two? In what order? Now both melt in the same lava. Same questions.
Discussion
Two hazards: two Sequences, one per hazard, in the hazards' order. HOT/MELT runs before DEFEAT, so the keg dies, its Deathrattle resolves (and may kill more), the grid is reparsed, and only then does DEFEAT run and the scribe die. One hazard: one Sequence, one Death Creation Step, one Death Phase, and the two Deathrattles resolve in order of play.
Module 9's Stress it asked whether the order of two hazards could be observed, and found that in the grid engine alone it took an object with two properties. Here any two minions do it. The seam turned an internal order into a rule players can see, without a line of code that mentions the order. When you join two systems, list each one's "it doesn't matter which goes first" decisions and ask again; the join is where they start to matter.
It also shows what seam 2 chose. "All objects destroyed in the same step are destroyed simultaneously" became "share one Death Creation Step", which is Part I's meaning of simultaneous. Objects destroyed in different steps of the same turn got Part I's meaning of sequential. Neither game said what a turn-and-Sequence hybrid should do. Write a test for whichever answer you chose.
DEATHRATTLE IS NOT DOUBLEis on the grid, and two Test Chorus ("Your minions trigger their Deathrattles an additional time.") are in play. A keg dies. How many activations in the reference? Is that what the sentence says?
Discussion
One. The negated rule compiles to a cap of one activation, and the cap is applied last, whatever was multiplying.
Read literally, though, the board says only that Deathrattles are not doubled. Nothing on it says "additional activations don't count", and a player could fairly expect three. The reference's reading is "NOT beats everything of this kind"; the literal reading is "NOT removes the DOUBLE contributions and leaves the rest". Under the second, the negation would compile to something that cancels multipliers whose word is DOUBLE, and the cap would not exist.
Both are coherent, and the difference is the old one from Module 7 in new clothes: is X IS NOT Y about the sentence X IS Y, or about the fact Y? In Part II the two coincide, because the only way to have a property is a sentence that grants it. In SpellGrid a card can grant the fact with no sentence, and the design has to pick. The lesson's last discussion says SpellGrid "chose Baba's answer". This question is the case where you can see that it was a choice.
- A Clay Golem stands on the grid and dies. Seam 4 puts its Clay Shard on the golem's tile. Which choices did that seam make that neither game made for it? What would change if the tile were occupied, or the token had no noun?
Discussion
Neither engine could decide alone. Part I knows a minion was summoned, and since Module 3 it even knows where on the board, as a neighbour. Part II knows tiles. So the reference built it as a fourth seam that looks like the third: Part I records Summoned with the summoner, and the code in the middle reads the event when the Sequence ends and places an object (engine/spellgrid.*, test_10_summoned_minion_enters_grid).
The questions the seam had to answer, none of which either game answers:
- Which tile? The reference uses the summoner's tile: for a Deathrattle, where the dead minion stood, as Rune Scribe's
spawnTextdoes. The dead minion's object is removed in the same pass, so the seam orders the two as seam 3 does for text: place first, remove second. Another design could pick the nearest empty tile, or ask the player. - Occupied? Grid objects stack, so nothing stops it: the shard shares the tile with whatever is there. Hearthstone's board has a size limit; a grid tile doesn't. A design that wanted one minion per tile would have to say what happens to the token that doesn't fit.
- What noun? The token needs a
"noun"in its card data to be anything on the grid. A card with no noun is a minion no rule can name, so the reference keeps it off the grid (test_10_summon_without_noun_stays_off_grid): it can't beYOU,PUSHorMELT. That may even be a feature. Still open: a minion with a noun that a spell summons has no summoner's tile, and stays off the grid too. Is that right for a spell that says where? - Order of play versus age. The token's object is the newest on the grid and its minion the newest in play. Two different counters, as in Module 1, now in two engines. They agree here because the seam places tokens in summon order; a design that placed them in any other order would make them disagree.
The point of the exercise is the list, not the code. A seam is cheap to write once you know what crosses it and when, and those are design questions.
- The course ships ten levels, each with one intended solution. Are they sound: is each solvable, and only in the intended way? How would you know?
Discussion
You search. A level is a small deterministic system with a handful of inputs per position, so a breadth-first search over inputs finds every position it can reach. The course's tool does this with the reference engine:
node tools/solve-spellgrid.mjs l1
node tools/solve-spellgrid.mjs l1 --max 40 --never-rule "DEATHRATTLE IS DOUBLE"
node tools/solve-spellgrid.mjs l3 --naive
What it reports for the shipped levels:
-
Level 1 has no solution shorter than 10 inputs, the length of the scripted one. With every position where
DEATHRATTLE IS DOUBLEis in force thrown away, the search runs out of positions (622 of them, the deepest 17 inputs in) without winning. The level can't be solved without the rule it was built to teach. -
Level 2 has no solution shorter than 4 inputs, and among some 18,600 positions within 4 inputs exactly one wins: the intended graft, played and pushed into the lava.
-
Level 3 is solved in 6 inputs, and under
--naivethe search exhausts all 11 reachable positions with no win. "The knight dies" is not only what the one scripted test shows; no other line of play saves the naive engine. -
Levels 4 to 10 were designed against the solver: each was changed until its shortest solution was the intended one and the search, with one rule forbidden, ran out of positions. The forbidden rule is one the player has to form:
DEATHRATTLE IS DOUBLE(level 4),KEG IS NOT KEG(5),LAVA IS HOT(6),SHARD IS OPEN(7),SHARD IS WIN(8),SKULL IS SINK(9),KNIGHT IS YOU(10). For levels 4, 5, 7 and 10 that rule is the level's idea. For levels 6, 8 and 9 it is only one ingredient of it: the search proves that the lava has to beHOT(6), thatSHARD IS WINhas to be finished (8), and that the skulls have to beSINK(9). It doesn't prove the rest of the idea: that the keg melts before the shard'sDEFEATis checked, that the shard comes from Graft's top-half swap, that the two rocks move as one chain.
Each of these claims is written down in course/10-spellgrid/levels/proofs.json, one entry per level ({ "level": "l4-crossed-out", "shortest": 7, "neverRule": "DEATHRATTLE IS DOUBLE" }, or "naiveUnsolvable": true for level 3), and node tools/verify-levels.mjs (npm run verify:levels) checks every one with the solver. The solver and verify-levels, like play-spellgrid and run-scenario, stage this module's reference, so the proofs are about the Module 10 engine. The play page, diff-tracks and mutation-check use the newest reference. For these levels that is the same game today, for the reasons given in Play it; a later module that changed it would need the proofs checked on its engine. A forbidden rule is the closest a rule filter gets to "the idea". For level 6 the map does the rest, because the only push that sends the keg into the lava puts the knight on the shard. For levels 8 and 9 the rest is shown, not proved: each level's test plays one line that does it, and checks on the way that it happened.
An unintended solution is called a cook, and a level with one doesn't teach what it was built to teach. Module 13 explains how the solver works and what it can't tell you. Then design an eleventh level, and try to cook it before the tool does.
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. None of the Hearthstone cards the course uses can cancel Baron's doubling (the course hasn't surveyed the whole card pool), 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); that NOT from the grid caps card multipliers too; and that a minion summoned by a minion on the grid stands on its summoner's tile. None of those came from either game. Each is a little data or a few lines of code, and each is a test.