Files
systemic-game-design-course/course/06-parser/lesson.md
T
Lucas WintherandClaude Fable 5.2 4cb0f3a61f feat: Stress it beat for Modules 6-10, Module 11 (Break your own engine), level solver tool
- lessons 8 and 9 updated for the once-per-move rule, per-object negated
  conversion and the new ordering tests
- tools/solve-spellgrid.mjs: breadth-first search over a SpellGrid level
  with the TypeScript reference; --never-rule and --naive
- README, harness.md and the mentor skill know the new beat and module

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 09:32:22 +01:00

32 KiB
Raw Blame History

Module 6: Grid, lexer and parser

This is the first module of Part II. In Part I the rules lived inside the cards: Harvest Golem's Deathrattle was a clause on Harvest Golem, and your engine's job was to find the right clause at the right moment and run it. Part II turns that around. In Baba Is You the objects on the board have no behaviour of their own. A rock is just a rock until some text on the board says ROCK IS PUSH: "When three blocks in a row make a sentence, like "ROCK IS PUSH," it creates a rule that immediately takes effect in-game", and "If you push one of the words in a ruleset out of line, that effect instantly ceases" (Game Developer). Apart from a few base rules that hold with no text, TEXT IS PUSH among them (Rule), every rule comes from words lying on the grid, and the words can be pushed around, so the rules change while you play. The engine's job is now to compile the rules from the board, and to compile them again every time the board changes.

This module builds the compiler's front end: a grid that holds objects and text, a lexer that turns text into words with grammatical roles, and a parser that turns words into rules. Nothing moves yet (that is Module 8), and no rule does anything yet (Modules 7 and 9). By the end, rules() reads any board you build.

You need your Module 5 workspace with all Module 1–5 tests passing, under git. Part I's engine stays where it is, and its tests keep running: Part II is a second engine next to the first, in the same project. Part III will plug them together.

Observe

All quotes in this module come from sources recorded in the course's research notes. The Baba Is You Wiki is an unofficial community wiki, and the course treats it as the best available description of the game, not as Hempuli's own word.

How a sentence reads, from the wiki's Rule page:

Rules read from left to right in a row, or from top to bottom in a column.

A noun can take a property or another noun (Category:Nouns):

A noun can be used as a NOUN RULE PROPERTY statement e.g. BABA IS YOU to give it a property or as a NOUN IS NOUN statement eg. WALL IS WATER to turn an object into another object.

Sentences can cross. The Esolangs wiki says "multiple rules are able to cross and overlap if the parsing rules allow for a valid statement", so one IS can sit in a row sentence and a column sentence at once.

AND joins words on either side of IS. The AND page calls it "mechanically equivalent to each rule being defined individually", and gives this table row:

BABA AND KEKE IS PUSH AND STOP = BABA IS PUSH, BABA IS STOP, KEKE IS PUSH, KEKE IS STOP

Text can share a tile. The Rule page: "If more than one parsable object is stacked, the game will attempt to produce all possible rules." The game's designer, Arvi "Hempuli" Teikari, told Game Developer that reworking the rule system for stacked words "turned out to be one of the larger undertakings over the game's development, because the stacking adds the need to go through all the possible permutations a rule could have" (Designing Baba Is You's delightfully innovative rule-writing system).

Some rules have no text at all. Again from Rule:

The following rules are enabled by default, even though there isn't any text saying so. TEXT IS PUSH, LEVEL IS STOP, CURSOR IS SELECT. These rules can still be detected by FEELING and MIMIC and negated by NOT and BROKEN. These rules are not listed in the pause menu.

That is why you can push text around although no sentence says so. TEXT is a noun about text itself (TEXT).

And when the rules are worked out. The Rule page: "Rules parse when any parsable object changes position or is destroyed, or an object is transformed to text. Between parsings, active rules are simply stored." Teikari describes the same thing from the inside (same Game Developer article): "The game only updates the rules if it needs to", and when it does, "The previous rules are removed entirely, and words that could potentially start rule sentences are detected on the level."

Read that last quote twice. The game doesn't edit its rule list when a word moves. It throws the list away and reads the board again.

Predict

Write your answers down before you open the discussion, for example in notes/06.md in your workspace. Coordinates are (x, y), with x growing right and y growing down.

  1. Text BABA (0,1), IS (1,1), YOU (2,1), FLAG (1,0), WIN (1,2). Draw it. Which rules does it spell?
  2. One row: BABA AND ROCK IS PUSH AND STOP. How many rules? Which?
  3. Text BABA and text ROCK both on (0,0), then IS (1,0), YOU (2,0). Which rules?
  4. One row: YOU IS FLAG. Which rules? And a level with no text at all?
  5. Your Module 5 engine stored each card's behaviour on the card. Where would you store "Baba is YOU" here: on each Baba object, on the noun BABA, somewhere else? What has to happen when the YOU text is pushed out of line?
  6. Your engine has to know that IS is special. Does it have to know that BABA is a noun?
Discussion
  1. BABA IS YOU across the middle row and FLAG IS WIN down the middle column, sharing the IS. Plus TEXT IS PUSH, which is always there.

  2. Four: BABA IS PUSH, BABA IS STOP, ROCK IS PUSH, ROCK IS STOP. AND distributes over both sides: two subjects times two complements.

  3. BABA IS YOU and ROCK IS YOU. Each stacked word gets its turn as the start of the sentence: "all possible rules".

  4. None from the text: a sentence must start with a noun, and YOU is a property. The parser never reads right to left, so it doesn't find FLAG IS YOU either. With no text at all, the rules are exactly TEXT IS PUSH.

  5. Nowhere permanent. If "Baba is YOU" were stored on each Baba, pushing YOU away would have to find every Baba and take it back, and a Baba created later would have to look up what it missed. The rule belongs to a table derived from the board. Objects ask the table. When the board changes, the table is rebuilt: the game "removes the previous rules entirely".

  6. It shouldn't. IS, AND, NOT are grammar, and the engine must know them to parse at all. BABA, ROCK and FLAG are vocabulary: the parser only needs to know each one's role (noun or property), and that can come from data. That is the Zero-Coupling Law of Part I, applied to words: the engine doesn't name nouns.

Spec

From the course repository, copy the module's tests into your workspace:

node tools/start-module.mjs 06 ~/my-engine

The level format

A level is data, in the format of course/schema/level.schema.json: a size and a list of objects.

{
  "width": 3,
  "height": 3,
  "objects": [
    { "kind": "text:BABA", "x": 0, "y": 1 },
    { "kind": "text:IS", "x": 1, "y": 1 },
    { "kind": "text:YOU", "x": 2, "y": 1 },
    { "kind": "baba", "x": 1, "y": 2 }
  ]
}
  • A kind is either an object, named by its noun in lower case ("baba"), or a piece of text carrying one word ("text:BABA"). Text objects are objects like any other; they just happen to be readable.
  • x grows right and y grows down. Any number of objects may share a tile.
  • The tests build most levels with place rather than from files.

The harness

Part II has its own harness object, Baba, next to Part I's Game:

TypeScript Rust
newBaba(level: LevelDef): Baba new_baba(level: LevelDef) -> Baba (free function)
place(kind, x, y): Id place(&mut self, kind: &str, x: i32, y: i32) -> Id
rules(): string[] rules(&self) -> Vec<String>
  • newBaba builds the level and parses its text straight away.
  • place adds one object and returns its id.
  • rules() returns every distinct rule in force as a normalised string ("BABA IS YOU"), sorted. It always includes "TEXT IS PUSH". A rule spelled twice on the board appears once. In the real game a stacked noun can make a conversion rule parse more than once, and that duplicates objects (the wiki's Advanced rulebook), so copies are not nothing; but rules() is a set, and no test counts copies.
  • In Rust, LevelDef and LevelObject are structs in (or re-exported from) src/harness.rs, and LevelDef must implement Default: the tests write LevelDef { width: 3, height: 3, ..Default::default() }. The exact shape is in course/harness.md.

The tests

The full statements are in course/06-parser/scenarios.md.

  • test_06_horizontal_rule: a level file with BABA IS YOU on the top row, a baba object, and YOU IS FLAG on the bottom row. Only BABA IS YOU (and TEXT IS PUSH).
  • test_06_crossword_shared_is: Predict question 1, built one word at a time. Before WIN arrives, FLAG above IS is half a sentence; after, the column spells FLAG IS WIN. The rules are recompiled when the board changes.
  • test_06_and_distributes: BABA AND ROCK IS PUSH AND STOP, four rules.
  • test_06_stacked_text_all_combinations: BABA and ROCK stacked, then IS YOU. Both rules.
  • test_06_default_text_is_push: a level with one baba and no text. Exactly TEXT IS PUSH.

The crossword test:

#[test]
fn test_06_crossword_shared_is() {
    let mut b = new_baba(empty_level(3, 3));
    write(
        &mut b,
        &[("BABA", 0, 1), ("IS", 1, 1), ("YOU", 2, 1), ("FLAG", 1, 0)],
    );

    assert_eq!(b.rules(), vec!["BABA IS YOU", "TEXT IS PUSH"]);

    b.place("text:WIN", 1, 2);

    assert_eq!(
        b.rules(),
        vec!["BABA IS YOU", "FLAG IS WIN", "TEXT IS PUSH"]
    );
}
test("test_06_crossword_shared_is", () => {
  const b = newBaba(emptyLevel(3, 3));
  write(b, [
    ["BABA", 0, 1],
    ["IS", 1, 1],
    ["YOU", 2, 1],
    ["FLAG", 1, 0],
  ]);

  expect(b.rules()).toEqual(["BABA IS YOU", "TEXT IS PUSH"]);

  b.place("text:WIN", 1, 2);

  expect(b.rules()).toEqual(["BABA IS YOU", "FLAG IS WIN", "TEXT IS PUSH"]);
});

write places one text:<WORD> object per entry; empty_level/emptyLevel is a level with no objects.

Run cargo test or npm test. The Module 6 tests fail to compile (Rust) or fail (TypeScript): there is no newBaba. Modules 1–5 still pass. That is RED.

Build

Make the five tests pass, and keep Modules 1–5 green. Where code goes:

  • A second engine, beside the first. Put Part II in its own directory under src/engine/. It shares nothing with Part I's world yet. Pick its name with care: the coupling audit reads every spelling of every noun in the catalog, so a module called baba names BABA. The reference calls it puzzle.
  • Grammar in the engine, vocabulary in content. The engine may name the operators IS, AND and NOT, the meta-noun TEXT, and properties such as PUSH (the base rule needs it). It must not name a noun. The nouns, and the properties the parser accepts, are a word list in src/content/: data, one entry per word with its role. The reference uses a JSON file, src/content/words.json, the same file in both tracks.
  • Three stages. The grid stores objects by id with a tile each. The lexer reads the text objects and, with the word list, turns them into words with a role, grouped by tile. Text whose word isn't in the list is left out, so it spells nothing. The parser turns words into rules.
  • Compile, don't patch. Store the rule list next to the board. Every place (and, from Module 8, every move) throws it away and parses the whole board again. Levels are small; a full reparse is cheap and can't drift out of date.
  • src/harness stays thin: newBaba builds the level with your content's words, place forwards, rules formats, sorts and removes duplicates.

The grammar for this module:

sentence   = subjects IS complements
subjects   = NOUN { AND NOUN }
complements = (NOUN | PROPERTY) { AND (NOUN | PROPERTY) }

read left to right in a row and top to bottom in a column. NOT is a known operator but not part of the grammar yet (Module 7). Where a sentence needs a noun or a property, a NOT stops it, as an unknown word does: BABA IS NOT YOU spells nothing, and in BABA IS YOU AND NOT WIN the run of complements ends at the NOT. In front of a subject, though, nothing looks at it: NOT BABA IS YOU reads as BABA IS YOU, which is wrong. Module 7 makes the parser refuse that sentence.

Hint 1 — nudge

Where does a sentence begin? You could try every noun on the board as a start and read right and down. That works, but BABA AND ROCK IS PUSH then reads twice: once from BABA and once from ROCK. Every sentence has exactly one IS in it. What if you start there and read outwards?

Hint 2 — concept
  • Start at every IS, twice: once in its row, once in its column. Each IS is in at most one row sentence and one column sentence, so nothing is read twice.
  • Read a run outwards. Before the IS, walking backwards, collect the nouns on the next tile; if the tile after them holds an AND, keep going from the tile after that. After the IS, the same, forwards, with nouns and properties. If the first tile has nothing that fits, the run is empty and the sentence spells nothing.
  • Stacking is free. A tile is a list of words. "The nouns on this tile" is a list too, and every one of them goes into the run. Two stacked subjects and one complement are two rules, with no special case.
  • One rule per pair. Subjects × complements. That is AND distributing.
  • Base rules first. Start the list with TEXT IS PUSH, then add what the text spells.
  • Keep the text ids. A rule can remember the three text objects that spelled it. Two copies of BABA IS YOU spelled by different text are two rules; the same text found from two directions can't happen when you start from IS.
Hint 3 — pseudo-code
lex(board, words):
    tiles = map (x, y) -> list of {id, word, role}
    for each text object o, oldest first:
        role = words.role(o.word)          // IS AND NOT: operator, TEXT: noun, else from content
        if role is known: tiles[o.x, o.y].push({o.id, o.word, role})

parse(board, words):
    tiles = lex(board, words)
    rules = [TEXT IS PUSH]
    for each IS token t at (x, y):
        for (dx, dy) in [(1, 0), (0, 1)]:
            subjects    = run(tiles, (x - dx, y - dy), (-dx, -dy), noun)
            complements = run(tiles, (x + dx, y + dy), (dx, dy), noun or property)
            for s in subjects, c in complements:
                rules.push({s.word, c.word, text: [s.id, t.id, c.id]})
    return rules

run(tiles, at, step, fits):
    found = [w in tiles[at] where fits(w)]
    if found is empty: return []
    if tiles[at + step] has an AND:
        found += run(tiles, at + 2 * step, step, fits)
    return found

When your tests pass, compare with the reference engine in course/06-parser/reference/rust/src/engine/puzzle/ or course/06-parser/reference/ts/src/engine/puzzle/: grid (the level format and the board), word (lexicon and lexer), parse and level (the board plus its compiled rules). The words are in src/content/words.json.

Decisions in the reference that the tests don't force:

  • rules() deduplicates; the engine doesn't. The engine's list keeps a rule once per set of text objects that spells it, because stacked text really does produce duplicate rules (see Spec). The harness turns that list into a sorted set of strings.
  • Unknown words spell nothing. Text whose word isn't in the lexicon is skipped by the lexer, so it breaks any sentence it stands in. That is what makes the Expansion Drop below a data change.
  • Content can't redefine grammar. The lexicon starts from content's word list and then sets IS, AND, NOT and TEXT itself.
  • A kind is its noun in lower case. "baba" is a thing of noun BABA. The engine never checks that the noun exists: an object of an unknown noun is just an object no rule can name.
  • Off the board is an error. place outside the level's width and height panics (Rust) or throws (TypeScript).
  • Reparse on every change, including placing a non-text object. The real game only reparses when text moves (Observe), which gives the same rules; the reference trades a little speed for one fewer condition.

How the real game differs

  • The real game has more grammar: conditions such as ON, NEAR and FACING, which the wiki's Order of Operations page shows reparsing between movement stages ("conditions (e.g. IT ON BABA IS YOU AND MOVE) reparse in between"), verbs other than IS, such as HAS and WRITE (same page), and NOT (Module 7). The course's grammar is NOUN [AND NOUN]* IS (NOUN|PROPERTY) [AND (NOUN|PROPERTY)]*.
  • Where two sentences overlap on an AND, the real game does something subtler: "Overlapping rules with AND in the overlapped part does not apply AND to the right/downward rule" (AND). The course's parser applies AND in both, and no test covers the case.
  • The real game has two more base rules, LEVEL IS STOP and CURSOR IS SELECT (Rule). The course has no level object and no cursor.
  • Base rules "are not listed in the pause menu" (same page). The course's rules() lists TEXT IS PUSH, because Module 7 negates it and the tests need to see it.
  • The real game reparses only when text has moved, been destroyed or been created (same page). The reference reparses on every change to the board.

Name it

Two names, one old and one for this course.

The old one is from compilers: a lexer and a parser. The lexer turns a stream of characters into tokens with a category; the parser turns tokens into structure according to a grammar. Here the "characters" are text objects on a 2D grid, the categories are noun, property and operator, and the structure is a list of rules. The split pays off at once: the parser never looks at an object, only at tokens with a role, and the lexer is the only place that consults the word list. Adding a word touches neither.

The new one is the theme of Part II: rules are compiled from world state. In Part I, a rule was something a card had. Here a rule is something the board says, and the engine's rule table is a function of the board: change the board, recompute the table. Teikari's description of the real game is exactly this: "The previous rules are removed entirely, and words that could potentially start rule sentences are detected on the level" (Game Developer). Nothing in the engine edits a rule in place, so nothing can forget to.

The heart of the reference parser, one IS in two directions:

for (&(x, y), tokens) in &tiles {
    for is in tokens.iter().filter(|t| t.word == IS) {
        for (dx, dy) in [(1, 0), (0, 1)] {
            let subjects = run(&tiles, (x - dx, y - dy), (-dx, -dy), is_subject);
            let complements = run(&tiles, (x + dx, y + dy), (dx, dy), is_complement);
            for s in &subjects {
                for c in &complements {
                    rules.push(Rule {
                        subject: s.word.clone(),
                        complement: c.word.clone(),
                        text: vec![s.id, is.id, c.id],
                    });
                }
            }
        }
    }
}
for (const o of board.objects()) {
  if (o.kind.type !== "text" || o.kind.word !== IS) continue;
  for (const [dx, dy] of [
    [1, 0],
    [0, 1],
  ] as const) {
    const subjects = run(tiles, o.x - dx, o.y - dy, [-dx, -dy], isSubject);
    const complements = run(tiles, o.x + dx, o.y + dy, [dx, dy], isComplement);
    for (const s of subjects) {
      for (const c of complements) {
        rules.push({ subject: s.word, complement: c.word, text: [s.id, o.id, c.id] });
      }
    }
  }
}

The same shape appears in baba-is-auto, a C++ simulator of a subset of the game. Game::ParseRules in Sources/baba-is-auto/Games/Game.cpp (around line 611, commit 2305a3d) starts with m_ruleManager.ClearRules(); and then scans every cell horizontally and vertically; its ARCHITECTURE.md says "Rules are rebuilt after every move, so moving text can immediately change the active rule set." The rest of the simulator asks RuleManager (AddRule, GetRules, HasProperty) rather than the board. That is the split you just built: derive a table from the world, then let every system query the table.

One warning about baba-is-auto: it is a good example of rules as data, and not an authority on the real game. Its own rule-language.md says "all text is pushable without an explicit rule", where the real game has TEXT IS PUSH as a base rule that NOT can switch off, and its NOT support is partial ("Negated subjects, predicates, condition targets, repeated NOT, and nested forms are unsupported"), so it has no IS NOT. Module 7 needs both.

Run the coupling audit again: node tools/coupling-audit.mjs ~/my-engine should print coupling-audit: ok. Your Part II engine knows IS, AND, NOT, TEXT and PUSH. BABA lives only in your word list.

Expansion Drop

A new word arrives: HOT. The HOT page: "HOT is a property that causes the object to destroy any same-float MELT objects it contacts." What HOT does waits for the turn in Module 9. For now your parser only has to read it.

The constraint, as always: do not edit anything under src/engine/. The harness doesn't change either. Only your word list should.

  1. Commit your workspace and note the commit: git add -A && git commit -m "module 6 done".
  2. Copy the expansion test for your track from course/06-parser/expansion/tests/rust/test_06x.rs or course/06-parser/expansion/tests/ts/test_06x.test.ts into your workspace's tests/. Run it and watch it fail.
  3. Add HOT to your word list.
  4. When the tests pass, run the check from the course repository:
node tools/expansion-check.mjs ~/my-engine HEAD

The test, test_06x_hot_word, places LAVA IS HOT AND PUSH and expects LAVA IS HOT and LAVA IS PUSH. Before the drop the parser doesn't know HOT: the sentence stops right after IS, and not even LAVA IS PUSH survives, because PUSH is only reached through HOT AND. The full scenario is in course/06-parser/expansion/README.md.

Discussion: what this drop measures

If your nouns and properties are data, HOT is one line:

{ "name": "HOT", "role": "property" }

That is the whole reference answer, in course/06-parser/expansion/solution/words.json (the Module 6 word list with that line added), the same file for both tracks.

Shapes that fail this drop:

  • Properties as an enum in the engine. If Property::You | Property::Push | ... lives under src/engine/, HOT is a new variant there. A typed enum feels safer, but every new word becomes an engine change, and the course's catalog alone lists fourteen properties.
  • The parser checks words, not roles. If the complement test is word == "YOU" || word == "PUSH" || ..., the list of properties is in the parser. Move it to the lexer's input, and let the parser ask only "noun or property?".
  • Unknown words accepted as anything. If your lexer treats every unknown word as a property, the test passes before the drop, and so does BABA IS BANANA. The drop's first run should fail.

If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1.

Stress it

Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.

  1. One row: BABA IS ROCK IS PUSH. How many rules does your parser read, and which? Then one row: BABA AND IS YOU, and one row: BABA IS YOU AND.
Discussion

BABA IS ROCK IS PUSH gives two rules in the reference: BABA IS ROCK and ROCK IS PUSH. Each IS reads outwards on its own, and the word ROCK is the complement of the first and the subject of the second. Nothing in "start at every IS" stops two sentences sharing a word end to end, just as the crossword test shares an IS at right angles. Whether the real game reads this row the same way is not in the course's sources; if you care, try it in the game.

BABA AND IS YOU spells nothing: the tile before IS holds no noun, so the subject run is empty. BABA IS YOU AND is just BABA IS YOU: a trailing AND continues the run onto a tile with nothing that fits, and the run keeps what it had. These are the cases a grammar written as three lines doesn't mention and a parser has to decide anyway. Write a test for each decision you made, even if the decision was "whatever my loop happened to do".

  1. Without running anything: if you slide every object on a board three tiles right and two down (on a level big enough to hold it), do the rules change? If you swap every object's x and y? If you mirror the board left to right?
Discussion

Sliding: no. Nothing in the grammar mentions an absolute position. Swapping x and y: no, and this one is less obvious. Rows become columns and columns rows, but rows read left to right and columns top to bottom, so every sentence still reads in its forward direction. Mirroring: yes. BABA IS YOU becomes YOU IS BABA, which spells nothing.

Each "no" is a test you can run on boards nobody wrote. Scatter random words on a random board, compute rules(), apply the change, compute again, compare:

rules(board) == rules(slide(board, dx, dy))
rules(board) == rules(swapXY(board))

This is metamorphic testing: you don't know the right answer for a random board, but you know how the answers for two related boards must relate. A metamorphic relation is a necessary property "of the intended functionality" that "must involve multiple executions of the software" (Metamorphic testing, Wikipedia). The swap relation is a sharp one for this module, because a parser that handles rows and columns in two separate pieces of code can easily be right in one and wrong in the other, and no hand-written test compares them. Module 1's seeded generator is all you need. Module 11 collects the relations that hold across the course.

  1. Three tiles in a row hold two stacked nouns, one IS, and three stacked properties. How many rules? Now k words on each of the three tiles of a sentence with one AND-free subject and complement. Why did Teikari call stacking "one of the larger undertakings"?
Discussion

Six, then k × k (and k stacked IS words would each spell all of them again, as separate rules with different text ids). Add AND and the count multiplies along the run. The parser's work grows with the number of rules it emits, and stacked text makes that number a product, not a sum.

For the course's parser this costs nothing to write: Hint 2's "stacking is free" is true because a tile was a list from the start. That is the real lesson of the quote. Teikari was reworking a rule system in which a tile held one word, and "the need to go through all the possible permutations" arrived late. The decision that a tile holds a list is cheap on day one and expensive afterwards, like most decisions about the shape of data.

  1. The reference reparses on every change to the board. The real game reparses only "when any parsable object changes position or is destroyed, or an object is transformed to text" (Rule). Add that optimisation to your engine. How do you convince yourself it never changes a result?
Discussion

The optimisation is a flag: set it when a text object is placed, moved, removed or created; reparse only if it is set. The argument that it is safe is that parse reads text objects and nothing else, so a change that touches no text object can't change its output.

An argument is not a test. The test is to keep both: build one level that always reparses and one that uses the flag, feed them the same random sequence of changes, and compare rules() after every change. Two implementations of one specification checking each other is differential testing, and it is the cheapest way to make an optimisation safe: the slow, obviously correct version is the oracle for the fast one. (It is also why this course ships its reference in two languages.)

Then notice what the flag costs. "Reparse on every change" can't be wrong. "Reparse when text changed" is right only as long as every way text can change remembers to set the flag, and Module 9 adds conversion and Module 10 adds text written by a Deathrattle. The reference never took the optimisation, for that reason.

  1. Baba Is You compiles its rules from the board. PuzzleScript, a small language for making puzzle games, has the author write the rules down as patterns: "The engine looks for the pattern on the left, and replaces it with the one on the right." (Rules 101). Pushing a crate is the rule [ > Player | Crate ] -> [ > Player | > Crate ]. What does each design make easy, and what does each make impossible?
Discussion

In PuzzleScript the rules are fixed for the whole game and the board is only data; in Baba Is You the board is the rule source. PuzzleScript can express a rule in one line that this course needs a module for (Module 8 is that crate rule), and its author can invent mechanics without touching an engine. It can't let the player change a rule, because rules aren't objects in the level.

The two share more than they differ on. Both separate the rule table from the code that applies it. Both have to say in what order rules apply: PuzzleScript's documentation is blunt about it, "order does matter - these aren't abstract replacement rules floating around in a vacuum" (Execution Order), which is Module 9 in one sentence. Reading its docs after this module is the fastest way to see which of your design choices were about Baba Is You and which were about rule engines in general.

Further reading