Baba.undo() restores the level before the last step or move (board snapshot: objects in age order with ids, the id counter, and won; rules recompiled). place clears the history. SpellGrid's undo throws/panics 'not supported'. stackAt / stack_at lists a tile's kinds oldest first, on Baba and SpellGrid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
41 KiB
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.
This is the last Baba Is You module. Module 10 joins the two halves of the course.
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:
- Movement (
domovement()): YOU first, then MOVE, AUTO and CHILL, then NUDGE, FEAR and SHIFT. - Rule parsing.
- Conversions: REVERT, and "object transformations via IS and WRITE".
- Rule parsing.
- A second movement (
moveblock()): FOLLOW, BACK, TELE, SHIFT. Then SELECT, the stack limit, and FALL. - Rule parsing.
- Status (
statusblock()): directions, TURN, FLOAT. Then FALL again. - Rule parsing.
- The level block (
levelblock()). block(): SINK, BOOM, WEAK, HOT/MELT, DEFEAT, OPEN/SHUT, EAT, MAKE, then BONUS, END and WIN, then out-of-bounds deletion and CRASH.- 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.
- The lava board above. Does Baba survive? Would it if your turn checked hazards before reparsing?
BABA ISis written with an empty tile after it. Baba pushes the wordROCKinto that tile. Do the Babas become rocks this turn, or next turn?BABA IS BABAandBABA IS ROCKare both on the board. What does a turn do to Baba?KEY IS NOT KEYandKEY IS ROCKare both on the board. What is on the key's tile after a turn?BABA IS YOU,BABA IS FLOAT,SKULL IS DEFEAT. Baba steps onto a skull. What happens?- Baba pushes a rock into water, and
WATER IS SINK. What is left? FLAG IS WIN AND DEFEAT. Baba steps onto the flag. Won, lost, or neither?- 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?
TEXT IS ROCKis written on the board. What is left after one turn? Which rules?ALL IS ROCKandBABA IS NOT ROCK, with a Baba and a key on the board. After a turn, what is each of them?SKULL IS DEFEAT AND MELT,LAVA IS HOT. A skull lies on a lava tile and Baba steps onto it. Does Baba survive?
Discussion
-
Yes. The rules are parsed right after movement, so
BABA IS MELTno 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. -
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 isYOUany more, and the level is lost.) -
Nothing.
BABA IS BABAblocks every conversion out of Baba. Module 7's resolution pass already tookBABA IS ROCKout of the active rules. -
A rock, and no key.
KEY IS NOT KEYdeletes the key. It also cancelsKEY IS KEY, so nothing blocksKEY 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. -
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".
-
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.
-
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.
-
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.
-
Rocks where the words were, and one rule:
TEXT IS PUSH.TEXTnames every text object, and the three words spellingTEXT IS ROCKare 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 isYOU: 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. -
The key is a rock; Baba is still Baba.
ALLnames both of them, andBABA IS NOT ROCKnames Baba. Module 7's resolution pass leavesALL IS ROCKin force, because its subject isALL, notBABA. So conversion has to do for each object whathasPropertydoes: gather every rule whose subject names this object, and let a negation among them win. -
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 thatrules()matches the board. Movement is Module 8's, without its reparse. Module 8'smove(dir)stays as it was.won()is true once a turn's win check has found aYOUobject that isWIN, or that shares its tile with aWINobject on the same float level. It stays true.lost()is true when no object isYOU.undo()puts the level back as it was before the laststepormove, and returns true. Called again, it goes one further back. With nothing left to undo it returns false and changes nothing.placeforgets the history: you can't undo past it.
What the phases do:
- Conversion. An object named by
X IS Y, whereYis a noun, becomes aYwhere it stands, with the same id.X IS Xblocks it, and so does an… IS NOT Ythat names the object. An object named byX IS NOT Xis 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 underKEY IS ROCKto show that conversion works.test_09_self_negation_deletes: Predict question 4.test_09_float_separates_hazards: Predict question 5, with a key that isYOUand notFLOATstepping onto a second skull. The key is destroyed.test_09_sink_destroys_both: Predict question 6.test_09_you_and_win:BABA IS YOUandBABA 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 isMELTstanding on lava. It survives, because the parse after conversion has removed every rule.test_09_conversion_before_hazards: a key that isMELTand 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 isHOTandMELTlies on water that isSINK. Both are destroyed, because SINK runs first.test_09_undo_restores_everything: a turn that moves, converts, destroys and wins, thenundo(). Every tile'skindsAtandstackAt, everyposOf, the rules andwon()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
stepwalk 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
movereparsed 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 ROCKandROCK IS BABAtogether 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 Xlocks, andX IS NOT Xdeletes. - 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
stepandmove, save what the level will need to be put back;undorestores 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, andmovebecomes "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 isX IS NOT X, it is deleted. Otherwise eachX IS YwithYa 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.
TURNlists the phases andstepwalks 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'sblock()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,WINandFLOAT.YOUpicks who moves (Module 8).WINis the engine's own end-of-turn check.FLOATis 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 ROCKwithBABA IS NOT ROCKleaves 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 rulehasPropertyhas 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 Xdeletes 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,stepkeeps playing if you call it again, andwon()stays true.- The last parse. The course's turn ends with a parse, as the game's does, so
rules()afterstepmatches the board. No phase reads it until the next turn. - Undo is a stack of snapshots in the level. Before each move or turn,
Levelsaves a copy of the board (its objects, oldest first, with their ids, and the id counter) andwon.undopops 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 makesundocheap, with no replay. SpellGrid, in Module 10, has noundo: 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, MAKEWin BONUS, END, WIN (the tail of block()), then out-of-bounds deletion, CRASHParse 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 DEFEATwins. The course has no SAFE, HAS or LEVEL.Conversions. The course converts only into nouns that name their own things: no
X IS TEXT, noX IS ALL, no REVERT or WRITE. In the game, a stacked noun inA IS Bparses more than once, which duplicates objects (research notes, from the Advanced rulebook). The course makes one object per distinct target.Where
X IS NOT Xdeletes. 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, thenParseRules,ProcessDirectionPropertiesandProcessMoveProperty.ParseRules,ProcessTransformations, andParseRulesagain.- The hazards:
ProcessSink,ProcessWeak,ProcessHotMelt,ProcessDefeatandProcessOpenShut. CheckPlayState, which sets won or lost, and a lastParseRules.
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.
- Commit your workspace and note the commit:
git add -A && git commit -m "module 9 done". - Copy the expansion test for your track from
course/09-turn-pipeline/expansion/tests/rust/test_09x.rsorcourse/09-turn-pipeline/expansion/tests/ts/test_09x.test.tsinto your workspace'stests/. Run it and watch it fail. - Add
WEAKto your word list, and make it do what it says. - 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, undersrc/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.
- 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.
- 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.
- 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'sLevelsaves (saveandundoinengine/puzzle/level). Then test it beyond the two tests: play twenty random steps, undo them all, play the same twenty again, and compare everykindsAt,stackAt,rules()andposOfwith 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.
kindsAtreturns 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.stackAtshows 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::MovePlayerinSources/baba-is-auto/Games/Game.cpp(line 495), and the hazard functions andCheckPlayStateafter 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).