---
title: "Four in a Row"
format:
html:
toc: true
vignette: >
%\VignetteIndexEntry{fourinarow}
%\VignetteEngine{quarto::html}
%\VignetteEncoding{UTF-8}
knitr:
opts_chunk:
collapse: true
comment: '#>'
---
```{r}
#| label: setup
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`:
```{r, eval=FALSE}
play4inaRow(humanPlayer, randomBot)
```
```{r, echo=FALSE}
game <- matrix('.', nrow = 6, ncol = 7)
cat(paste0(' ',paste(1:7, collapse=' ')))
cat('\n')
cat(' _______________________\n')
for(i in 1:nrow(game)){
cat(' | ')
cat(paste(game[i,], collapse=' '))
cat(' |\n')
}
cat(' -----------------------\n')
cat('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.
```{r}
play4inaRow(easyBot, randomBot)
```
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:
- `"X"`: the bot player's pieces
- `"O"`: the opponent's pieces
- `"."`: empty spaces
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:
```{r}
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:
```{r}
set.seed(1)
play4inaRow(myBot, randomBot)
```
But other times, it will make an invalid move, resulting in an automatic loss:
```{r}
set.seed(2)
play4inaRow(myBot, randomBot)
```
# 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`:
```{r, warning=FALSE}
testBots(myBot, randomBot)
```
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.
```{r}
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:
```{r}
testBots(myBot, randomBot)
```
## 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:
```{r}
sets <- getSetsof4()
head(sets)
```
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:
```{r, eval=FALSE}
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.
```{r, eval=FALSE}
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))
}
```
```{r, echo=FALSE}
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){
# 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)
})
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`!
```{r}
testBots(myBot, randomBot)
```
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`?