Ruby chess engine gem

This is my first Ruby gem that claims to provide all the rules of the chess game. My goal was to keep this library as simple as possible via OOP principles, and I’d be glad to hear any feedback from you. In particular, I’m interested in code organization and OOP issues

The source code and useful README can be found at GitHub: https://github.com/anikeef/chess_engine

The library consists of 5 main parts:

  • Game class that implements the chess rules
  • Board class that provides data structure for the chess board
  • Piece class and its inheritors, which I’ve tried to make as lightweight as possible
  • MoveValidator module that provides validations methods to Game class
  • Move class that I’ve found useful to make move canceling easier

Also it contains the CLI class for command line interface, available with chess_engine executable.

NOTE: The bishop piece here is called an Elephant because of the russian tradition, but for the rest of the app there should be no difference in naming

Game

require_relative "board" require_relative "move_validator" require_relative "move"  module ChessEngine   class InvalidMove < StandardError; end    ##   # This class provides all the rules for the chess game. It recognizes check,   # checkmate and stalemate. Move validations logic can be found in the   # MoveValidator module, which is included in this class    class Game     attr_accessor :name     attr_reader :current_color     include MoveValidator      def initialize       @board = Board.new       @board.set_default       @current_color = :white       @last_piece = nil       @name = nil       @promotion_coord = false     end      ##     # Accepts the move string in algebraic notation, e.g. "e2e4",     # and applies it to the board.     # Raises InvalidMove if:     # * Game is already over     # * Pawn promotion should be executed first     # * Empty square is chosen     # * Player tries to move piece of the opponent     # * Move is invalid (checks via the MoveValidator module)     # * Move is fatal (king is attacked after the move)     #     # After successfull move, the method changes the current player or     # goes into "A pawn needs promotion" state, which can be checked by     # #needs_promotion? method      def move(string)       from, to = Game.string_to_coords(string)       piece = @board.at(from)       raise InvalidMove, "Game is over" if over?       raise InvalidMove, "#{@current_color} player should execute pawn promotion first" if needs_promotion?       raise InvalidMove, "Empty square is chosen" if piece.nil?       raise InvalidMove, "This is not your piece" unless piece.color == @current_color       raise InvalidMove, "Invalid move" unless valid_moves(from).include?(to)       move = Move.new(@board, from, to)       move.commit       if king_attacked?         move.rollback         raise InvalidMove, "Fatal move"       end        @last_piece = piece       piece.moves_count += 1       @promotion_coord = to and return if piece.pawn? && [7, 0].include?(to[1])       next_player     end      ##     # Returns a piece (or nil if the square is empty) at given coordinates     # === Example     #   g = Game.new     #   g["e2"] #=> <Pawn ...>     #   g["e4"] #=> nil      def [](str)       letters = ("a".."h").to_a       return nil unless /[a-h][1-8]/.match?(str)       @board.at([letters.find_index(str[0]), str[1].to_i - 1])     end      ##     # Returns the board in the nice-looking string      def draw       @board.to_s     end      ##     # Accepts a string with name of the piece.     # Promotes a pawn and changes the current player.     # Raises InvalidMove if promotion is not needed or invalid +class_name+     # has been passed     # === Example     #   game.promotion("queen")      def promotion(class_name)       unless needs_promotion? && ["rook", "knight", "elephant", "queen"].include?(class_name.downcase)         raise InvalidMove, "Invalid promotion"       end       @board.set_at(@promotion_coord, Module.const_get("ChessEngine::#{class_name.capitalize}").new(@current_color))       @promotion_coord = nil       next_player     end      ##     # Accepts a +length+ sybmol :short or :long. Ensures that castling is     # possible and commits appropriate moves. Otherwise, raises InvalidMove      def castling(length)       row = @current_color == :white ? 0 : 7       king = @board.at([4, row])       if length == :short         rook = @board.at([7, row])         line = [5, 6]         moves = [Move.new(@board, [4, row], [6, row]),           Move.new(@board, [7, row], [5, row])]       else         rook = @board.at([0, row])         line = [1, 2, 3]         moves = [Move.new(@board, [4, row], [2, row]),           Move.new(@board, [0, row], [3, row])]       end       raise InvalidMove, "Invalid castling" unless         king && rook && king.moves_count == 0 && rook.moves_count == 0 &&         line.all? { |x| @board.at([x, row]).nil? }        moves.each { |move| move.commit }       if king_attacked?         moves.each { |move| move.rollback }         raise InvalidMove, "Fatal move"       end       @last_piece = nil       next_player     end      ##     # Returns true if game is over      def over?       @board.piece_coordinates(@current_color).all? do |coord|         safe_moves(coord).empty?       end     end      ##     # Checks if pawn promotion is needed      def needs_promotion?       !!@promotion_coord     end      ##     # Returns true if current king is attacked      def check?       king_attacked?     end      private      def king_attacked?       king_coords = @board.king_coords(@current_color)       [[1, 1], [-1, 1], [-1, -1], [1, -1]].each do |move|         next_coords = relative_coords(king_coords, move)         piece = @board.at(next_coords)         return true if piece && piece.color != @current_color && (piece.pawn? || piece.king?)         edge_coords = repeated_move(king_coords, move).last         piece = edge_coords.nil? ? nil : @board.at(edge_coords)         return true if piece && piece.beats_diagonally?       end       [[1, 0], [-1, 0], [0, 1], [0, -1]].each do |move|         next_coords = relative_coords(king_coords, move)         piece = @board.at(next_coords)         return true if piece && piece.king?         edge_coords = repeated_move(king_coords, move).last         piece = edge_coords.nil? ? nil : @board.at(edge_coords)         return true if !piece.nil? && piece.beats_straight?       end       [[1, 2], [2, 1], [1, -2], [-2, 1],       [-1, 2], [2, -1], [-1, -2], [-2, -1]].each do |move|         coords = relative_coords(king_coords, move)         piece = possible_move?(coords) ? @board.at(coords) : nil         return true if !piece.nil? && piece.knight?       end       false     end      ##     # Converts a string in algebraic notation to array of coordinates     # === Example     #   Game.string_to_coord("a2a4") #=> [[0, 1], [0, 3]]      def Game.string_to_coords(string)       string = string.gsub(/\s+/, "").downcase       raise InvalidMove, "Input must look like \"e2 e4\" or \"a6b5\"" unless /^[a-h][1-8][a-h][1-8]$  /.match?(string)       letters = ("a".."h").to_a       [[letters.find_index(string[0]), string[1].to_i - 1],        [letters.find_index(string[2]), string[3].to_i - 1]]     end      def opposite_color       @current_color == :white ? :black : :white     end      def next_player       @current_color = opposite_color     end   end end 

Board

require_relative "piece" require "colorize"  module ChessEngine   ##   # This class provides a data structure for the chess board.   # It is responsible for storing information about pieces positions and moving   # them. It doesn't implement move validations and chess rules,   # so it is possible to make any moves at this level of abstraction.    class Board     ##     # Creates an empty 8x8 board as a 2-dimensional array      def initialize       @board = Array.new(8) { Array.new(8) { nil } }     end      ##     # Sets the initial board position according to the classic chess rules      def set_default       [[:white, 0, 1], [:black, 7, 6]].each do |color, row1, row2|         ["Rook", "Knight", "Elephant", "Queen", "King", "Elephant", "Knight", "Rook"].each.with_index do |class_name, column|           self[column, row1] = Module.const_get("ChessEngine::#{class_name}").new(color)         end          0.upto(7) do |column|           self[column, row2] = Pawn.new(color)         end       end     end      ##     # Returns the piece string on the given position     # === Example     #   at([0, 0]) #=> Rook:white     #   at([3, 3]) #=> nil       def at(coordinates)       return nil unless self.exists_at?(coordinates)       @board[coordinates[0]][coordinates[1]]     end      ##     # Sets the board on given coordinates to +piece+      def set_at(coordinates, piece)       @board[coordinates[0]][coordinates[1]] = piece     end      ##     # Checks if the values of +coordinates+ are between 0 and 7     # === Example     #   exists_at?([0, 0]) #=> true     #   exists_at?([8, -1]) #=> false      def exists_at?(coordinates)       coordinates.all? { |c| c.between?(0, 7) }     end      ##     # Returns a string containing the board in printable format     # (uses colorize gem to paint the squares)      def to_s       string = ""       colors = [[:default, :light_white].cycle, [:light_white, :default].cycle].cycle        7.downto(0) do |row|         string += "#{row + 1} "         colors_cycle = colors.next          0.upto(7) do |column|           piece = self[column, row]           string += piece.nil? ? " " : piece.symbol           string += " "           string[-2..-1] = string[-2..-1].colorize(background: colors_cycle.next)         end         string += "\n"       end        string += "  a b c d e f g h"       string     end      ##     # Moves the value of +from+ coords to +to+ coords. Sets the value of +to+ to nil      def move_piece(from, to)       piece = self.at(from)       self.set_at(from, nil)       self.set_at(to, piece)     end      ##     # Returns the coordinates of the king of given +color+      def king_coords(color)       Board.coordinates_list.find do |coord|         at(coord) && at(coord).king? && at(coord).color == color       end     end      ##     # Returns the array of coordinates where pieces of given +color+ a located      def piece_coordinates(color)       Board.coordinates_list.select do |coord|         piece = at(coord)         !piece.nil? && piece.color == color       end     end      private      def [](column, row)       @board[column][row]     end      def []=(column, row, piece)       @board[column][row] = piece     end      def Board.coordinates_list       list = []       (0..7).each do |x|         (0..7).each { |y| list << [x, y]}       end       list     end   end end 

Piece

module ChessEngine   class Piece     attr_reader :symbol, :color     attr_accessor :moves_count      def initialize(color)       @color = color       @moves_count = 0     end      def inspect       "#{self.class}:#{@color}"     end      def beats_diagonally?       elephant? || queen?     end      def beats_straight?       rook? || queen?     end      ["knight", "king", "pawn", "rook", "queen", "elephant"].each do |piece|       define_method(:"#{piece}?") do         self.class.to_s == "ChessEngine::#{piece.capitalize}"       end     end   end    class Elephant < Piece     def initialize(color)       super       @symbol = (@color == :black) ? "\u25B2" : "\u25B3"     end      def moves       [[1, 1], [1, -1], [-1, 1], [-1, -1]]     end   end    class King < Piece     def initialize(color)       super       @symbol = (@color == :black) ? "\u265A" : "\u2654"     end      def moves       [[0, 1], [0, -1], [1, 0], [-1, 0],       [1, 1], [1, -1], [-1, 1], [-1, -1]]     end   end    class Knight < Piece     def initialize(color)       super       @symbol = (@color == :black) ? "\u265E" : "\u2658"     end      def moves       [[1, 2], [2, 1], [1, -2], [-2, 1],       [-1, 2], [2, -1], [-1, -2], [-2, -1]]     end   end    class Pawn < Piece     attr_reader :direction      def initialize(color)       super       @symbol = (@color == :black) ? "\u265F" : "\u2659"       @direction = (@color == :white) ? 1 : -1     end   end    class Queen < Piece     def initialize(color)       super       @symbol = (@color == :black) ? "\u265B" : "\u2655"     end      def moves       [[0, 1], [0, -1], [1, 0], [-1, 0],       [1, 1], [1, -1], [-1, 1], [-1, -1]]     end   end    class Rook < Piece     def initialize(color)       super       @symbol = (@color == :black) ? "\u265C" : "\u2656"     end      def moves       [[1, 0], [0, 1], [-1, 0], [0, -1]]     end   end end 

MoveValidator

module ChessEngine   ##   # This module contains all the methods needed to check if   # some move is valid or not. It is included in the Game class and so uses   # some of its attributes: board, current_color and last_piece (for en passant only)    module MoveValidator      ## Excludes from valid_moves all fatal moves      def safe_moves(from)       valid_moves(from).reject { |move| fatal_move?(from, move) }     end      ##     # Returns an array of valid moves for a piece at the given position.     # Note: this method doesn't exclude moves that lead current king to be attacked     # (See +#safe_moves+ method)      def valid_moves(from)       piece = @board.at(from)       if piece.king? || piece.knight?         piece.moves.map do |move|           to = relative_coords(from, move)           to if possible_move?(to)         end.compact       elsif piece.pawn?         pawn_valid_moves(from)       else         valid_moves_recursive(from)       end     end      ##     # Returns an array of coordinates that can be reached by recursively     # applying the given +move+, starting from the +from+ coordinates      private      def repeated_move(from, move, valid_moves = [])       coordinates = relative_coords(from, move)       return valid_moves unless possible_move?(coordinates)       return valid_moves << coordinates unless @board.at(coordinates).nil?       repeated_move(coordinates, move, valid_moves << coordinates)     end      ##     # Returns coordinates that will be reached after applying the +move+,     # starting from the +from+ coordinates      def relative_coords(from, move)       [from[0] + move[0], from[1] + move[1]]     end      ##     # Returns true if:     # * The 8x8 board exists at given coordinates     # * Board at given coordinates is empty or it contains a piece with the same     #   color as the current_color      def possible_move?(coordinates)       if @board.exists_at?(coordinates)         piece = @board.at(coordinates)         return (piece.nil? || piece.color != @current_color)       end       return false     end      ##     # Returns true if the current king is attacked after the given move      def fatal_move?(from, to)       is_fatal = false       move = Move.new(@board, from, to)       move.commit       is_fatal = true if king_attacked?       move.rollback       is_fatal     end      def pawn_valid_moves(from)       pawn = @board.at(from)       direction = pawn.direction       moves = []       next_coords = relative_coords(from, [0, direction])       jump_coords = relative_coords(from, [0, direction * 2])       take_coords = [relative_coords(from, [1, direction]),         relative_coords(from, [-1, direction])]       if @board.exists_at?(next_coords) && @board.at(next_coords).nil?         moves << next_coords         moves << jump_coords unless pawn.moves_count > 0 || @board.at(jump_coords)       end       take_coords.each do |coords|         moves << coords if @board.at(coords) && @board.at(coords).color != pawn.color       end       en_passant_coords(from) ? moves << en_passant_coords(from) : moves     end      ##     # Returns additional valid coordinates for the pawn if available      def en_passant_coords(from)       pawn = @board.at(from)       [1, -1].each do |x|         next_coords = [from[0] + x, from[1]]         next_piece = @board.at(next_coords)         if next_piece.class == Pawn && next_piece == @last_piece &&           next_piece.moves_count == 1 && from[1].between?(3, 4)             return [from[0] + x, from[1] + pawn.direction]         end       end       nil     end      ##     # This method is used by #valid_moves for pieces like Queen, Rook and Elephant,     # that should move recursively      def valid_moves_recursive(from)       piece = @board.at(from)       piece.moves.inject([]) do |valid_moves, move|         valid_moves.push(*repeated_move(from, move))       end     end   end end 

Move

module ChessEngine   ##   # This class is made to make move canceling easier if something goes wrong.    class Move     def initialize(board, from, to)       @board = board       @from = from       @to = to       @original_squares = []       @original_squares << {coord: from, piece: board.at(from)}       @original_squares << {coord: to, piece: board.at(to)}       if en_passant?         @en_passant_coord = [to[0], from[1]]         @original_squares << {coord: @en_passant_coord, piece: board.at(@en_passant_coord)}       end     end      ##     # Applies the move to the board      def commit       if en_passant?         @board.set_at(@en_passant_coord, nil)       end       @board.move_piece(@from, @to)     end      ##     # Moves pieces back and returns the board to the previous state      def rollback       @original_squares.each do |square|         @board.set_at(square[:coord], square[:piece])       end     end      private      def en_passant?       @board.at(@from).pawn? && @from[0] != @to[0] && @board.at(@to).nil?     end   end end ```