Files
systemic-game-design-course/course/09-turn-pipeline/lesson.md
T
Lucas WintherandClaude Opus 5.5 34c655c1a4 feat: Module 11, Conditions (NOUN ON NOUN IS ..., checked per object at use; NEAR as the Expansion Drop)
Adds course/11-conditions: lesson, scenarios, nine test_11 tests in both
tracks, the cumulative reference (Module 10's plus conditions) and the NEAR
drop. The engine learns a condition role, rules carry conditions, and
Level.hasProperty and conversion evaluate content's condition checks against
the board whenever a rule is used. Resolution pairs only rules with the same
conditions. Catalog, harness contract, research notes and the diff-tracks
generator follow.

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

41 KiB
Raw Blame History

Module 9: The turn pipeline

In Module 8, a key press moved things and nothing else happened. This module makes it a whole turn. Things move, the rules are read again, objects turn into other objects, hazards destroy what they touch, and the level is won or lost. Every one of those steps reads the board the step before it left, so the order is part of the rules. The same board, played in a different order, ends differently. This module writes the order down once, as a list of phases, and runs it.

Part I had this too. In Module 3, the end of the outermost Phase ran a fixed sequence of steps: the Death Creation Step, then the Death Phase, then the check again. In Module 4 the sequence grew: "After the outermost Phase ends, Hearthstone does an Aura Update (Health/Attack), then does the Death Creation Step", and another Aura Update follows (Advanced rulebook). In both games, the order of the steps is a decision written down once, in one place.

Module 10 joins the two halves of the course. Module 11 comes back to Baba Is You for one more word, ON, and asks when a rule applies.

You need your Module 8 workspace with all Module 1–8 tests passing, under git. This module's tests also use the meta-noun ALL from the Module 7 Expansion Drop. If you skipped that drop, do it first; it is one line of data.

Observe

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

The wiki's Order of Operations page opens with this:

Within a turn, most properties and verbs take place exactly once. Conditions process when the game checks for what objects have a rule, which is usually right before it uses it.

Then it lists the turn. Shortened, in the course's words:

  1. Movement (domovement()): YOU first, then MOVE, AUTO and CHILL, then NUDGE, FEAR and SHIFT.
  2. Rule parsing.
  3. Conversions: REVERT, and "object transformations via IS and WRITE".
  4. Rule parsing.
  5. A second movement (moveblock()): FOLLOW, BACK, TELE, SHIFT. Then SELECT, the stack limit, and FALL.
  6. Rule parsing.
  7. Status (statusblock()): directions, TURN, FLOAT. Then FALL again.
  8. Rule parsing.
  9. The level block (levelblock()).
  10. block(): SINK, BOOM, WEAK, HOT/MELT, DEFEAT, OPEN/SHUT, EAT, MAKE, then BONUS, END and WIN, then out-of-bounds deletion and CRASH.
  11. Rule parsing.

On block(), the page says: "Deletion occurs after each step that removes objects, but all objects destroyed in the same step are destroyed simultaneously." And the Rule page says when "rule parsing" actually does anything: "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."

What the hazards do. All quotes are from the wiki's property pages:

  • SINK: "SINK is a property that causes an object to destroy all overlapping same-float objects along with itself when there is at least one overlapping object."
  • HOT: "HOT is a property that causes the object to destroy any same-float MELT objects it contacts." and "An object that is both HOT and MELT destroys itself."
  • DEFEAT: "If a DEFEAT object intersects with or is a YOU (or YOU2 or 3D), the YOU object will be destroyed."
  • FLOAT: "EAT and properties BONUS BOOM DEFEAT END HOLD HOT MELT OPEN SHUT SHIFT SINK TELE WEAK WIN require that the interacting objects are both FLOAT or both not FLOAT."

And winning:

  • WIN: "If an object is both WIN and YOU YOU2 3D, the level is won."
  • Also from WIN: "WIN is checked very late in the process: any other removal of either object involved will occur first and prevent WIN from WINning."
  • DEFEAT: "If an object is both WIN and DEFEAT, then DEFEAT takes priority and the YOU object is destroyed."

Last, conversions. The IS page: "BABA IS BABA prevents BABA from transforming into any object", and "BABA IS NOT BABA causes BABA to vanish in a puff of logic." The NOT page adds that X IS NOT X "negates X IS X, it enables other X IS Y rules, which can result in transformation in addition to the disappearance." Module 7 put these rules in force. This module carries them out.

Now put a board together. BABA IS YOU, LAVA IS HOT, and BABA IS MELT written down a column, with its MELT lying on a lava tile. Baba steps onto the lava and, in the same step, pushes MELT off the end of its sentence. Movement comes first and HOT/MELT comes late, in block(), with several "(rule parsing)" points in between. By the time HOT/MELT runs, BABA IS MELT is gone. Baba survives. No page shows this exact board. The course's research notes mark the outcome as inferred from the documented order, and so does this module's test.

Predict

Write your answers down before you open the discussion, for example in notes/09.md in your workspace.

  1. The lava board above. Does Baba survive? Would it if your turn checked hazards before reparsing?
  2. BABA IS is written with an empty tile after it. Baba pushes the word ROCK into that tile. Do the Babas become rocks this turn, or next turn?
  3. BABA IS BABA and BABA IS ROCK are both on the board. What does a turn do to Baba?
  4. KEY IS NOT KEY and KEY IS ROCK are both on the board. What is on the key's tile after a turn?
  5. BABA IS YOU, BABA IS FLOAT, SKULL IS DEFEAT. Baba steps onto a skull. What happens?
  6. Baba pushes a rock into water, and WATER IS SINK. What is left?
  7. FLAG IS WIN AND DEFEAT. Baba steps onto the flag. Won, lost, or neither?
  8. In your engine, where does the order of the turn live? If you had to move hazards before conversion, how many places would you edit?
  9. TEXT IS ROCK is written on the board. What is left after one turn? Which rules?
  10. ALL IS ROCK and BABA IS NOT ROCK, with a Baba and a key on the board. After a turn, what is each of them?
  11. SKULL IS DEFEAT AND MELT, LAVA IS HOT. A skull lies on a lava tile and Baba steps onto it. Does Baba survive?
Discussion
  1. Yes. The rules are parsed right after movement, so BABA IS MELT no longer exists when HOT/MELT runs. That is inferred from the order, not stated by a source. A turn that checks hazards with the rules from before the move destroys Baba. The rules didn't change; the order did.

  2. This turn. Conversion comes after the parse that follows movement, so the rule the move completed is already in force. The Babas move first, then become rocks where they stand. (With no ROCK IS YOU, nothing is YOU any more, and the level is lost.)

  3. Nothing. BABA IS BABA blocks every conversion out of Baba. Module 7's resolution pass already took BABA IS ROCK out of the active rules.

  4. A rock, and no key. KEY IS NOT KEY deletes the key. It also cancels KEY IS KEY, so nothing blocks KEY IS ROCK, and a rock appears where the key was: "transformation in addition to the disappearance". Module 7's Predict question 3 asked which comes first. In the course they happen in the same step: the key is deleted, and its rock is made as a new object.

  5. Nothing. Baba is FLOAT and the skull isn't, so they are on different float levels, and DEFEAT needs "the interacting objects are both FLOAT or both not FLOAT".

  6. Only Baba. The pushed rock lands on the water, and SINK destroys "all overlapping same-float objects along with itself". Baba was never on the water's tile.

  7. Lost. DEFEAT runs before WIN, and "any other removal of either object involved will occur first and prevent WIN from WINning". Baba is destroyed, so no YOU object is left to win.

  8. If the order is spread across functions that call each other (move, which reparses and then calls convert, which calls hazards), moving a phase means untangling those calls. If it is a list, it is one edit to the list. Question 1 shows why that matters: the game's order is a long list (see "How the real game differs"), and the course builds a short version of it.

  9. Rocks where the words were, and one rule: TEXT IS PUSH. TEXT names every text object, and the three words spelling TEXT IS ROCK are text. The sentence converts itself, along with every other sentence on the board. The parse after conversion finds no text at all, so every property is gone before the hazards run, and nothing is YOU: the level is lost. A rule that destroys the text that states it is the shortest demonstration that rules here are board state and nothing more.

  10. The key is a rock; Baba is still Baba. ALL names both of them, and BABA IS NOT ROCK names Baba. Module 7's resolution pass leaves ALL IS ROCK in force, because its subject is ALL, not BABA. So conversion has to do for each object what hasProperty does: gather every rule whose subject names this object, and let a negation among them win.

  11. Yes. The hazards run in a fixed order and each one's victims are removed before the next runs. HOT/MELT comes before DEFEAT, so the skull has melted by the time DEFEAT looks for something on Baba's tile. Swap those two and Baba dies. Inferred from the order of block(); no source shows this board.

Spec

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

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

The harness

Four new functions on Baba:

TypeScript Rust
step(dir): void step(&mut self, dir: Dir)
won(): boolean won(&self) -> bool
lost(): boolean lost(&self) -> bool
undo(): boolean undo(&mut self) -> bool
  • step(dir) plays one whole turn, in the course's order: Movement → Parse → Conversion → Parse → Hazards → Win, then one last parse, so that rules() matches the board. Movement is Module 8's, without its reparse. Module 8's move(dir) stays as it was.
  • won() is true once a turn's win check has found a YOU object that is WIN, or that shares its tile with a WIN object on the same float level. It stays true.
  • lost() is true when no object is YOU.
  • undo() puts the level back as it was before the last step or move, and returns true. Called again, it goes one further back. With nothing left to undo it returns false and changes nothing. place forgets the history: you can't undo past it.

What the phases do:

  • Conversion. An object named by X IS Y, where Y is a noun, becomes a Y where it stands, with the same id. X IS X blocks it, and so does an … IS NOT Y that names the object. An object named by X IS NOT X is deleted. Every object is decided from the board as movement left it.
  • Hazards, in this order, each one against the objects sharing a tile on the same float level, and each one destroying everything it finds at once, before the next runs:
    • SINK destroys every object it shares a tile with, and itself.
    • HOT destroys MELT objects, and an object that is both destroys itself.
    • DEFEAT destroys YOU objects, and an object that is both destroys itself.
  • Win runs after every hazard.

New words: HOT, MELT, SINK, DEFEAT, FLOAT.

The tests

The full statements are in course/09-turn-pipeline/scenarios.md.

  • test_09_lava_saved_by_reparse: Predict question 1. Baba stands on the lava and survives. The outcome is the course's inference.
  • test_09_mid_turn_conversion: Predict question 2. Both Babas are rocks after the turn, on the tiles they moved to.
  • test_09_identity_lock_blocks_conversion: Predict question 3, with a key under KEY IS ROCK to show that conversion works.
  • test_09_self_negation_deletes: Predict question 4.
  • test_09_float_separates_hazards: Predict question 5, with a key that is YOU and not FLOAT stepping onto a second skull. The key is destroyed.
  • test_09_sink_destroys_both: Predict question 6.
  • test_09_you_and_win: BABA IS YOU and BABA IS WIN. A turn wins the level; placing the text doesn't.
  • test_09_defeat_beats_win: Predict question 7.
  • test_09_negated_conversion_beats_group: Predict question 10.
  • test_09_conversion_reparses_before_hazards: Predict question 9, with a Baba that is MELT standing on lava. It survives, because the parse after conversion has removed every rule.
  • test_09_conversion_before_hazards: a key that is MELT and lies on lava becomes a rock in the same turn. The rock survives.
  • test_09_last_parse_matches_board: a hazard destroys a word. rules() after the turn no longer has that word's sentence.
  • test_09_hazard_order_melt_before_defeat: Predict question 11.
  • test_09_hazard_order_sink_before_melt: a key that is HOT and MELT lies on water that is SINK. Both are destroyed, because SINK runs first.
  • test_09_undo_restores_everything: a turn that moves, converts, destroys and wins, then undo(). Every tile's kindsAt and stackAt, every posOf, the rules and won() are as before the turn. The same turn again gives the same result, ids included.
  • test_09_undo_walks_back_and_stops: two steps, three undos. The third returns false.

The five from test_09_conversion_reparses_before_hazards to test_09_hazard_order_sink_before_melt look like a lot of tests for one list. Each exists because the turn makes a separate ordering decision there, and an engine with that one decision changed passed every earlier test in the course. Module 13 shows the experiment.

The first test:

#[test]
fn test_09_lava_saved_by_reparse() {
    let mut b = new_baba(empty_level(6, 5));
    write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
    write(&mut b, &[("LAVA", 0, 1), ("IS", 1, 1), ("HOT", 2, 1)]);
    write(&mut b, &[("BABA", 4, 2), ("IS", 4, 3)]);
    let melt_text = b.place("text:MELT", 4, 4);
    let lava = b.place("lava", 4, 4);
    let baba = b.place("baba", 3, 4);

    assert!(b.has_property(baba, "MELT"));

    b.step(Dir::Right);

    assert_eq!(b.pos_of(baba), Some((4, 4)));
    assert_eq!(b.pos_of(melt_text), Some((5, 4)));
    // Inferred from the order of operations, not stated by a source: Baba survives.
    assert_eq!(b.pos_of(lava), Some((4, 4)));
    assert_eq!(b.kinds_at(4, 4), vec!["baba", "lava"]);
    assert!(!b.rules().contains(&"BABA IS MELT".to_string()));
    assert!(!b.lost());
}
test("test_09_lava_saved_by_reparse", () => {
  const b = newBaba(emptyLevel(6, 5));
  write(b, [
    ["BABA", 0, 0],
    ["IS", 1, 0],
    ["YOU", 2, 0],
  ]);
  write(b, [
    ["LAVA", 0, 1],
    ["IS", 1, 1],
    ["HOT", 2, 1],
  ]);
  write(b, [
    ["BABA", 4, 2],
    ["IS", 4, 3],
  ]);
  const meltText = b.place("text:MELT", 4, 4);
  const lava = b.place("lava", 4, 4);
  const baba = b.place("baba", 3, 4);

  expect(b.hasProperty(baba, "MELT")).toBe(true);

  b.step("right");

  expect(b.posOf(baba)).toEqual([4, 4]);
  expect(b.posOf(meltText)).toEqual([5, 4]);
  // Inferred from the order of operations, not stated by a source: Baba survives.
  expect(b.posOf(lava)).toEqual([4, 4]);
  expect(b.kindsAt(4, 4)).toEqual(["baba", "lava"]);
  expect(b.rules()).not.toContain("BABA IS MELT");
  expect(b.lost()).toBe(false);
});

Run cargo test or npm test. The Module 9 tests fail to compile (Rust) or fail (TypeScript). Modules 1–8 still pass. That is RED.

Build

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

  • The turn is a list. Write the order down once, as a list of phases, and have step walk it. Movement doesn't call parsing, and parsing doesn't call conversion. Each phase is called by the turn.
  • Parse between phases. Module 8's move reparsed at the end. In the turn, parsing is a phase of its own. It runs after movement and again after conversion, and each phase uses the rules from the parse before it. Hazards and the win check share one set of rules: a hazard that destroys text doesn't change the rules until the turn's last parse.
  • Conversion: decide, then change. Decide what every object becomes from the board as it stands, then apply all of it. Otherwise BABA IS ROCK and ROCK IS BABA together would turn Baba into a rock and the new rock straight back into Baba. An object keeps its id and its tile and changes its kind. Remember Module 7: X IS X locks, and X IS NOT X deletes.
  • Hazards: destroy together, then the next. "All objects destroyed in the same step are destroyed simultaneously." Collect a hazard's victims first, then remove them. An object that is HOT and MELT destroys itself, and a second MELT object on its tile goes with it. Remove the first as soon as you find it, and the second no longer has HOT company, so it survives.
  • Float levels. Every hazard and the win check ask the same question: which other objects share this tile, on this float level? Answer it in one place.
  • Win last, lost any time. The win check runs after the hazards, so a destroyed YOU can't win. lost() just asks whether anything is YOU now.
  • Undo: save before, restore after. Before each step and move, save what the level will need to be put back; undo restores the last save. What exactly to save is Stress it question 3. Try to answer it before you read the undo test.
  • One more question. In Module 8, each movement property was something the planner ran. Is each hazard a branch in your hazards phase, or something the phase runs? The Expansion Drop brings a new hazard, and you won't be allowed to touch src/engine/.
Hint 1 — nudge

Write step as a loop over a constant list: [movement, parse, conversion, parse, hazards, win, parse]. Then write each phase as a function that does one thing to the board, using the rules it finds there. The tests then mostly check one phase each.

Hint 2 — concept
  • Split Module 8's move. Movement without the reparse becomes the first phase, and move becomes "movement, then parse", so the Module 8 tests keep passing.
  • Conversion is a plan too. For each object, list the active rules that name it. If one of them is X IS X, the object is locked. If one is X IS NOT X, it is deleted. Otherwise each X IS Y with Y a noun is a target, unless one of the rules naming this object is … IS NOT Y. Record the changes, then apply them: remove, or change the kind in place.
  • A hazard is a function from the level to a list of ids. SINK: every SINK object with company on its tile, plus the company. HOT/MELT: every MELT object that is HOT or has HOT company. DEFEAT: every YOU object that is DEFEAT or has DEFEAT company. The hazards phase runs them in order and removes each list before running the next.
  • "Company" means same float level. One function, sharing(id), returns the other objects on the tile whose FLOAT matches this one's. The hazards and the win check all use it.
  • Win is: some YOU object is WIN, or has WIN company.
Hint 3 — pseudo-code
TURN = [movement, parse, conversion, parse, hazards, win, parse]

step(dir):
    for phase in TURN: run phase

conversion():
    changes = []
    for o in objects:
        rules = active rules whose subject names o
        if some rule in rules is X IS X:      continue        // locked
        deleted = some rule in rules is X IS NOT X
        into = distinct noun complements of X IS Y in rules, other than o's own kind,
               less every Y that some negated rule in rules has as its complement
        if deleted or into: changes += (o, deleted, into)
    for (o, deleted, into) in changes:
        if deleted: remove o; place each of into on o's tile
        else: o.kind = into[0]; place each of into[1..] on o's tile

sharing(id) = other objects on id's tile with has(FLOAT) == has(id, FLOAT)

hazards():
    for hazard in content.hazards:          // sink, hot_melt, defeat
        remove every id in hazard(level)

sink(level)     = for s with SINK: if sharing(s) not empty: s and sharing(s)
hot_melt(level) = m with MELT where m is HOT or some o in sharing(m) is HOT
defeat(level)   = y with YOU where y is DEFEAT or some o in sharing(y) is DEFEAT

win():  if some y with YOU is WIN or has some WIN o in sharing(y): won = true
lost(): no object is YOU

When your tests pass, compare with the reference engine in course/09-turn-pipeline/reference/rust/src/ or course/09-turn-pipeline/reference/ts/src/. Look at the new engine/puzzle/turn (the phases and TURN), engine/puzzle/level (step, convert, sharing, the hazards phase and the win check), and the new content/hazards.

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

  • The turn is a constant in the engine. TURN lists the phases and step walks it. The order is the engine's because it is the game's rules for a turn, not any one word's.
  • The hazards are content. SINK, HOT/MELT and DEFEAT are three functions in src/content/. The engine runs them in the order content lists them, which is the wiki's block() order, and removes each one's victims before running the next. This is the same shape as Module 8's movement responses.
  • The engine knows YOU, WIN and FLOAT. YOU picks who moves (Module 8). WIN is the engine's own end-of-turn check. FLOAT is in the engine because it isn't a hazard. It decides which objects share a tile at all, and the win check needs that as much as the hazards do ("a same-float YOU YOU2 3D object", WIN). If each hazard filtered by FLOAT itself, a new hazard could forget to, and the engine's win check would need the rule anyway. So it lives in one place, sharing, and hazards can't get it wrong.
  • Negation is settled per object in conversion too. ALL IS ROCK with BABA IS NOT ROCK leaves every Baba a Baba. The resolution pass can't do this, because it compares subjects as words; conversion compares what the subjects name. It is the same rule hasProperty has followed since Module 7, applied in the second place that needs it.
  • Several conversions. "if you write A IS B and A IS C simultaneously in the same turn ... then you get both a B and a C for each A" (Advanced rulebook). The object becomes the first target and keeps its id; each other target is a new object on its tile.
  • X IS NOT X deletes in the conversion step, together with the conversions. The deleted object's other targets still appear, as new objects. No source gives the deletion its own place in the turn; see "How the real game differs".
  • won() stays true. In the game, winning ends the level. In the course, step keeps playing if you call it again, and won() stays true.
  • The last parse. The course's turn ends with a parse, as the game's does, so rules() after step matches the board. No phase reads it until the next turn.
  • Undo is a stack of snapshots in the level. Before each move or turn, Level saves a copy of the board (its objects, oldest first, with their ids, and the id counter) and won. undo pops the last one, puts it back and reparses. The rules are not saved. Replaying the inputs would work too (Stress it question 3); a snapshot makes undo cheap, with no replay. SpellGrid, in Module 10, has no undo: Part I's health, hands and log are state that the board doesn't hold.

How the real game differs

  • The order. The course's turn is a labelled simplification. Side by side, the course on the left and the game, from Order of Operations, on the right:

    Course Game
    Movement (YOU) Movement: YOU, then MOVE/AUTO/CHILL, NUDGE, FEAR, SHIFT, with OPEN/SHUT, WEAK/EAT, SWAP, PUSH/PULL and STOP checked on every step
    Parse Rule parsing
    Conversion (IS, and X IS NOT X) Conversions: REVERT, IS, WRITE ("X IS ALL takes the lowest priority")
    Parse Rule parsing
    Second movement: FOLLOW, BACK, TELE, SHIFT; then SELECT, the stack limit, FALL
    Rule parsing
    Status: directions, TURN, FLOAT; then FALL
    Rule parsing
    Level block
    Hazards: SINK, HOT/MELT, DEFEAT block(): SINK, BOOM, WEAK, HOT/MELT, DEFEAT, OPEN/SHUT, EAT, MAKE
    Win BONUS, END, WIN (the tail of block()), then out-of-bounds deletion, CRASH
    Parse Rule parsing
  • When parsing happens. The game reparses only "when any parsable object changes position or is destroyed, or an object is transformed to text" (Rule). The course reparses at every Parse phase. The result is the same; the game does less work.

  • FLOAT updates once per turn. "Whether an object is FLOATing or not updates once per turn, or at object creation (including transforms)" (FLOAT), in the status block. The course reads FLOAT from the rules in force whenever a hazard asks.

  • Words the course doesn't have. BOOM, WEAK (the Expansion Drop), EAT, MAKE, BONUS and END in block(); SAFE, which protects from SINK ("Overlapping objects are destroyed (unless SAFE)", SINK) and from MELT (MELT); HAS, "checked after every standard destruction" (Order of Operations); and the overlap half of OPEN/SHUT ("If an OPEN object is on ... a same-float SHUT object", OPEN), which the course checks only during movement.

  • Exceptions to DEFEAT beating WIN. The research notes record three, from the WIN page: an object that is YOU, DEFEAT, WIN and SAFE wins; a YOU object respawned by HAS can still win in the same turn; and LEVEL IS WIN AND DEFEAT wins. The course has no SAFE, HAS or LEVEL.

  • Conversions. The course converts only into nouns that name their own things: no X IS TEXT, no X IS ALL, no REVERT or WRITE. In the game, a stacked noun in A IS B parses more than once, which duplicates objects (research notes, from the Advanced rulebook). The course makes one object per distinct target.

  • Where X IS NOT X deletes. The NOT page says X IS NOT X "makes an object disappear" and enables "transformation in addition to the disappearance", but no source read gives the deletion its own place in the turn. Putting it in the conversion step is the course's reading.

  • The lava board is inferred. Baba surviving follows from the documented order. No source shows it.

  • lost() is the course's definition, "no YOU object remains". No source read describes what the game does when nothing is YOU.

Name it

The pattern is a phase pipeline: an ordered list of phases, each of which reads the state the previous one left. The list is data, so the order is visible in one place and changes in one place. Each phase does one job and doesn't call the next. The bugs this prevents are ordering bugs: a hazard that runs before the rules are re-read, a win that is checked before a DEFEAT. In a pipeline, each of those is a position in a list that you can read.

The reference's turn:

/// The turn, in order.
pub const TURN: [Phase; 7] = [
    Phase::Movement,
    Phase::Parse,
    Phase::Conversion,
    Phase::Parse,
    Phase::Hazards,
    Phase::Win,
    Phase::Parse,
];

/// Play one turn: the phases of `TURN`, in order.
pub fn step(&mut self, dir: Dir) {
    for phase in TURN {
        match phase {
            Phase::Movement => self.movement(dir),
            Phase::Parse => self.reparse(),
            Phase::Conversion => self.convert(),
            Phase::Hazards => self.run_hazards(),
            Phase::Win => self.check_win(),
        }
    }
}
/** The turn, in order. */
export const TURN: readonly Phase[] = ["movement", "parse", "conversion", "parse", "hazards", "win", "parse"];

/** Play one turn: the phases of `TURN`, in order. */
step(dir: Dir): void {
  for (const phase of TURN) {
    switch (phase) {
      case "movement":
        this.movement(dir);
        break;
      case "parse":
        this.reparse();
        break;
      case "conversion":
        this.convert();
        break;
      case "hazards":
        this.runHazards();
        break;
      case "win":
        this.checkWin();
        break;
    }
  }
}

Two levels of list are at work. The engine owns the turn, and content owns what happens inside the hazards phase: a list of hazards, each a function from the level to the objects it destroys. Module 8 had the same split. The engine owned the planner, and content owned the responses.

baba-is-auto writes its turn the same way, as a fixed run of calls in Game::MovePlayer (Game.cpp, line 495):

  • ProcessPlayerMove, then ParseRules, ProcessDirectionProperties and ProcessMoveProperty.
  • ParseRules, ProcessTransformations, and ParseRules again.
  • The hazards: ProcessSink, ProcessWeak, ProcessHotMelt, ProcessDefeat and ProcessOpenShut.
  • CheckPlayState, which sets won or lost, and a last ParseRules.

Each hazard there is a named function in the engine, and none of them checks FLOAT. Its own rule-language.md says its FLOAT "is not applied to the other overlap/effect properties". Compare that with hazards as content, and think about what each costs when a new hazard arrives. As in Module 8, baba-is-auto is an example, not an authority.

Run the coupling audit again: node tools/coupling-audit.mjs ~/my-engine should print coupling-audit: ok. The turn names no noun. Conversion turns whatever the rules name into whatever they say.

Expansion Drop

A new word arrives: WEAK. From the wiki's WEAK page: "If a WEAK object overlaps any same-float object, it is destroyed." It runs among the hazards: "Destruction by WEAK happens after SINK BOOM, but before other overlap checks."

The constraint, as always: do not edit anything under src/engine/. The harness doesn't change either. Your word list changes, and so may the rest of your content.

  1. Commit your workspace and note the commit: git add -A && git commit -m "module 9 done".
  2. Copy the expansion test for your track from course/09-turn-pipeline/expansion/tests/rust/test_09x.rs or course/09-turn-pipeline/expansion/tests/ts/test_09x.test.ts into your workspace's tests/. Run it and watch it fail.
  3. Add WEAK to your word list, and make it do what it says.
  4. When the tests pass, run the check from the course repository:
node tools/expansion-check.mjs ~/my-engine HEAD

The test, test_09x_weak_breaks, has two WEAK rocks. Baba steps onto one, which breaks; the other, alone on its tile, stays. The full scenario is in course/09-turn-pipeline/expansion/README.md.

Discussion: what this drop measures

If each hazard is a function the hazards phase runs, WEAK is one word in the list and one function in content, listed after sink:

/// A WEAK object that shares its tile with anything is destroyed.
fn weak(level: &Level) -> Vec<ObjectId> {
    with(level, "WEAK")
        .into_iter()
        .filter(|&id| !level.sharing(id).is_empty())
        .collect()
}
/** A WEAK object that shares its tile with anything is destroyed. */
function weak(level: Level): ObjectId[] {
  return withProperty(level, "WEAK").filter((id) => level.sharing(id).length > 0);
}

That is the reference answer, in course/09-turn-pipeline/expansion/solution/: words.json with WEAK added, and the hazards with weak between sink and hot_melt. Because sharing already applies float levels, weak gets them for free. Two WEAK objects on one tile both break, with no extra code.

Shapes that fail this drop:

  • Hazards as branches in the turn. If the hazards phase says if SINK ... if MELT ... if DEFEAT ..., WEAK is a fourth branch, under src/engine/. Make hazards something the phase runs, as a normal commit, then redo the drop.
  • Destroying while scanning. If a hazard removes objects as it finds them, two WEAK rocks on one tile go wrong. The first has company and is removed; then the second is alone, and survives. The WEAK page says "Multiple same-float WEAK objects overlapping each other are all destroyed." Collect, then remove.
  • Float level in each hazard. If every hazard filters by FLOAT itself, WEAK has to remember to. This test doesn't catch it if it forgets. The next FLOAT puzzle would.

How the real game differs

  • WEAK also breaks on collision: "If a WEAK object attempts to move into a solid object and fails (e.g. STOP PULL or blocked PUSH), regardless of float levels, it is destroyed", and "If an object attempts to move into a same-float WEAK solid object that is not PUSHable, the movement is not blocked" (WEAK). Those are movement checks, which would be movement responses in the course. The drop leaves them out.
  • BOOM runs between SINK and WEAK in the game. The course has no BOOM.

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. Take your turn's list and change its order, one change at a time, running the whole test suite after each: drop the parse after conversion; run hazards before conversion; run the win check before the hazards; drop the last parse; reverse the list of hazards. For each change, which test fails? Is there any change after which every test still passes?
Discussion

With this module's sixteen tests, each of those changes fails at least one test, and the name of the failing test tells you which decision you broke. On the reference:

Change to the turn Fails
No parse between movement and conversion test_09_mid_turn_conversion
No parse between conversion and hazards test_09_conversion_reparses_before_hazards
Hazards before conversion test_09_conversion_before_hazards, test_09_conversion_reparses_before_hazards
Win check before hazards test_09_defeat_beats_win
No last parse test_09_last_parse_matches_board
DEFEAT before HOT/MELT test_09_hazard_order_melt_before_defeat
Hazards reversed both test_09_hazard_order_… tests

With the eight tests this module first shipped with, five of those rows were empty. The engine could run hazards before conversion, skip two of its parses, or run the hazards backwards, and stay green. Nobody had made those mistakes, so nobody had noticed that nothing would catch them.

A changed program that the tests don't notice is called a surviving mutant, and looking for them on purpose is mutation testing: it "involves making small changes to the program being tested", and a test "detects, and therefore rejects, a mutant upon test failure" (Mutation testing, Wikipedia). A pipeline is the ideal subject, because its mutants are easy to list: every pair of neighbours swapped, every phase removed. If your order is a list in one place, each mutant is a one-line edit. That is a second payoff of writing the order down as data.

Finding the scenario that kills a mutant is a design exercise in its own right. For "HOT/MELT before DEFEAT" you need one object that is a victim of the first hazard and a weapon of the second. Try constructing the one for "SINK before HOT/MELT" before you read its test.

  1. Is the order of SINK and HOT/MELT observable at all? Try to find a board, with objects that have one of those properties each, where swapping the two hazards changes what survives.
Discussion

With one property per object, there isn't one. For HOT to melt something, the two must share a tile and a float level. If a SINK object shares that tile and level, it destroys both, whichever hazard runs first; if it doesn't, it affects neither. The victim sets overlap but the survivors are the same.

The order shows only through an object that destroys itself: something HOT and MELT, alone with water. HOT/MELT first removes it and leaves the water with no company; SINK first takes both. That is test_09_hazard_order_sink_before_melt, and it needed an object with two properties to exist.

An order that can't be observed isn't a rule of the game yet, only a habit of the implementation. It becomes a rule the moment something makes it visible, and extensions tend to do that. In Module 10 a hazard's victims can be minions with Deathrattles, and then the order in which two hazards kill is the order in which two Deathrattles resolve, on any board. Decide orders deliberately even when today's content can't tell the difference; tomorrow's will.

  1. Your undo() saves something before each step and restores it. What is the smallest thing you can save? Write down your list, then compare it with what the reference's Level saves (save and undo in engine/puzzle/level). Then test it beyond the two tests: play twenty random steps, undo them all, play the same twenty again, and compare every kindsAt, stackAt, rules() and posOf with the first run.
Discussion

Not the rules: they are compiled from the board, so restoring the board and reparsing gets them back. That is the payoff Module 8's last question promised, and it is why undo is cheap in Baba Is You and would be expensive in an engine that edited its rule list in place.

"The board" is more than it looks, though. Try the cheapest snapshot, a list of (kind, x, y) read off with kindsAt, and rebuild the level from it. The replay will sooner or later differ from the first run, and the differences show what else is state:

  • Ids. A rebuilt level numbers its objects afresh. Anything outside the engine that holds an id (your test, a UI) now points at the wrong object. Module 1's handles are only stable if undo preserves them.
  • Age. The reference breaks ties by object age: the oldest SHUT partner is the one destroyed, and the older of two YOU objects on a line moves first. kindsAt returns kinds sorted by name, so a snapshot made from it forgets which object on a tile was older, and two objects of different kinds can swap seniority. stackAt shows the order a snapshot has to keep.
  • won. It stays true once set, and it is not on the board.

So the smallest correct snapshot is every object's id, kind and position, in age order, plus the id counter and won. That is exactly what the reference saves: a copy of the board (its objects in age order, with ids, and its id counter) and won, and nothing else. test_09_undo_restores_everything checks each item: ids through posOf, age through stackAt, won() directly, and the id counter by playing the turn again and getting the same id for the new flag. The exercise is finding that list; an undo that is right nineteen times in twenty comes from guessing it.

The other design is to save nothing but the inputs and replay all but the last. It can't forget a piece of state, because it never copies any. It costs a replay per undo, and it requires the engine to be deterministic, which yours is. Robert Nystrom's Command chapter covers both designs; Martin Fowler's Event Sourcing is the second one taken as an architecture: "we can also use the event log to reconstruct past states".

Further reading

  • The Baba Is You Wiki's Order of Operations: the whole turn. Read it with this module's turn beside it and mark each line the course leaves out.
  • The wiki's SINK, HOT, MELT, DEFEAT, WIN and FLOAT pages: every hazard in this module, and the exceptions it leaves out (SAFE, HAS, LEVEL). Then WEAK for the drop.
  • baba-is-auto, Game::MovePlayer in Sources/baba-is-auto/Games/Game.cpp (line 495), and the hazard functions and CheckPlayState after it. It is a turn as a fixed list of calls, in C++. It is an example, not an authority on the game: its own docs list where it diverges (rule-language.md).