DEV Community

Cover image for Puzzle Classic "Baba is You" recreated in JavaScript - Behind the Code
Michael Z
Michael Z

Posted on • Originally published at michaelzanggl.com

Puzzle Classic "Baba is You" recreated in JavaScript - Behind the Code

"Baba is You" is a unique and innovative puzzle game that offers players the ability to dynamically change the game's rules as they progress through each level. The game presents its rules as blocks that players can manipulate. In other words, it's a low-code puzzle game.

How the Game works

baba is you example

To get a better understanding of the mechanics, let's break down how to win and lose in this level:

  • The player controls Baba, the sheep-like creature you see stuck inside a block of walls.
  • The "WALL IS STOP" rule is active, preventing Baba from moving out.
  • By moving to the left, the player can push the "WALL" block and break up the "WALL IS STOP" rule, allowing Baba to move through walls.
  • Breaking up the "BABA IS YOU" rule results in a loss as there is nothing left that "IS YOU".
  • Pushing up the "WIN" block creates the "FLAG IS WIN" rule.
  • To win the level, the player must move Baba to the flag while the "FLAG IS WIN" rule is active.

This is just one way to beat this level. An alternative solution would be to move the blocks around to create the vertical rule "BABA IS WIN" while keeping the horizontal "BABA IS YOU".

In later levels, you might be required to

  • Push the text block WALL in place of BABA, so it creates the rule "WALL IS YOU" -> You now have control of all WALL objects
  • Create the rule "WALL IS FLAG", this will turn all wall objects into flags

Everytime I played on the game, the itch to figure out how it was developed grew and grew. So, I finally decided to scratch that itch by recreating it in JavaScript.

Terminology

Before we start coding, let's get the terminology straight.

  • Blocks refer to any object or text on the game grid
  • "Nouns" are blocks that represent elements such as WALL, BABA, or FLAG and can have properties.
  • Operators are linking verbs such as IS, AND, or ON. They link nouns with other nouns or properties
  • Properties are verbs like STOP, WIN, PUSH, MELT, and adjectives like HOT, which can be linked with nouns.
  • An "object" is the actual game element, such as the sheep-like creature Baba or the walls that Baba was trapped in
  • "Text" refers to the textual representation of an object and is used to make up the rules of the game.

Coding

Now, to the fun part!

The Level

A level consists of a three dimensional array to hold the grid. Row, cell, layer, or x, y, z.
We need that final dimension as multiple blocks can be on top of each other in a single cell (like Baba on top of the flag).

For example:

[
  [[text(Baba)]], [[text(IS)]], [[text(YOU)]],
  [[BABA, FLAG]]
]
Enter fullscreen mode Exit fullscreen mode

Now creating a big level in this format is painful. Instead I lay out the level on a a spreadsheet, then copy the cells as csv. The class "Level" takes that stringified CSV and deserializes it at the start of the game into the structure you see above.

  • cells ending with ! represent the actual objects, while others represent text blocks
  • you can place multiple blocks in a cell by separating them by a space like ,BABA! FLAG!,
new Level(`
,,,,,,,,,
,BABA,IS,YOU,,,FLAG,IS,,
,,,,,,FLAG!,,WIN,
,,,,,,,,,
,WALL!,WALL!,WALL!,WALL!,WALL!,,,,
,WALL!,,,,WALL!,,,,
,WALL!,,WALL,BABA!,WALL!,,,,
,WALL!,,IS,,WALL!,,,,
,WALL!,,STOP,,WALL!,,,,
,WALL!,WALL!,WALL!,WALL!,WALL!,,,,
`)
Enter fullscreen mode Exit fullscreen mode

For each move the player makes, we serialize the current state of the board back into that format and save it in a timeline array. This allows for"undoing a move", just like in the game.

undo() {
  const previousBoard = this.timeline.pop()
  if (!previousBoard) return
  this.board = deserializeBoard(previousBoard)
  this.updateRules()
}
Enter fullscreen mode Exit fullscreen mode

Blocks

Let's look at how blocks are defined on the code-side.

First, the base class. It's not used directly, but we will inherit nouns, operators, and properties from this class:

class Block {
  constructor(level, position) {
    this.id = this.constructor.name.toUpperCase()
    this.level = level
    this.position = position
  }

  getRow() {
    return this.level.board[this.position.row]
  }

  getCell() {
    return this.getRow()[this.position.cell]
  }
}
Enter fullscreen mode Exit fullscreen mode

And here's the base classes for the individual categories (nouns, operators, and properties):

class Noun extends Block {
  type = 'noun'
  hasProperty(property) {}
}

class Operator extends Block {
  type = 'operator'
}

class Property extends Block {
  type = 'property'

  static onBeforeLand() { return true }
  static onAfterLand() {}
}
Enter fullscreen mode Exit fullscreen mode

Now, to create an actual operator, noun, or property, we simply extend from these classes:

class Baba extends Noun {}
class Is extends Operator {}
class You extends Property {}
class Hot extends Property {}
class Push extends Property {}
class Text extends Property {
  // to reference the actual object. 
  // e.g. The ref for the text BABA is an instance of the object BABA
  ref = null
}
Enter fullscreen mode Exit fullscreen mode

Properties have two event listeners "onBeforeLand" and "onAfterLand", which is all we need to express every interaction between blocks.

Let's see some interactions in action!

Block Interactions

Here's the logic for the DEFEAT property:

if block A touches a block with the property DEFEAT, block A gets deleted:

class Defeat extends Property {
  static onAfterLand(movingBlock, blockWithProperty, { direction, level }) {
    level.deleteBlock(movingBlock)
  }
}
Enter fullscreen mode Exit fullscreen mode

For an interaction between blocks with certain properties, let's look at HOT and MELT.

If block A is HOT and it touches block B with the property MELT, block B gets deleted. We can express it like this:

class Melt extends Property {}

class Hot extends Property {
  static onAfterMove(movingBlock, blockWithProperty, { direction, level }) {
    if (movingBlock.hasProperty('melt')) {
      level.deleteBlock(movingBlock)
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

block.hasProperty is a method on the Noun class that checks if there's a rule "noun-of-block IS given-property":

class Noun extends Block {
  type = 'noun'
  hasProperty(property) {
    return this.level.rules.some(rule => {
      const [noun, operator, ruleProperty] = rule
      return noun === this.id && operator === 'IS' && ruleProperty === property.toUpperCase()
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

We'll look at how the rules are detected at the end as it's the most complex part of the game.

How to move...

We've seen how blocks interact with each other. Let's check out what happens when the player moves on the grid and how the events on the properties are triggered:

class Level {
  move(direction) {
    // avoid moving if the game is already won/lost
    if (this.progress !== 'running') return false;

    // push the current state into the timeline to allow for undoing
    this.timeline.push(serializeBoard(this.board))

    // get all movable blocks on the board
    const movableBlocks = this.getBlocksWithProperties(['you', 'move'])
    this.moveBlocks(direction, movableBlocks)

    // check if the user won/lost -> we look at it later
  }
}
Enter fullscreen mode Exit fullscreen mode

So in essence, we retrieve all movable blocks and call the method "moveBlocks". Let's look at that:

moveBlocks(direction, blocks) {
  const eventOptions = { level: this, direction }
  // for each block...
  // make sure it doesn't exceed the boundaries of the board
  if (!block.canMove(direction)) {
    return false
  }

  const { landingCell, rowIndex, cellIndex } = this.getNextPosition(block, direction)

  // landing cell can have multiple layers. Like Baba on top of Flag
  for (const landingBlock of landingCell) {
    // if block is ROCK and "ROCK IS PUSH", this returns ["PUSH"]
    for (const property of landingBlock.getRuleProperties()) {
      // if any property hinders moving, abort
      if (!property.onBeforeLand(block, landingBlock, eventOptions)) {
        return false
      }
    }
  }

  // move the block by deleting it from the current position and adding it to the new one
  this.deleteBlock(block)
  const blocksInCell = this.board[rowIndex][cellIndex].push(block)
  block.position = { row: rowIndex, cell: cellIndex, z: blocksInCell - 1 }

  this.updateRules()

  // call onAfterLand
  landingCell.forEach(landingBlock => {
    landingBlock.getRuleProperties().forEach((property) => {
      property.onAfterLand(block, landingBlock, eventOptions)
    })
  })


  this.emit('after:move') // allow the UI to rerender
  return true
}
Enter fullscreen mode Exit fullscreen mode

Here you can see both the "onBeforeLand" and "onAfterLand" events being triggered!

Together with this, let's see how the PUSH property is implemented.

Rules such as "ROCK IS PUSH" make rocks pushable.

class Push extends Property {
  static onBeforeLand(movingBlock, blockWithProperty, { direction, level }) {
    return level.moveBlocks(direction, [blockWithProperty])
  }
}
Enter fullscreen mode Exit fullscreen mode

Did you catch it?

If Baba stands in front of a pushable rock and moves in its direction, level.moveBlocks() will trigger the "onBeforeLand" event on "Push", which will once again trigger level.moveBlocks on the rock. We've got recursion!

This is neat because it allows us to very easily do a recursive collision check.

Say, Baba is standing in front of three text blocks "BABA" "IS" "YOU", which are positioned at the very right of the board.
If you try to move to the right, it will recursively attempt to push each block until "onBeforeLand" on the "YOU" block will return false, which will prevent all of these blocks from moving due to this line:

// if any property hinders moving, don't move at all
if (!property.onBeforeLand(block, landingBlock, { level: this, direction: direction })) {
  return false
}
Enter fullscreen mode Exit fullscreen mode

Complements/Transformations

There is a special type of rule that transforms objects. We looked at rules like "ROCK IS PUSH" which adds an ability to the rock. But you can also link two nouns.

"ROCK IS BABA" for example will turn all rock objects on the board into a Baba object. We can perform this transformation after a move like this:

for (const [subject, complement] of complements) {
  // get all ROCK objects
  for (const block of this.getBlocksFor(subject.id)) {
    // replace them with new BABA objects
    const instance = new complement.constructor(this, block.position)
    block.getCell()[block.position.z] = instance
  }
}
Enter fullscreen mode Exit fullscreen mode

We get the complements from the rules, which we will look at later.

Detecting the game's progress

For winning, we can use an event listener on the Win property:

class Win extends Property {
  static onAfterLand(movingBlock, blockWithProperty, { level }) {
    if (movingBlock.hasProperty('you')) {
      level.progress = 'won'
      level.emit('has:won')
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  • After every move, we also call "onAfterMove" on every block with a rule again. This is to check for rules like "BABA IS YOU AND WIN" or "BABA IS HOT AND MELT"
  • At that time, we also check the number of blocks with the property "YOU" and mark the progress as lost if no block is left.

UI Integration

This was the last thing I worked on, as I initially just created unit tests to test the game logic. This allowed me to instantly catch regressions and allow for safe refactoring.

test('can read vertical rules', () => {
  const level = new Level(`
    BABA,,FLAG
    IS,,IS
    YOU,,WIN
  `)

  expect(level.rules).toEqual([
    ['TEXT', 'IS', 'PUSH'], // this is an implicit rule set by default
    ['BABA', 'IS', 'YOU'],
    ['FLAG', 'IS', 'WIN'],
  ])
})
Enter fullscreen mode Exit fullscreen mode

For the UI, we make our code emit events at key moments, like "after:move" to rerender the board, "has:won" to detect when the user has won, or "after:update:rules" when the rules are updated (to render the current ruleset).

We also register some keyup event listerers to allow for moving and undoing!

level.on('has:won', () => {
  alert('you have won!')
})
level.on('after:move', renderBoard)
level.on('after:update:rules', renderRules)

window.addEventListener('keyup', event => {
  const keyDirections = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' }
  if (keyDirections[event.key]) {
    level.move(keyDirections[event.key])
  }

  if (event.key === 'z') {
    level.undo()
  }
})
Enter fullscreen mode Exit fullscreen mode

As for renderBoard we simply loop through the board and render the blocks on a CSS grid element, nothing special here.

For the game assets, I scraped the GIFs from the official wiki and named them after their corresponding words. While this is sufficient for experiments like this, it is important to note that using copyrighted material without permission could result in infringement issues if you plan to publish or distribute this.

Rules

The hardest part is detecting the rules of the game, since there are so many possible combinations to consider:

  • Rules can appear both vertically and horizontally
  • We must detect both rules between nouns and properties, as well as subject complements
  • "BABA AND ROCK AND WATER IS PUSH" makes all Baba, Rock, and Water blocks pushable
  • "BABA IS YOU AND PUSH" makes Baba controllable and pushable.
  • "BABA AND ROCK IS PUSH AND MELT" makes... you guessed it

We want to encapsulate all this complexity in one function so we don't need to deal with all these rules elsewhere.

So if you have rules like "BABA IS WATER" and "BABA AND ROCK IS PUSH AND HOT", the method "updateRules" should create the following four rules:

["BABA IS PUSH", "ROCK IS PUSH", "BABA IS HOT", "ROCK IS HOT"]

as well as the following complement: ["BABA", "WATER"]

To begin, we start by finding all noun text blocks, as these are the starting points.

class Level {
  updateRules() {
    // this rule is always present
    const rules = [['TEXT', 'IS', 'PUSH']]
    const complements = []

    // rules always start with nouns, so let's collect all:
    const nounTextBlocks = this.getBlocksFor('text')
      .filter(block => block.ref.type === 'noun')

    function addRule(block, axis) {
      // ...
    }

    // check both horizontally and vertically
    for (const textBlock of nounTextBlocks) {
      addRule(textBlock, 'row')
      addRule(textBlock, 'cell')
    }

    // remove any duplicates using https://github.com/MZanggl/flooent
    this.rules = unique(rules, rule => rule.join(' '))
    this.complements = complements
  }
}
Enter fullscreen mode Exit fullscreen mode

This code provides a solid starting point. For each noun found, the "addRule" function is called for both the horizontal and vertical axes. Now, let's dive into how the "addRule" function works.

Consider the rule "BABA AND ROCK IS PUSH AND WATER". We can split this into two parts: the subject "BABA AND ROCK" and the predicate "PUSH AND WATER". The key here is to identify the "IS" operator as that's where the split happens!

The "subject" side should not contain any properties, as "BABA AND PUSH IS ..." would not make sense. We begin by collecting all adjacent text blocks that fit the sequence:

function addRule(block, axis) {
  const ruleBlocks = [block]
  let position = { ...block.position }
  const subjectSequence = [['noun'], ['operator']]
  const predicateSequence = [['property', 'noun'], ['operator']]
  let indexOfIsOperator = -1

  // loop and add blocks to "ruleBlocks" until the next cell is invalid
  while(true) {
    // increment the position on the x or y axis and find a text block there
    position[axis]++
    const nextTextBlock = this.board[position.row]?.[position.cell]?.find(block => block.id === 'TEXT')
    if (!nextTextBlock) break

    if (nextTextBlock.ref.id === 'IS') {
      // disallow multiple IS operators in a single rule
      if (indexOfIsOperator >= 0) break
      indexOfIsOperator = ruleBlocks.length
    } else {
      // check if the sequence matches to 
      // avoid bogus rules like "BABA ROCK IS" or "BABA AND IS"
      const parity = indexOfIsOperator === -1 ? subjectSequence : predicateSequence
      const remainder = ruleBlocks.length % 2
      if (!parity[remainder].includes(nextTextBlock.ref.type)) break
    }

    ruleBlocks.push(nextTextBlock)
  }
  // ... quick interruption
}
Enter fullscreen mode Exit fullscreen mode

We now have a ruleBlocks array that contains valid text blocks to make up a rule. However, we still need to handle cases such as "BABA AND ROCK IS PUSH" and detect complements/transformations like "BABA IS ROCK".

To achieve this, we can simply discard the AND operators, split the array into the "subject side" and "predicate side", and then cross join both sides.

To split the array at the position of "IS," we can use the "point" API provided by flooent.

const [subjectBlocks, predicateBlocks] = point(ruleBlocks, indexOfIsOperator).split()

// we expect every operator except IS to be AND, so we can just discard them
const discardAND = block => block.ref.id !== 'AND'

// cross join - if one side is empty, nothing gets added
for (const subjectBlock of subjectBlocks.filter(discardAND)) {
  for (const predicateBlock of predicateBlocks.filter(discardAND)) {
    // subject block is always a noun, if predicate block is also
    // a noun, it goes to the complements, otherwise, it's a rule
    if (predicateBlock.ref.type === 'noun') {
      complements.push([subjectBlock.ref, predicateBlock.ref])
    } else {
      rules.push([subjectBlock.ref.id, "IS", predicateBlock.ref.id])
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

And with that, we have completed the basic integration of "Baba is You"! Although I haven't provided the full solution in this article, I hope this has provided a solid foundation for readers to piece together the presented code and potentially extend it with more advanced mechanics.

However, it's worth noting that the real game is both affordable and easily accessible, with the added bonus of including a level editor. Therefore, unless your goal is purely for learning and experimentation purposes, it may not make much sense to recreate the game from scratch. Regardless, I hope this article has provided some insight into the game's mechanics and how they can be implemented using JavaScript.

Top comments (0)