Four in a Row

library(fourinarow)

Intro

This package implements a text-based version of the classic 4-in-a-row game, through the primary function play4inaRow. It’s intended for fun and as a motivating example for people looking to improve their coding skills. Here, I’ll describe the basic functionality of the package and show you how you can write your own function to play the game automatically.

Game Modes

Human vs. CPU

For those who just want to have fun, you can play against a computer-controlled opponent. There are a few different opponents you could play against, but for now we’ll use the most basic (and easiest to beat), randomBot. Here’s how to start a game against randomBot:

play4inaRow(humanPlayer, randomBot)
#>    1  2  3  4  5  6  7
#>  _______________________
#>  | .  .  .  .  .  .  . |
#>  | .  .  .  .  .  .  . |
#>  | .  .  .  .  .  .  . |
#>  | .  .  .  .  .  .  . |
#>  | .  .  .  .  .  .  . |
#>  | .  .  .  .  .  .  . |
#>  -----------------------
#> Move:

This will show you an empty game board and prompt you to input your next move. You must enter a value between 1 and 7, corresponding to a column with at least one empty space remaining. After receiving a valid input, the computer opponent will immediately take it’s turn and the updated game board will be displayed. This continues until one player wins by getting four pieces in a row (horizontally, vertically, or diagonally).

There are four computer opponents you can play against, posing four distinct challenge levels: randomBot, easyBot, mediumBot, and hardBot.

CPU vs. CPU

To simulate a game between two computer players, you can use the same code as above, but replace "humanPlayer" with one of the "*Bot" functions.

play4inaRow(easyBot, randomBot)
#>    1  2  3  4  5  6  7
#>  _______________________
#>  | .  .  .  .  .  .  . |
#>  | .  .  .  .  .  .  . |
#>  | .  .  . *X* .  .  . |
#>  | .  .  . *X* .  .  . |
#>  | .  O  . *X* .  .  . |
#>  | .  O  . *X* .  O  . |
#>  -----------------------
#> [1] 1

This will simulate a full game, display the final board, and output a value of 1 or 2, telling us which player won (in the case of a tie, it will output a 0).

Writing your own Bot player

Each bot player is just an R function, so you can make a new one by writing your own function. These functions have specific inputs and outputs, described below.

Input

The input to a bot function is a "matrix" object with 6 rows and 7 columns, representing the current state of the game. It contains the following symbols:

Note that the game internally switches the symbols X and O so that every bot player sees their own pieces as X and their opponents’ as O. This makes it easier to write your own bot player, as you don’t need a second input or additional internal logic to tell your function which symbol to use.

Output

The output of the function must be an integer between 1 and 7, representing the bot player’s next move. Values outside of this range or values corresponding to columns that are already full will result in an automatic loss. If the function encounters an error, this also results in an automatic loss.

Getting Started

Let’s make a new function, called myBot, and start by having it choose a random number between 1 and 7. We can achieve this with the sample function:

myBot <- function(game){
  sample(7, 1)
}

This bot will not be very good, but it’s a start! Sometimes it will work for a full game:

set.seed(1)
play4inaRow(myBot, randomBot)
#>    1  2  3  4  5  6  7
#>  _______________________
#>  | .  .  .  .  .  .  . |
#>  | .  .  .  .  .  .  . |
#>  | X  X  .  .  .  . *O*|
#>  | X  O  O  .  X *O* O |
#>  | O  O  X  . *O* X  X |
#>  | X  X  O *O* O  X  X |
#>  -----------------------
#> [1] 2

But other times, it will make an invalid move, resulting in an automatic loss:

set.seed(2)
play4inaRow(myBot, randomBot)
#> Warning in play4inaRow(myBot, randomBot): playerOne made invalid move: column 1
#> is full (turn 27)
#>    1  2  3  4  5  6  7
#>  _______________________
#>  | X  .  .  .  .  .  . |
#>  | X  .  .  .  .  .  O |
#>  | X  .  X  .  O  O  O |
#>  | O  .  X  .  X  X  X |
#>  | X  O  O  .  O  O  O |
#>  | X  X  O  O  X  X  O |
#>  -----------------------
#> [1] 2

Strategy

Valid Moves

To get a better sense for how good (or bad) our bot is, we can simulate many games at once with the testBots function. This function simulates 100 games between two bots, each getting 50 games as playerOne and 50 as playerTwo (because making the first move gives you slight advantage). Let’s see how our bot stacks up against randomBot:

testBots(myBot, randomBot)
#>      ties playerOne playerTwo 
#>         0        39        61

We’ve hidden all the warnings in the above output, because there are quite a few! But we can still see that our bot is not doing very well, largely because of how often it tries to make an invalid move.

Let’s update our function to look for valid moves and only select from those. We can identify valid moves based on whether or not a column contains a dot ('.') in the top row (row 1), representing an empty space. Here, we’ll create a vector of possible moves, called poss, and if there’s only one valid move, we make it. Otherwise, we select at random.

myBot <- function(game){
  # identify legal moves
  poss <- which(game[1,] == '.')
  
  # if only one legal move
  if(length(poss) == 1){
    return(poss)
  }
  
  # otherwise, select randomly
  return(sample(poss, 1))
}

Notice that we now have two return calls in our function. This works because as soon as R encounters one of them, it exits the function and returns the given output. So the second return is only used in cases when there is more than one valid move available.

Our bot is now as smart as randomBot and we can see that they are pretty evenly matched:

testBots(myBot, randomBot)
#>      ties playerOne playerTwo 
#>         1        50        49

Four in a row

Now that our bot knows to only make valid moves, how can we select the best move? The most basic strategy is to look for places where either we or our opponent can make 4 in a row.

There a number of ways you could do this, but the most straightforward is an exhaustive search. We’ll take advantage of a utility function called getSetsof4 that lists all possible sets of four in a row. First, let’s examine the output:

sets <- getSetsof4()
head(sets)
#>      [,1] [,2] [,3] [,4]
#> [1,]    1    2    3    4
#> [2,]    7    8    9   10
#> [3,]   13   14   15   16
#> [4,]   19   20   21   22
#> [5,]   25   26   27   28
#> [6,]   31   32   33   34

This is a matrix object, where each set consists of four numbers, representing spaces on the game board. Spaces are numbered 1 (the top of the first column) through 42 (the bottom of the seventh column). Because of how R represents matrices, the indices go down each column before moving on to the next column. So, to check the space in the 3rd row and 4th column, we could use either game[3,4] or game[21] (because (4-1)*6+3 = 21).

We’ll use the apply function to loop over every possible set of 4, searching for places where we could either make four in a row or block the opponent from doing so. We also need to check whether or not such a space can be reached on this turn, so that we don’t accidentally make a winning space available to our opponent. Here’s what that apply function will look like:

goodmoves <- apply(sets, 1, function(set){
  # get symbols from game board ('X','O','.')
  symbols <- game[set]
  # index of the (potential) target space
  index <- 0
  
  # look for sets of 3 X's with an open space
  if(sum(symbols == 'X') == 3 & sum(symbols == '.') == 1){
    index <- set[which(symbols == '.')]
  }
  # look for sets of 3 O's with an open space
  if(sum(symbols == 'O') == 3 & sum(symbols == '.') == 1){
    index <- set[which(symbols == '.')]
  }
  
  # if either of the above situations were found, 
  # check whether or not the empty space is reachable on this turn
  if(index != 0){
    column <- ((index - 1) %/% 6) + 1
    row <- ifelse(index %% 6 != 0, index %% 6, 6)
    if(row == 6 || all(game[(row+1):6, column] %in% c('X','O'))){
      # found a good move
      return(column)
    }
  }
  
  # didn't find a good move, return 0
  return(0)
})

Now we just need to wrap it in our myBot function. If we identified a good move in the apply loop, we should make that move, otherwise we’ll continue to pick randomly.

myBot <- function(game){
  # identify legal moves
  poss <- which(game[1,] == '.')
  
  # if only one legal move
  if(length(poss) == 1){
    return(poss)
  }
  
  # look for good moves (make 4 in a row or block opponent)
  goodmoves <- apply(sets, 1, function(set){
    # ...
  })
  
  if(any(goodmoves != 0)){
    # return the first non-zero value
    return(goodmoves[which.max(goodmoves != 0)])
  }
  # otherwise, select randomly
  return(sample(poss, 1))
}

And now we have a bot player that can consistently beat randomBot!

testBots(myBot, randomBot)
#>      ties playerOne playerTwo 
#>         1        98         1

Hopefully this tutorial has been helpful and you have some ideas for how to continue improving upon your bot player! Can you make a bot that consistently beats mediumBot? What about hardBot?