Final project for the ruby course of the Odin Project.
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"
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!
- 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.
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
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.
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.
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.
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
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:
- feat: block horizontal movement with other pieces
- refactor: block horizontal movement with other pieces
- refactor: initialize moves with board and position
- feat: horizontal moves should not allow own pieces capturing
- refactor: dry up legal moves from both horizontal directions
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
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.
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
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
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 )
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.
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")
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.
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.
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
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… :-)
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.
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.
Again I just did one direction to keep it simple, and only then did the en passant for the other color.
So until that point, I had not seen a single string representation of my board, it felt like it would be
- nice to see something at this point.
- and a game loop
- checks for checkmate / stalemate
- and promotion
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.
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.
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"
- 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 thePiece
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!
- 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.