Files
systemic-game-design-course/course/10-spellgrid/scenarios.md
T
Lucas WintherandClaude Opus 5.5 570e5d6826 feat: age-aware solver, verify-levels, seven solver-proved SpellGrid levels (l4-l10)
The solver's position key reads stackAt (each tile oldest first). New tool
tools/verify-levels.mjs checks course/10-spellgrid/levels/proofs.json: each
level's shortest solution, and that it exhausts without its idea (neverRule)
or on the naive engine. Levels 4-10, one course idea each, with
test_10_level_N_solution in both tracks, scenarios and Lesson 10's list;
l1 no longer matches l10, and proofs.json is not listed as a level.

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

20 KiB
Raw Blame History

Module 10 scenarios: SpellGrid

Each heading is a test name. The Rust test lives in tests/rust/test_10.rs and the TypeScript test in tests/ts/test_10.test.ts; both state exactly what is written here.

SpellGrid is the course's own small game: Part I's card engine and Part II's grid engine in one level. The harness adds one constructor, newSpellGrid(level, cards) (Rust: new_spell_grid(level, cards)), returning a SpellGrid:

  • Every Part II function works as in Modules 6–9: place, rules, activeRules, hasProperty, move, posOf, kindsAt, stackAt, step, won, lost.
  • The level format gains two optional fields (course/schema/level.schema.json): cards, a list of { card, x, y }, and hand, a list of card names. Each entry of cards is a minion in play for player 0 and a grid object at (x, y) whose noun is the card's "noun" field ("noun": "KEG" makes an object of kind "keg"). Each name in hand is a card in player 0's hand.
  • cards (the constructor's second argument) is a list of extra card definitions, added to the course's cards. Every test here passes an empty list.
  • One id space: a minion's id is its grid object's id. place, posOf, hasProperty and health all take it, and log() reports it. A hero, or a card that isn't on the grid, gets an id no grid object has.
  • cast(card, target?) (Rust: cast(card, Some(target)) or None) casts a spell from player 0's hand, as one Sequence. The card leaves the hand. Casting a card that isn't in the hand is an error, and so is casting a spell that hits a chosen minion (Spark) without a target; neither changes anything.
  • play(card, x, y) plays a minion from player 0's hand onto the empty tile (x, y), as one Sequence (Module 4's play: it enters the board, then its Battlecry resolves), and returns its id. Playing onto a tile that holds anything is an error.
  • setChooser(choose) is Module 5's: it makes Assemble's choices ("base", "donor", "half").
  • health(id), hero(player), nameOf(id), log() and clearLog() are Part I's, with ids translated as above. minionAt(x, y) is the id of the minion on that tile, or null (Rust: Option<Id>). hand() lists the card names in player 0's hand, in order.
  • withNaiveAuraTiming() is a test hook (Rust: with_naive_aura_timing()). It reverses the two steps at the end of an outermost Phase, so that the Aura Update runs before the Death Creation Step and not after it. Only test_10_level_3_naive_would_differ calls it.

The four seams between the engines, each tested below:

  1. Rules become modifiers. A rule in force <TRIGGER> IS DOUBLE (DEATHRATTLE IS DOUBLE) is a trigger multiplier like Baron Rivendare's (times: 2, stacking: "max"), for every player's minions. DEATHRATTLE IS NOT DOUBLE caps that trigger at one activation, whatever else multiplies it. The rule takes effect at the next Aura Update, like a minion's aura.
  2. Hazards mark, the death step removes. A minion that a hazard (or OPEN/SHUT, or X IS NOT X) destroys is marked pending destroy, in one Sequence. Module 3's Death Creation Step removes it, and its Deathrattle resolves.
  3. Grid changes reparse. A minion that leaves play leaves the grid, and an effect that writes on the grid (spawnText: "put the word W on this minion's tile") writes there. Both happen when the Sequence ends, and then the rules are parsed again, before anything else.
  4. Summoned minions enter the grid. In the same pass, after the text is written and before the dead leave: a minion that a minion's ability summoned, whose card has a "noun", becomes an object of that noun on its summoner's tile (where the summoner stands, or stood if it died in this Sequence), with the newest id. A minion summoned by a spell, or whose card has no noun, is in play but not on the grid. The log is unchanged: Summoned { id } reports the new object's id.

Casting is not a turn: there is no win check until the next step.

SpellGrid's own cards ("game": "spellgrid" in course/catalog.json; not Hearthstone cards):

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 (none)
Thorn Hedge minion, 1 0/2 HEDGE (none)
Grave Warden minion, 3 1/1 WARDEN Your minions trigger their Deathrattles twice. (Baron-like, with 1 Health)
Ember Moth minion, 1 1/1 MOTH Taunt. Deathrattle: Deal 2 damage to the enemy hero. (split 1)
Rune Scribe minion, 2 1/1 SCRIBE Deathrattle: Write WIN on this tile. (split 0)
Tin Knight minion, 2 1/3 KNIGHT (none)
Clay Golem minion, 2 2/2 GOLEM Deathrattle: Summon a Clay Shard. (split 1)
Clay Shard minion, 1 1/1 SHARD (none)
Spark spell, 1 Deal 1 damage to a minion.
Graft spell, 2 Assemble a Deathrattle minion.

Graft is Module 5's assemble effect: the chooser picks a base from the Deathrattle minions among the course's cards, a donor, and a half, and the Assembled minion goes to the hand. It keeps the base's name, stats and noun. Whirlwind ("Deal 1 damage to ALL minions.") is the Hearthstone card from Module 3.

New words: the nouns KEG, BRAMBLE, HEDGE, WARDEN, MOTH, SCRIBE, KNIGHT, GOLEM, SHARD and DEATHRATTLE, and the property DOUBLE. A sentence like DEATHRATTLE (0,0) IS (1,0) DOUBLE (2,0) is three text objects, as in Modules 6–9.

test_10_spatial_rule_feeds_interceptor

  • Setup: a level 6 wide and 3 high with DEATHRATTLE (0,0) IS (1,0) and nothing at (2,0); cards Powder Keg at (0,2) and Bramble Wall at (2,2); hand ["Spark"].
  • Assertions 1: activeRules() doesn't contain "DEATHRATTLE IS DOUBLE".
  • Action 1: place("text:DOUBLE", 2, 0).
  • Assertions 2: activeRules() contains "DEATHRATTLE IS DOUBLE".
  • Action 2: clearLog(), then cast("Spark", keg).
  • Assertions 3:
    • The log holds exactly two Triggered { source: keg, trigger: "deathrattle" }.
    • health(bramble) is 1: two Deathrattles of 1 damage each.
    • posOf(keg) is null and posOf(bramble) is (2,2).
    • hand() is empty.

test_10_not_cancels_spatial_modifier

  • Setup: a level 6 wide and 4 high with DEATHRATTLE (0,0) IS (1,0) DOUBLE (2,0) and DEATHRATTLE (0,1) IS (1,1) NOT (2,1) DOUBLE (3,1); cards Grave Warden at (0,3), Powder Keg at (1,3) and Bramble Wall at (2,3); hand ["Spark"].
  • Assertions 1: rules() contains "DEATHRATTLE IS DOUBLE" and "DEATHRATTLE IS NOT DOUBLE"; activeRules() contains "DEATHRATTLE IS NOT DOUBLE" but not "DEATHRATTLE IS DOUBLE".
  • Action: clearLog(), then cast("Spark", keg).
  • Assertions 2:
    • The log holds exactly one Triggered { source: keg, trigger: "deathrattle" }, although the Grave Warden was in play when the keg died: the negated rule caps the card's aura too.
    • health(bramble) is 2.
    • posOf(warden) is null: the Deathrattle's 1 damage killed it, in the next Death Creation Step.

test_10_grid_and_card_doublers_take_max

  • Setup: a level 6 wide and 3 high with DEATHRATTLE (0,0) IS (1,0) DOUBLE (2,0); cards Grave Warden at (0,2), Powder Keg at (1,2) and Bramble Wall at (2,2); hand ["Spark"].
  • Action: clearLog(), then cast("Spark", keg).
  • Assertions:
    • The log holds exactly two Triggered { source: keg, trigger: "deathrattle" }: the rule's multiplier and the Warden's both have stacking: "max", so together they double, not quadruple.
    • health(bramble) is 1.

test_10_hazard_feeds_death_step

  • Setup: a level 5 wide and 4 high with KEG (0,0) IS (1,0) YOU (2,0) AND (3,0) MELT (4,0) and LAVA (0,1) IS (1,1) HOT (2,1); an object lava at (2,3); cards Powder Keg at (1,3) and Bramble Wall at (4,3).
  • Action: clearLog(), then step("right").
  • Assertions:
    • The log is exactly [Destroyed { id: keg }, DeathPhase { round: 1 }, Triggered { source: keg, trigger: "deathrattle" }, Damaged { target: bramble, amount: 1 }]: the keg melted, as a pending destroy that Module 3's Death Creation Step removed, and its Deathrattle resolved in the Death Phase.
    • posOf(keg) is null, kindsAt(2,3) is ["lava"] and health(bramble) is 2.
    • lost() is true: the keg was the only YOU object.

test_10_spawned_text_triggers_reparse

  • Setup: a level 5 wide and 3 high with BABA (0,0) IS (1,0) YOU (2,0) and BABA (0,1) IS (1,1); card Rune Scribe at (2,1), on the empty end of BABA IS; hand ["Spark"]. Then place("baba", 4, 2).
  • Assertions 1: rules() doesn't contain "BABA IS WIN".
  • Action 1: cast("Spark", scribe).
  • Assertions 2:
    • kindsAt(2,1) is ["text:WIN"]: the scribe's Deathrattle wrote WIN on its tile, and the scribe left the grid.
    • rules() contains "BABA IS WIN" and hasProperty(baba, "WIN") is true, straight after the cast: the grid was reparsed when the Sequence ended.
    • won() is false: casting is not a turn.
  • Action 2: step("left").
  • Assertions 3: won() is true.

test_10_targeted_spell_needs_target

  • Setup: a level 3 wide and 1 high with card Bramble Wall at (0,0); hand ["Spark"].
  • Action 1: cast("Spark"), with no target.
  • Assertions 1: it is an error (Rust: a panic; TypeScript: it throws), and nothing changed: hand() is still ["Spark"] and health(bramble) is 3. Spark's effect hits a chosen minion, so a cast without one would waste the card.
  • Action 2: cast("Spark", bramble).
  • Assertions 2: health(bramble) is 2 and hand() is empty.

test_10_summoned_minion_enters_grid

  • Setup: a level 5 wide and 3 high with SHARD (0,0) IS (1,0) PUSH (2,0); card Clay Golem at (2,2); hand ["Spark", "Spark"].
  • Action: cast("Spark", golem), then clearLog(), then cast("Spark", golem).
  • Assertions, with shard the minion at (2,2):
    • The log is exactly [SpellCast { card: "Spark" }, Damaged { target: golem, amount: 1 }, Destroyed { id: golem }, DeathPhase { round: 1 }, Triggered { source: golem, trigger: "deathrattle" }, Summoned { id: shard }]: the log reports the shard by its grid id.
    • kindsAt(2,2) is ["shard"]: the shard stands on the golem's tile, and the golem left the grid. posOf(golem) is null.
    • nameOf(shard) is "Clay Shard" and health(shard) is 1.
    • activeRules() contains "SHARD IS PUSH" and hasProperty(shard, "PUSH") is true, straight after the cast: the shard was placed before the reparse.
    • shard is greater than golem, hero(0) and hero(1), and the next place("rock", 4, 2) returns shard + 1: the shard has the newest id.

test_10_summon_without_noun_stays_off_grid

  • Setup: a level 3 wide and 1 high with no objects; hand ["Graft", "Spark", "Spark"]; setChooser answering "Clay Golem" for the base, "Harvest Golem" for the donor and "bottom" for the half.
  • Action 1: cast("Graft").
  • Assertions 1: hand() is ["Spark", "Spark", "Clay Golem (Assembled)"]: the golem's noun with Harvest Golem's Deathrattle, "Summon a 2/1 Damaged Golem."
  • Action 2: play("Clay Golem (Assembled)", 1, 0), returning golem; cast("Spark", golem); clearLog(); cast("Spark", golem).
  • Assertions 2, with token the id in the log's last event:
    • The log is exactly [SpellCast { card: "Spark" }, Damaged { target: golem, amount: 1 }, Destroyed { id: golem }, DeathPhase { round: 1 }, Triggered { source: golem, trigger: "deathrattle" }, Summoned { id: token }].
    • nameOf(token) is "Damaged Golem" and health(token) is 1: it is in play.
    • posOf(token) is null, kindsAt(1,0) is empty and minionAt(1,0) is null: Damaged Golem has no noun, so it is not on the grid.

test_10_level_1_solution

Level levels/l1-double-tap.json. The Bramble Wall (3 Health) is STOP and blocks the only way to the flag. Whirlwind and one Powder Keg Deathrattle do 2 damage, one short.

  • Inputs: step("up"), step("up") (pushing DOUBLE through the gap in the wall to finish DEATHRATTLE IS DOUBLE), then cast("Whirlwind").
  • Assertions 1: activeRules() contains "DEATHRATTLE IS DOUBLE" before the cast. After it, the keg's Deathrattle has triggered twice and posOf(bramble) is null.
  • Inputs: step("down"), step("down"), step("left"), step("down"), step("down"), step("right").
  • Assertions 2: won() is false, and kindsAt(5,6) is ["baba"]: Baba stands where the bramble was.
  • Input: step("right").
  • Assertions 3: won() is true.

test_10_level_2_solution

Level levels/l2-spliced-corpse.json. BABA IS ends on a lava tile. MOTH IS PUSH AND MELT is written, but there is no moth: the hand holds only Graft.

  • Setup: setChooser answering "Ember Moth" for the base, "Rune Scribe" for the donor and "bottom" for the half.
  • Input: cast("Graft").
  • Assertions 1: hand() is ["Ember Moth (Assembled)"]: the moth's noun and Taunt, the scribe's Deathrattle.
  • Input: play("Ember Moth (Assembled)", 4, 3), returning moth.
  • Assertions 2: kindsAt(4,3) is ["moth"], and nameOf(moth) is "Ember Moth (Assembled)".
  • Inputs: step("left"), then step("left"), which pushes the moth onto the lava.
  • Assertions 3:
    • posOf(moth) is null and kindsAt(2,3) is ["lava", "text:WIN"]: the moth melted and its Deathrattle wrote WIN where it died.
    • rules() contains "BABA IS WIN", and won() is true after that same step: the reparse after the Sequence came before the win check.

test_10_level_3_solution

Level levels/l3-simultaneous-paradox.json. The player is a Tin Knight (KNIGHT IS YOU, 3 Health). The Thorn Hedge (2 Health) is STOP and blocks the flag. The Grave Warden and the Powder Keg have 1 Health each.

  • Input: cast("Whirlwind").
  • Assertions 1:
    • The keg's Deathrattle triggered exactly once: the Warden died in the same Death Creation Step, and the Aura Update after that step removed its aura before the Death Phase.
    • health(knight) is 1, and posOf(hedge), posOf(keg) and posOf(warden) are null.
  • Inputs: step("right") five times.
  • Assertions 2: won() is true.

test_10_level_3_naive_would_differ

The same level and the same inputs, on a SpellGrid with withNaiveAuraTiming() called first.

  • Assertions:
    • The keg's Deathrattle triggered twice: the Warden's aura was still in the snapshot when the Death Phase began.
    • posOf(knight) is null, lost() is true and won() is false: the second Deathrattle killed the knight along with the hedge. This is the trap the level is built on.

test_10_level_4_solution

Level levels/l4-crossed-out.json. The Thorn Hedge (2 Health) is STOP and blocks the flag; the hand holds one Spark. DEATHRATTLE IS DOUBLE is written down a column and DEATHRATTLE IS NOT DOUBLE along a row, sharing the DEATHRATTLE. The keg is at (1,6).

  • Assertions 1: rules() contains "DEATHRATTLE IS DOUBLE", and activeRules() doesn't: the negated rule cancels it.
  • Inputs: step("right"), step("down"), which pushes the row's DOUBLE out of line.
  • Assertions 2: rules() no longer contains "DEATHRATTLE IS NOT DOUBLE", and activeRules() contains "DEATHRATTLE IS DOUBLE".
  • Inputs: step("right"), step("right"), then cast("Spark", keg).
  • Assertions 3: the keg's Deathrattle has triggered twice, and posOf(hedge) is null.
  • Inputs: step("right"), step("up").
  • Assertions 4: won() is true.

test_10_level_5_solution

Level levels/l5-sealed-keg.json. Walled off at the top: KEG IS KEG, KEG IS FLAG, FLAG IS WIN. There is no flag. Below: KEG IS with a gap and then KEG, a loose NOT, and the keg at (1,5).

  • Inputs: step("left"), step("left"), step("down"), step("left").
  • Assertions 1: kindsAt(1,5) is ["keg"]: the lock holds.
  • Input: step("up"), which pushes NOT into the gap.
  • Assertions 2:
    • activeRules() contains "KEG IS NOT KEG" and not "KEG IS KEG".
    • The keg's Deathrattle has triggered once, posOf(keg) is null, and kindsAt(1,5) is ["flag"]: the keg was deleted, and because the negation also cancelled the lock, KEG IS FLAG still made its flag.
  • Input: step("left").
  • Assertions 3: won() is true.

test_10_level_6_solution

Level levels/l6-melt-before-defeat.json. The player is a Tin Knight (KNIGHT IS YOU, 3 Health) at (0,6). A Powder Keg (PUSH and MELT) stands on a Clay Shard (SHARD IS DEFEAT) at (3,6), with lava and then the flag beyond. LAVA IS is missing its HOT.

  • Assertions 1: stackAt(3,6) is ["keg", "shard"].
  • Inputs: step("right"), step("right"), step("up") (finishing LAVA IS HOT), step("down").
  • Assertions 2: activeRules() contains "LAVA IS HOT".
  • Input: step("right"): the knight pushes the keg into the lava and steps onto the shard's tile.
  • Assertions 3:
    • The keg's Deathrattle triggered once, and posOf(keg) is null.
    • posOf(knight) is [3, 6], kindsAt(3,6) is ["knight"], health(knight) is 2 and lost() is false. HOT/MELT runs before DEFEAT: the keg melts, its Deathrattle kills the shard, and when DEFEAT runs there is nothing DEFEAT on the knight's tile.
  • Inputs: step("right"), step("right").
  • Assertions 4: won() is true.

test_10_level_7_solution

Level levels/l7-clay-key.json. The flag is behind a door (DOOR IS SHUT AND STOP). SHARD IS PUSH is written, and SHARD IS waits for its OPEN. There is no shard: a Clay Golem stands at (4,5), and the hand holds two Sparks.

  • Inputs: step("right"), step("up") (finishing SHARD IS OPEN), step("right").
  • Assertions 1: activeRules() contains "SHARD IS OPEN".
  • Inputs: cast("Spark", golem) twice.
  • Assertions 2: posOf(golem) is null, kindsAt(4,5) is ["shard"], and the minion there is a "Clay Shard": seam 4 put the Deathrattle's token where the golem died.
  • Inputs: step("right"), step("right"), pushing the shard into the door.
  • Assertions 3: kindsAt(6,5) is []: the shard and the door destroyed each other.
  • Inputs: step("right"), step("up").
  • Assertions 4: won() is true.

test_10_level_8_solution

Level levels/l8-nothing-dies.json. SHARD IS waits for its WIN, and there is no shard. The hand holds one Graft, and nothing on the level can deal damage.

  • Setup: setChooser answering "Clay Golem" for the base, "Novice Engineer" for the donor and "top" for the half.
  • Input: step("left").
  • Assertions 1: activeRules() contains "SHARD IS WIN".
  • Inputs: cast("Graft"), clearLog(), then play("Clay Golem (Assembled)", 4, 3), returning golem.
  • Assertions 2: the log contains Triggered{source: golem, trigger: "battlecry"}, posOf(golem) is [4, 3] and stackAt(4,3) is ["golem", "shard"]. The top half swap made "Deathrattle: Summon a Clay Shard." into "Battlecry: Summon a Clay Shard.", and seam 4 puts the shard on the living golem's tile.
  • Input: step("right").
  • Assertions 3: won() is true.

test_10_level_9_solution

Level levels/l9-skull-river.json. A river of skulls (SKULL IS DEFEAT) two tiles wide lies between Baba and the flag. Two rocks (ROCK IS PUSH) stand in a corridor in a line, and SKULL IS waits for its SINK.

  • Input: step("right"): Baba pushes both rocks at once.
  • Assertions 1: kindsAt(4,5) is ["rock", "skull"] and kindsAt(3,5) is ["rock"].
  • Inputs: step("down"), step("left") (finishing SKULL IS SINK).
  • Assertions 2: activeRules() contains "SKULL IS SINK", and kindsAt(4,5) is []: the skull took the rock with it at the end of that turn.
  • Inputs: step("up"), step("right"), step("right"), step("right").
  • Assertions 3: kindsAt(5,5) is []: the second rock sank in the second skull.
  • Inputs: step("right"), step("right").
  • Assertions 4: won() is true.

test_10_level_10_solution

Level levels/l10-friendly-fire.json. The player is a Clay Shard (SHARD IS YOU, 1 Health) at (1,6). The Thorn Hedge (2 Health) is STOP and blocks the flag; a Powder Keg and a Tin Knight (3 Health) are in play, and the hand holds Whirlwind. A KNIGHT text lies below SHARD.

  • Inputs: step("left"), step("up"), which pushes KNIGHT into the sentence and SHARD out of it.
  • Assertions 1: activeRules() contains "KNIGHT IS YOU" and not "SHARD IS YOU".
  • Inputs: step("up"), then cast("Whirlwind").
  • Assertions 2: posOf(shard) and posOf(hedge) are null, health(knight) is 1, and lost() is false. Whirlwind and the keg's Deathrattle deal 2 damage to every minion: the shard dies, and so would the player if it were still the shard.
  • Inputs: step("right"), step("up").
  • Assertions 3: won() is true.