Files
systemic-game-design-course/course/10-spellgrid/lesson.md
T
Lucas WintherandClaude Opus 5.5 78862d1a1f feat: mutation check, scenario runner and designer capstone; CI jobs; Module 13 brought up to date
- tools/mutation-check.mjs with tools/mutants.json: thirteen mutants against the
  Module 12 TypeScript reference (the eight of Module 13's first table, plus
  Armor before redirect, conditions at parse time, no defender priority, and the
  summon seam's noun check); all killed.
- tools/run-scenario.mjs with tools/scenario/run.ts: a designer's cards, words
  and seam words as data, played on the Module 10 TypeScript reference.
- course/10-spellgrid/designer-capstone.md and designer-example/ (output.txt
  is checked against the tool's output in the tools tests).
- CI: diff-tracks (300 seeds), mutation-check, verify-levels.
- Module 13: the mutation table is the tool's output; differential testing,
  undo and the solver sections describe the repository as it is. Lesson 10
  says what the level proofs prove for levels 6, 8 and 9.
- schema.mjs: a value that matches no oneOf branch is reported against the
  branch its const fields pick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:58:45 +01:00

50 KiB
Raw Blame History

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.

  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?
Discussion
  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 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.
  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.

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 }] 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 four 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.
  4. 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 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_summoned_minion_enters_grid: seam 4. The Clay Golem dies; its Clay Shard stands on the golem's tile, with the newest id, and SHARD IS PUSH names 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 to won().
  • test_10_level_4_solution to test_10_level_10_solution: levels 4 to 10, each played from the solver's shortest solution to won(), with one check on the way that the level's idea happened.
  • test_10_level_3_naive_would_differ: level 3 with withNaiveAuraTiming(). 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 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.
  • Seam 4: summons read the same events. Part I records who summoned each minion (Summoned carries 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 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.
  • 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 a by on the grid and a card with a noun → place an object of that noun on by's tile and link it to id.
  • 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.
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

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_PHASE leaves 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 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), 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:
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, 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.

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.

  1. In one turn, a Powder Keg melts in lava and a Rune Scribe steps onto a skull that is DEFEAT (both minions are YOU). 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.

  1. DEATHRATTLE IS NOT DOUBLE is 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.

  1. 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 spawnText does. 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 be YOU, PUSH or MELT. 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.

  1. 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 DOUBLE is 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 --naive the 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 be HOT (6), that SHARD IS WIN has to be finished (8), and that the skulls have to be SINK (9). It doesn't prove the rest of the idea: that the keg melts before the shard's DEFEAT is 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. 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. 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. 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.