Files
systemic-game-design-course/course/09-turn-pipeline/lesson.md
T
Lucas WintherandClaude Opus 5.5 1f3359cfe2 feat: module 09 turn pipeline
Module 9 lesson, scenarios, tests (8, both tracks), reference engines
(TURN phase list, conversion, content-registered hazards, win/lost),
WEAK Expansion Drop, harness.md M9 semantics, research notes (WEAK page,
baba-is-auto Game::MovePlayer, X IS NOT X placement reading).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 04:28:17 +01:00

30 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.

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.

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

Spec

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

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

The harness

Three new functions on Baba:

TypeScript Rust
step(dir): void step(&mut self, dir: Dir)
won(): boolean won(&self) -> bool
lost(): boolean lost(&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.

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

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 eight 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.
  • 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. 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
        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.
  • 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.

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.

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).