Skip to content

Ruby chess as suggested as a final project for the odin project

Notifications You must be signed in to change notification settings

RolandStuder/chess

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

91 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Chess

Final project for the ruby course of the Odin Project.

I highly recommend to explore my approach through my commits, rather than just the finished code

I however completed this project after around 2.5 years of professional experience as a Ruby on Rails web developer.

To run the game

  • bundle install
  • ruby main.rb

You can also start a game from a FEN notation like:

ruby main.rb "rnbqkbnr/pp1ppppp/8/2p5/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2"

"Why did I do this project, when you already got a job as a web dev?"

I had skipped chess back in the day, feeling a bit overconfident in my abilities and I was super eager to jump to Rails. I felt like I would not learn that much, or at least it was not clear to me what new things I would learn. I was wrong. Then I started helping out more in the TOP Discord, saw a lot of people with questions about chess and just got curious: How hard is it actually do write a chess? And how easy would it be for me today with my experience?

So I decided to go for it… after some encouragement from Zach… Thx, Zach!

My goals

  • Get a CLI chess working with all the rules
  • Keep the code clean and understandable
  • Refactor only to make your code extendable

My goal was NOT

  • to create the perfect OOP example
  • refactor until things are perfect.

Interesting bits

I'll go through my journey and highlight interesting bits… The links in this section all point to commits of my chess game, that show the concrete code.

I highly recommend to explore my approach through my commits, rather than looking at the "finished" code

Find the hard part, what moves are legal?

I did not draw up any diagramms, I just tried to think about the problem that felt like the core part / the hardest part: Figuring out what piece can move where, given the rules for the piece and the situation on the board

So in my initial commit I put down some notes about what classes I probably need. And how to deal with this core problem. From the beginning I was thinking to maybe have a MoveValidator, because neither the board nor the piece felt like the right place to put the responsibility of knowing all those rules.

A note on testing

I am not a big fan of rspec, and prefer minitest which is much less of a DSL. But you should be able to read MiniTest test cases without an issue. Added Minitest in my second commit.

Minimal board

As basically everything happens on the board, it felt like this is where I needed to start.

So my third commit I added a board class, a field class and a test to get a field from the board.

Note to self if I comment on every single commit, I will never finish writing this.

The concept of Position

Chess uses notation like a4, it was pretty clear, that I would have to pass around positions/coordinate around all the time. So instead of needing to think, about what @board.get(3,6) means I decided to make the Position a thing. And I was glad I did, it would turn out to spare me a lot of thinking. So I made that I can do either: Positions.parse("A4") or Position.new("A", 4). Interesting thing is maybe also how I made the positions comparable

How to find legal moves, start somewhere

I was not sure with what move to start with, but it felt like a horizontal or vertical move would be the easiest. (Hint: it isn't). When thinking about it, I saw that I could isolate the rules for a type of move in a class. So I started out with a Move::Horizontal class. Writing a test to check if I got the right square for unhindered movements.

I ignored at this point, that there might be occupying pieces, a king in check or anything. I did not even touch the board for the test, just seeing if I get the right list of positions back.

Then the next steps are actually nicely shown in the commits:

Putting together pieces and move types

So moves are done by pieces. So a piece should know what kind of moves it is allowed to make: So on the pieces I put something like:

class Rook < Piece
   def move_types
     [Move::Horizontal, Move::Vertical]
   end
 end

And I wired things up, so I could get the legal move by accessing the move types form a piece on the board

feat: get legal moves via move_types from piece for board

Interesting here is to see, that I only added one test, just to see if the principle works.

class Piece
   class RookTest < Minitest::Test
     def test_rook_can_move_horizontally
       board = Board.new
       board.place(Rook.new, "A1")
       assert_includes board.legal_moves_for("A1"), Position.parse("H1")
     end
   end
 end

Leveraging the positions class

At first I thought the positions class would just provide me a way to handle the, but I startd to see that I could use it to give me relative positions to itself, for example all the positions upwards. Like Position.parse("F3").positions_upwards. This way a horizontal move did not need to know how to generate positions in one direction itself.

Generalizing

From there I started to generalize more as I saw that horizontal / vertical and diagonal moves would always be in a line. The would always be blocked by friendlies and always allow capturing enemies. So I extracted a base class and created methods for legal_moves_in_line(positions) and occupied_by_friend?(target_position) and occupied_by_enemy?(target_position) see commit

Making positions even more useful

When coming to diagonals, I realized, I would be much happier, if I could just find new positions with relative commands. So the idea was born to allow stuff like Position.parse("A4).up.left, that turned out quite nifty. For example see how I set up the knight moves:

module Move
   # knight moves in ls
   class Knight < Base
     def legal_moves
       positions = directions.map do |direction|
         position.go(*direction)
       end
       without_illegal_moves(positions.compact)
     end

     private

     def directions
       [
         %i[up up right],
         %i[right right up],
         %i[right right down],
         %i[down down right],
         %i[down down left],
         %i[left left down],
         %i[left left up],
         %i[up up left]
       ]
     end
   end
 end

Profit of abstraction

At this point I had all straight moves, so getting the Queen to work felt almost like cheating (please ignore that at this point the Queen was a Bishop :-D )

The pawns rise up

By the time I got to the pawns, the pattern with the move types was well established,, and I was able to conditionally insert move types Move::OneDown and Move::OneUp depending on the color

Adding the double move I added the :moved attribute to the piece class. This felt right, since I knew I would also need something like that for castling.

Things get more complex

Until now, I basically only created boards with one single piece on it, sometimes two, to check if one was blocking the other. But my test only ever had the minimum. To create more complex situation, I read about the FEN Notation and got started on that to be able to create new boards with interesting situations easily, like: Board.from_fen("rnbqkbnr/pppppp1p/8/8/5PpP/4P3/PPPP2P1/RNBQKBNR b KQkq h3 0 4")

Starting with check stuff

Now I was ready to get gritty and start seeing if I can check for check

Basically going through all the squares occupied by one color and checking all their legal moves, and if any included the king, you got a check.

I decided to go for checking checks and stuff like this, before adding the complex special moves. It felt easier to tackle this first, can't really say why.

Gaining more clarity on what things mean/are

The next few commits are a bit less straightforward in their purpose. But I saw I had some stuff names wrongly and it became confusing, I was asking move types for :legal_moves, but actually received positions back. But more importantly some of those positions where not actually legal ones, because they might attack a friendly piece, or they might put the king in check.

So the interesting thing now I was able to have a nice method on the base move class, called legal_target_positions that took the position candidates, that then removed the illegal moves:

def legal_target_positions
   positions = position_candidates
   positions -= target_positions_that_are_occupied_by_friend(positions)
   positions -= target_positions_that_create_check_for_own_king(positions)
   positions
 end

here is the commit

btw: this was not as smooth as I thought, turns out array - other_array doesn't remove items by comparing with == but actually checks if they are the sae object, so I had to be a bit creative and make sure that Position.new("A", 3) would always return the same object.

Refactoring to make special moves possible

So far all the move types I had implemented, did exactly one thing, they moved the piece to another position, and if the squre was occupied by an enemy, it would capture that piece. Thinking about En passant and castling, I saw that these things were no longer true. So I saw, that I need to refactor in a way that I would be able to ask the move what it changes on the board.

So my board.move method needed to ask the move type what operation to perform

And moves could then return operations to the board:

def operations_on_board
   [
     { type: :capture, target: target },
     { type: :move, origin: position, target: target }
   ]
 end

It was a nice abstraction. I was also considering creating an Operation class that would then perform actions on the board. Which would have been even a nicer abstraction, but later felt like overkill. I would much later simplify this and only ask the move, how to place pieces, and which square to capture

Things start to break

So far things had gone pretty smooth, but suddenly I started to get endless loops. The test that provoked this was quickly identified, but it took me some time to realize that for the first time I had two kings on the board :-)

This was actually the one time, I felt quite stuck. It took me like two days if thinking until I realized what the problem was: Threatening a king is not necessarily a legal move! A rook puts another king in Check even if such a move would leave your own king in check.

I literally realized this mistake in the shower… :-)

And here came the fix

Castling: Subclassing

Castling moves felt quite unique. So I decided to implement really just one version of it at first. And tried to think what was common between castling moves and what was different:

  • Common: King and Rook cannot have moved, squares in between are not threatened, there are not blocking pieces in between
  • Different: Position of King, and Rook, Target positions of King and Rook.

So I created a Move::Castle class for the common behavior and Move::BlackLeftCastle to hold the different position. Probably this subclassing was a bit of an overkill, but honestly I somehow couldn't think through he conditionals needed. So if subclassing here, allows me to think less, then I'll take it.

En passant

After castling en passant felt easy. As the FEN notation contains the en passant target square, it felt like it would be easiset to just store that one on the board, and let a potential move check against that.

Implementation of en passant

Again I just did one direction to keep it simple, and only then did the en passant for the other color.

Where is the board display? The game? Checkmate? Stalemate?

So until that point, I had not seen a single string representation of my board, it felt like it would be

Half turn clock?

To determine whether to reset the half turn clock, I again relied on the move classes, since only capturing moves or moves from pawn do reset it. So every move class got a :resets_half_turn_clock? method.

Simulating situation for the special draw situations

So far my tests never really did more than one move. To simulate the threefold repetition and so one I needed a way to run games. So I decided to make a TestPlayer class that would just make moves that we pass to it in an Array.

You can see me getting lazy: I did not write a test for the threefold repetition, just ran a quick automated game in my main file.

Done

I did not polish the game at all, I wanted to get the rules right, but did not care much about saving state and nice visualization others do a much better job with that.

The only neat unnecessary thing I did was: To make it possible to start a game from the command line based on a FEN code.

ruby main.rb "rnbqkbnr/pp1ppppp/8/2p5/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2"

Reflections

  • It was a great experience.
  • I was really happy how I was able to continuously refactor with confidence thanks to my tests.
  • My tests never actually covered much of the methods on the Move classes, it just was simulation positions. But that was actually mostly beneficial, since I could refactor wildly on the move classes without having to touch the tests.
  • I was able to consistently move forward, looking through the commit history 1 month after finishing, the commits tell a story, without much back and forth
  • It was hugely beneficial to abstract the Move::Whatever from the start. It felt like the right abstraction from the start and more powerful, than having move logic on the Piece classes.
  • I love how easy it would be to add a new Piece with some new moves like:
# something like
class Magician < Piece
  def move_types
    [Move::Diagonal, Move::Horizontal, Move::Teleport]
  end
end

class Move::Teleport
  def candidate_positions
    Position.all_possible
  end
end
  • I was only really stuck once, before realizing legal_target_positions, and threatening_positions were not the same, but showering helped :-)

Also I found a deep respect for anyone who completed Chess, it truly is an achievement! Don't skip it!

Things that could be nicer

  • There are definitely some more things I could have extracted around some rules, and checks for check / checkmate and similar things but my classes felt manageable so it was ok, also the goal was not to create the perfect example (which is a matter of taste anyway)
  • The part I like the least is, that I needed to find the Class in my factory method I only recently realized, I should have returned move objects instead of just target positions, when asking a square/piece for what is possible from that position.

About

Ruby chess as suggested as a final project for the odin project

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages