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 ```