Tags


While playing around with RxJS, I thought it would be interesting to create a Snake and Ladders game that I played during my childhood days. A typical game is played by 3-4 players with a board size of 100 cells and a dice. The board has snakes and ladders on it. Rules are very simple; when a player ends up in a position where the ladder starts, it gets a lift to a position where the ladder ends and if a player ends up in a position where a snake’s mouth starts, it gets gulped down by the snake and reach the tail as a new position. Otherwise, the player moves to a new position from the old by the throw of a dice. Players take turn at throwing the dice.

In the reactive version using RxJS, lets start with a generator that would push a player (represented as a value starting from 0 for first player, 1 for second player and so on…) every few milliseconds, in my case 100ms. In the increment function, lets bump up the value by 1 and finally in the ‘Return Value’ function where the generated numbers are cast out by the number of players using mod operator. So if there are 3 players, the first player would start at 0 and the third would have number of 2. I’m using Node.js to run this and have imported the RxJS module. Here is what I ended up creating for starters.

var Rx = require("rx");

var howManyPlayers = 3;

Rx.Observable.generateWithRelativeTime(0,
  function (x) { return true; },  // Condition
  function (x) { return x + 1; }, // Increment function
  function (x) { return x % howManyPlayers; }, // Return value function
  function ()  { return 100; } // Produce next value every 100 ms
)
.take(6)
.subscribe(
  function(player) { 
    console.info("player =", player); 
  },
  function(e) { 
    console.info("error =", e.message);
  },
  function() { 
    console.info("*** Game Over ***");
});

As the generator goes on generating values infinitely, we take only 6 values to make sure that we get the expected results 0, 1, 2, 0, 1, 2 in our subscription. Next, its time to add dice throws for the players. So, lets define a throwDice() function and just map the above. We will use the tap() function (for side-effects) to print to console and make sure we get what we expect.

var throwDice = function() {
  return Math.ceil(Math.random() * 6);
};

Rx.Observable.generateWithRelativeTime(0,
  function (x) { return true; },  
  function (x) { return x + 1; }, 
  function (x) { return x % howManyPlayers; }, 
  function ()  { return 100; } 
)
.map(function(player) {  
  return [player, throwDice()];
})
.tap(function(playerWithDice){
  console.info("player", playerWithDice[0], " threw on dice", playerWithDice[1]);
})
.take(6)
.subscribe(
  function(playerWithDice) { 
  },
  function(e) { 
    console.info("error =", e.message);
  },
  function() { 
    console.info("*** Game Over ***");
});

As a next step, lets lets get rid of hard-coded players, instead use an array to hold each players’ initial position and choose value 0 to represent it.

var initialPlayerPositions = [0, 0, 0];
var players = initialPlayerPositions.length;

We will introduce newPlayerPositions() function, which for now, returns the same positions that it receives, but a copy of the old array (we don’t want to mutate input variables passed to us).

var newPlayerPositions = function(player, dice, playerPositions) {
  return playerPositions.slice(0);  // create a copy of current  
};

We will call the newPlayerPositions() function from within scan() of the observable. scan() is equivalent to foldLeft() or reduce(). The code now looks like this –

var initialPlayerPositions = [0, 0, 0];
var players = initialPlayerPositions.length;

Rx.Observable.generateWithRelativeTime(0, 
  function(x) { return true; },
  function(x) { return x + 1; },
  function(x) { return x % players; },
  function()  { return 100; }
)
.map(function(player){  
  return [player, throwDice()];
})
.tap(function(playerWithDice){
  console.info("player", playerWithDice[0], " threw on dice", playerWithDice[1]);
})
.scan(function(playerPositions, playerWithDice) {
  var player = playerWithDice[0];
  var dice = playerWithDice[1];
  return newPlayerPositions(player, dice, playerPositions);
}, initialPlayerPositions)
.take(6)
.subscribe(
  function(playerPosition) { 
  },
  function(e) { 
    console.info("error =", e.message);
  },
  function() { 
    console.info("*** Game Over ***");
});

Next step is to setup a board with positions of snakes and ladders. For simplicity, lets consider a board with 10 cells, 2 ladders and a snake.

var cells = 10;
var board = {
  3: 7, // Ladder
  8: 4, // Snake
  5: 9  // Ladder
};

Having done that, lets now start implementing the rules for Snakes and Ladders. The newPlayerPositions() function below encodes all the rules of the game.

Lets first start with adding to a players current position the dice value.

var newPlayerPositions = function(player, dice, playerPositions) {
  var newPlayerPositions = playerPositions.slice(0);  // create a copy of current player positions
  var currentPosition = newPlayerPositions[player];
  var newPosition = currentPosition + dice;
  newPlayerPositions[player] = newPosition;
  return newPlayerPositions;
}

Next, lets encode the next rule that an exact throw of dice is required for win.

var newPlayerPositions = function(player, dice, playerPositions) {
  var newPlayerPositions = playerPositions.slice(0);  // create a copy of current player positions
  var currentPosition = newPlayerPositions[player];
  var newPosition = currentPosition + dice;
  if (newPosition <= cells) {  //exact throw of dice required for win.
    newPlayerPositions[player] = newPosition;
  } 
  return newPlayerPositions;
};

Finally, we bring in the board with Snakes and Ladders to introduce the jumps for a ladder climb and a gulp of a snake.

var newPlayerPositions = function(player, dice, playerPositions) {
  var newPlayerPositions = playerPositions.slice(0);  // create a copy of current player positions
  var currentPosition = newPlayerPositions[player];
  var newPosition = currentPosition + dice;
  if (newPosition <= cells) {  //exact throw of dice required for win.
    newPlayerPositions[player] = newPosition;
  } 
  
  if(board[newPosition]) {
    newPlayerPositions[player] = board[newPosition];
  }
  
  return newPlayerPositions;
};

Now, we know that the game cannot finish after 6 rounds as defined early by the call to take(6). We need to implement the logic to determine the winner and stop the game. So, lets define a function that determines a winner –

var isWinner = function(playerPositions) {
  return playerPositions.some(function(playerPosition) { 
    return playerPosition == cells;
  });
};

Using this function, we will get rid of the hard-coded take(6) and instead use takeWhile(), which consumes a predicate and goes on allowing values to pass through until the predicate remains satisfied. As soon as the predicate returns false, the observable is disposed. We tap() again to make sure we get to see the output. So, the complete code now looks like –

var Rx = require("rx");

var throwDice = function() {
  return Math.ceil(Math.random() * 6);
};

var initialPlayerPositions = [0, 0, 0];
var players = initialPlayerPositions.length;
var cells = 10;
var board = {
  3: 7, 
  8: 4, 
  5: 9
};

var newPlayerPositions = function(player, dice, playerPositions) {
  var newPlayerPositions = playerPositions.slice(0);
  var currentPosition = newPlayerPositions[player];
  var newPosition = currentPosition + dice;
  if (newPosition <= cells) {
    newPlayerPositions[player] = newPosition;
  } 
  
  if(board[newPosition]) {
    newPlayerPositions[player] = board[newPosition];
  }
  
  return newPlayerPositions;
};

var isWinner = function(playerPositions) {
  return playerPositions.some(function(playerPosition) { 
    return playerPosition == cells;
  });
};

Rx.Observable.generateWithRelativeTime(0, 
  function(x) { return true; },
  function(x) { return x + 1; },
  function(x) { return x % players; },
  function()  { return 100; }
)
.map(function(player){  
  return [player, throwDice()];
})
.tap(function(playerWithDice){
  console.info("player", playerWithDice[0], " threw on dice", playerWithDice[1]);
})
.scan(function(playerPositions, playerWithDice) {
  var player = playerWithDice[0];
  var dice = playerWithDice[1];
  return newPlayerPositions(player, dice, playerPositions);
}, initialPlayerPositions)
.tap(function(playerPositions) {
  console.info('Player Positions = ', playerPositions);
})
.takeWhile(function(playerPositions) {
  return !isWinner(playerPositions);
})
.subscribe(
  function(playerPositions) { },
  function(e) { console.error("Oops! ", e.message); },
  function()  { console.info("*** Game Over ***");}
);

Here is the same code in ES6, using Fat Arrows and Destructuring.

var Rx = require("rx");

var throwDice = () => Math.ceil((Math.random() * 100) % 6)

var initialPlayerPositions = [0, 0, 0];
var players = initialPlayerPositions.length;

var cells = 10;
var board = {
  3: 7, 
  8: 4, 
  5: 9
};

var newPlayerPositions = function(player, dice, playerPositions) {
  var newPlayerPositions = playerPositions.slice(0);
  var currentPosition = newPlayerPositions[player];
  var newPosition = currentPosition + dice;
  if (newPosition <= cells) { 
    newPlayerPositions[player] = newPosition; 
  } 

  if(board[newPosition]) { 
    newPlayerPositions[player] = board[newPosition]; 
  } 
  return newPlayerPositions; 
};
 
var isWinner = playerPositions => playerPositions.some(_ => _ == cells)

Rx.Observable.generateWithRelativeTime(0, 
  x => true,
  x => x + 1,
  x => x % players,
  () => 100)
.map(player => [player, throwDice()])
.tap(([player, dice]) => console.info("player", player, " threw on dice", dice))
.scan((playerPositions, [player, dice]) => 
   newPlayerPositions(player, dice, playerPositions), initialPlayerPositions)
.tap(playerPositions => console.info('Player Positions = ', playerPositions))
.takeWhile(playerPositions => !isWinner(playerPositions))
.subscribe(playerPositions => { },
  e => console.error("Oops! ", e),
  () => console.info("*** Game Over ***")
);

Here is the same game in Scala, except that it is not based on reactive push model, but on the pull-model employing streams. Also, please note that I’m doing printing (side-effects) from within scanLeft() and takeWhile()

val initialPlayerPositions = List(0, 0, 0)

val board = Map(3 -> 7, 8 -> 4, 5 -> 9)

val cells = 10

def throwDice = Math.ceil((Math.random() * 6)).toInt

def newPlayerPositions(player: Int, dice: Int, playersPos: List[Int]) = {
  val newPosition = playersPos(player) + dice
  if (newPosition <= cells) 
    playersPos.take(player) ::: List(board.getOrElse(newPosition, newPosition)) ::: playersPos.drop(player + 1)   
  else 
    playersPos 
} 

def isNotWinner(playerPos: List[Int]) = !playerPos.exists(_ == cells) 

Stream 
  .from(0)
  .map(x => (x % initialPlayerPositions.size, throwDice))
  .scanLeft(initialPlayerPositions) { 
    case (playersPos, (player, diceValue)) =>
      println(s"Player $player threw on dice $diceValue")
      newPlayerPositions(player, diceValue, playersPos)
  }
  .takeWhile(playersPos => {
    println(s"$playersPos")
    isNotWinner(playersPos)
  })
  .force