A Chess Engine in RStats (Proof-of-Concept using Stockfish)

The Queen’s Gambit - Post 5

Inspired by The Queen’s Gambit on Netflix, I’m doing a few posts on Chess in R.

This screenshot from the show explains everything:

Stockfish

stockfish is a free, open source chess engine which implements the UCI (Universal Chess Interface)

Installation of Stockfish

On MacOS (with homebrew installed): brew install stockfish

See the stockfish website for more details on installation on your system

{processx}

To interface with Stockfish (a command line/terminal program) I’m using processx.

processx is a package for executing and controlling subprocesses (like a command like program) from R. You can write to the program’s stdin and then read what it outputs (stdout).

Simple interfacing with Stockfish using {processx}

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Run 'stockfish' on the command line and set up pipes so we can send and 
# receive messages from stdin and stdou
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
sf  <- process$new('stockfish', stdin = '|', stdout = '|', stderr = '|')

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# UCI standard says that you should always request 'uci' from whatever ches
# ending you're using.  This outputs the configuration in stockfish
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
sf$write_input('uci\n')
cat(sf$read_output_lines())
## Stockfish 12 by the Stockfish developers (see AUTHORS file)
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Initialise the board to the opening state and a couple of basic moves.
# There's no "opening book" in stockfish
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
sf$write_input('position startpos moves e2e4 e7e5\n')

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Ask stockfish to think about this move for 0.1 second (100 ms)
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
sf$write_input('go movetime 100\n')

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# wait for Stockfish to think 
# Need to be certain that it finishes and prints out its move
# Note: probably nicer ways to wait via processx, but haven't dug deep enough yet
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Sys.sleep(1)

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Get all the output that stockfish produces.
# There's a lot of lines that start with 'info' which are steps along its
# thinking process.
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
sf_response <- sf$read_output_lines()

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# The only line that i'm interested in is the last one which is the 
# description of the 'bestmove'
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
best_move_line <- tail(sf_response, 1)
best_move      <- strsplit(best_move_line, " ")[[1]][2]
best_move_line
## [1] "bestmove g1f3 ponder g8f6"
best_move
## [1] "g1f3"

Stockfish-based Chess game in RStats

The following code is a very rough chess engine in R using Stockfish to make the computer’s moves.

Instructions:

  1. Enter Your Move: in Long Algebraic Notation (LAN)
    • e.g. e2e4 is a valid opening move
    • The first 2 symbols are the starting location e2 i.e. White King’s Pawn
    • The second 2 symbols are the destination e4 i.e.  the fourth row in column e
  2. The computer will make a move (after thinking for 0.1 seconds)
  3. Enter q to quit when it’s your turn to move.

Limitiations:

  • no move validation, so there’s no prevention of impossible moves.
    • What stockfish does with impossible moves is undefined as far as I know.
  • No detection of ‘end-of-game’
  • no castling
  • I’ve given Stockfish very little thinking time by default (0.1s) - feel free to make this longer if you need a challenge.
Click to reveal/hide support functions
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#' Convert a FEN string to a char matrix representing the chess board.
#' 
#' @param fen fen string
#' 
#' @return 8x8 character matrix representing the chess board
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
fen_to_matrix <- function(fen) {
  
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Currently only interested in 1st field in fen i.e. piece placement
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  pieces <- strsplit(fen, ' ')[[1]][1]
  rows   <- strsplit(pieces, "/")[[1]]
  
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Replaces digits with the number of spaces they represent
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  for (i in 1:8) {
    rows <- gsub(i, paste(rep(' ', i), collapse=''), rows)
  }
  
  matrix(unlist(strsplit(rows, '')), 8, 8, byrow = TRUE, list(c(8:1), letters[1:8]))
}

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#' Create a plot of the state of a chess board from a FEN string
#'
#' @param fen FEN string
#'
#' @return ggplot2 object
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
plot_board <- function(mat) {
  
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Unicode chars for chess pieces
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  unicode <- c(
    ` ` = '',
    P='\u2659', R='\u2656', N='\u2658', B='\u2657', Q='\u2655', K='\u2654', # White
    p='\u265F', r='\u265C', n='\u265E', b='\u265D', q='\u265B', k='\u265A'  # Black
  )
  
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Convert the matrix chess board to a data.frame
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  board <- expand.grid(x=1:8, y=1:8) %>%
    mutate(
      tilecol = (x+y)%%2 == 1,
      piece  = as.vector(t(mat[8:1,])),
      colour = piece %in% letters,
      unicode = unicode[piece]
    )
  
  
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Create plot
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  p <- ggplot(board, aes(x, y)) + 
    geom_tile(aes(fill = tilecol), colour = 'black', show.legend = FALSE) + 
    geom_text(aes(label = unicode), family="Arial Unicode MS", size = 8) + 
    coord_equal() + 
    scale_fill_manual(values = c('grey50', 'grey95')) +
    scale_y_continuous(breaks = 1:8) + 
    scale_x_continuous(breaks = 1:8, labels = letters[1:8]) +
    theme_bw() +
    theme(
      axis.title      = element_blank(),
      plot.background = element_blank(),
      panel.grid      = element_blank(),
      panel.border    = element_blank(),
      axis.ticks      = element_blank()
    )
  
  plot(p)
  
  invisible()
}

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Super basic Long Annoation to matrix coordinates
# 
# @param lan e.g. e2e5
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
lan_to_coords <- function(lan) {
  stopifnot(nchar(lan) == 4)
  lan <- tolower(lan)
  
  bits <- strsplit(lan, '')[[1]]
  bits[1] <- match(bits[1], letters[1:8])
  bits[3] <- match(bits[3], letters[1:8])
  bits <- as.integer(bits)
  
  list(
    start = matrix(c(9 - bits[2], bits[1]), nrow = 1),
    end   = matrix(c(9 - bits[4], bits[3]), nrow = 1)
  )
  
}
library(ggplot2)
library(processx)

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Start the Stockfish proces
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
sf  <- process$new('stockfish', stdin = '|', stdout = '|', stderr = '|')
sf$write_input('uci\n')
jnk <- sf$read_output_lines()

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Initialise board (i.e. matrix) to opening position
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
fen <- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
board <- fen_to_matrix(fen)
plot_board(board)

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Keep track of all the moves played. This list of moves is fed to Stockfish
# every time, and then it decides what the next move should be
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
all_moves <- c()

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#
# Game Loop
#
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
while(TRUE) {
  
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Ask user for a move and add it to the list of all moves
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  move_lan <- readline("Your move?   : ")
  if (move_lan %in% c('', 'q', 'Q', 'quit')) {
    message("Done")
    break
  }
  
  all_moves <- c(all_moves, move_lan)
  
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Validate move
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Not done yet
  
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Move the piece on the local board
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  move_coords <- lan_to_coords(move_lan)
  if (board[move_coords$start] %in% c(NA, ' ', '')) {
    stop("Bad move given current board state: ", best_move)
  }
  board[move_coords$end  ] <- board[move_coords$start]
  board[move_coords$start] <- ' '
  plot_board(board)
  
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Tell stockfish about the move
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  moves <- paste(all_moves, collapse = " ")
  cmd   <- paste('position startpos moves', moves, '\n')
  # print(cmd)
  sf$write_input(cmd)
  
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Ask stockfish to consider the next move for 0.1 seconds
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  sf$write_input('go movetime 100\n')
  Sys.sleep(0.2)
  
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Extract the best move from output from Stockfish
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  sf_response    <- sf$read_output_lines()
  best_move_line <- tail(sf_response, 1)
  best_move      <- strsplit(best_move_line, " ")[[1]][2]
  cat("Computer move:", best_move, "\n")
  
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Add the computers move
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  all_moves <- c(all_moves, best_move)
  
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # Move the piece on the local board
  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  move_coords <- lan_to_coords(best_move)
  if (board[move_coords$start] %in% c(NA, ' ', '')) {
    stop("Bad move given current board state: ", best_move)
  }
  board[move_coords$end  ] <- board[move_coords$start]
  board[move_coords$start] <- ' '
  plot_board(board)
}