DEV Community

Cover image for Writing a chess game in python [Day 3]
rinaarts
rinaarts

Posted on

Writing a chess game in python [Day 3]

We're still in lockdown due to COVID-19, and I'm using the time to implement a textual chess game with my 12yo. In this series I'll be sharing what we did and how the project evolved.


We left off in Day 2 with a method which checks the basic validity of a chess move and one that checks if a rook move is valid, but we still haven't tested them so we don't know if they work. Or, to be more exact - as the code is untested - I am absolutely sure they don't work as expected, we just have to find where we went wrong. Today, we'll work on tests.

The first rule of testing is tests must be independent from each other, so we moved the board creation logic into a separate method to be reused in each test setup.

def create_board():
  board = [[EMPTY]*8]*8

  board[0] = ["WR","WN","WB","WQ","WK","WB","WN","WR"]
  board[1] = ["WP","WP","WP","WP","WP","WP","WP","WP"]
  board[6] = ["BP","BP","BP","BP","BP","BP","BP","BP"]
  board[7] = ["BR","BN","BB","BQ","BK","BB","BN","BR"]

  return board
Enter fullscreen mode Exit fullscreen mode

12yo asked what return did and I had some trouble explaining, these are really the hardest parts in teaching - when something you know so well it's totally embedded in the way you think - how can you explain return? What I came up with is that when someone calls the method it's like a teacher asked us to complete an assignment. If we don't return the result - they won't have access to our work, it will stay in our home computer (method scope) and the teacher won't be able to "see" it for grading. Maybe I could have explained better, but it worked so all's good.

Now, I said, we want to test our code. How will we do that? 12yo's answer was along the lines of "read the code and make sure we didn't miss anything". Now, that's good for whiteboard interviews - but we're writing high quality software here, we want to write tests that keep us safe during development and after we go to production! We need to write tests that execute our code and ensure it does what we meant!

First thing 12yo decided to test was if we implemented the is_valid_rook_move method correctly. This is what he came up with:

def test_rook_is_not_blocked():
  board = create_board()
  board[1][0] = EMPTY
  result = is_valid_rook_move(board, "WR", 2, 0)
  print(board)
  print(result)
Enter fullscreen mode Exit fullscreen mode

Output:

   ---- ---- ---- ---- ---- ---- ---- ----
8 | BR | BN | BB | BQ | BK | BB | BN | BR |
   ---- ---- ---- ---- ---- ---- ---- ----
7 | BP | BP | BP | BP | BP | BP | BP | BP |
   ---- ---- ---- ---- ---- ---- ---- ----
6 |    |    |    |    |    |    |    |    |
   ---- ---- ---- ---- ---- ---- ---- ----
5 |    |    |    |    |    |    |    |    |
   ---- ---- ---- ---- ---- ---- ---- ----
4 |    |    |    |    |    |    |    |    |
   ---- ---- ---- ---- ---- ---- ---- ----
3 |    |    |    |    |    |    |    |    |
   ---- ---- ---- ---- ---- ---- ---- ----
2 |    | WP | WP | WP | WP | WP | WP | WP |
   ---- ---- ---- ---- ---- ---- ---- ----
1 | WR | WN | WB | WQ | WK | WB | WN | WR |
   ---- ---- ---- ---- ---- ---- ---- ----
    a    b    c    d    e    f    g    h  
False
Enter fullscreen mode Exit fullscreen mode

Wait, what? That should be a valid move! The rook's path is totally open. We added some print commands in the code and found that after the first loop, we just exited.

def is_valid_rook_move(board, piece, target_rank, target_file):
  found_rank = -1
  found_file = -1
  for i in range(8):
    if board[target_rank][i] == piece:
      found_rank = target_rank
      found_file = i
      break

  if found_rank > -1:
    ...
Enter fullscreen mode Exit fullscreen mode

Oh! yeah... that should be if found_rank == -1!!! We want try and find a piece in the target file (column) only if we haven't found one in the target rank (row). Fixed that and it worked!
Output:

   ---- ---- ---- ---- ---- ---- ---- ----
8 | BR | BN | BB | BQ | BK | BB | BN | BR |
   ---- ---- ---- ---- ---- ---- ---- ----
7 | BP | BP | BP | BP | BP | BP | BP | BP |
   ---- ---- ---- ---- ---- ---- ---- ----
6 |    |    |    |    |    |    |    |    |
   ---- ---- ---- ---- ---- ---- ---- ----
5 |    |    |    |    |    |    |    |    |
   ---- ---- ---- ---- ---- ---- ---- ----
4 |    |    |    |    |    |    |    |    |
   ---- ---- ---- ---- ---- ---- ---- ----
3 |    |    |    |    |    |    |    |    |
   ---- ---- ---- ---- ---- ---- ---- ----
2 |    | WP | WP | WP | WP | WP | WP | WP |
   ---- ---- ---- ---- ---- ---- ---- ----
1 | WR | WN | WB | WQ | WK | WB | WN | WR |
   ---- ---- ---- ---- ---- ---- ---- ----
    a    b    c    d    e    f    g    h  
True
Enter fullscreen mode Exit fullscreen mode

Cool! Here I introduced the assert command. I told him if we write a lot of tests it's going to be very difficult to remember what the result we expected is for every test, we want something that's easy and simple to run and gives us a quick result we can count on. Assert asks: is this statement true? and if not, generates an error printed in red for our convenience. If there was no error, we should just print something to make sure our test ran successfully (I wrote this part):

def test_rook_is_not_blocked():
  board = create_board()
  board[1][0] = EMPTY
  result = is_valid_rook_move(board, "WR", 2, 0)
  assert result 
  print("test_rook_is_not_blocked passed")
Enter fullscreen mode Exit fullscreen mode

Output:

test_rook_is_not_blocked passed
Enter fullscreen mode Exit fullscreen mode

We added a test for a blocked rook and a test case for a horizontal move (the full code is right ahead, don't worry about that now).

After that I suggested we test the basic is_valid_move method as well, and guess what! We found a bug there too...
if 0 > target_file > 7 doesn't work :face-palm:.
Changed that into:

def is_valid_move(board, piece, target_rank, target_file):
  # out of bounds
  if  target_rank < 0 or target_rank > 7:
    return False
  if  target_file < 0 or target_file > 7:
    return False
  # piece with same color is in the target cell
  if board[target_rank][target_file][0] == piece[0]:
    return False

  if piece in ("WR", "BR"):
    return is_valid_rook_move(piece, target_rank, target_file)

  return True
Enter fullscreen mode Exit fullscreen mode

We ended up with the following test suite (he wrote all of this while I was checking social media on my phone wow):

def test_is_rank_valid():
  board = create_board()
  result = is_valid_move(board, "xx", 22, 4)
  assert not result
  result = is_valid_move(board, "xx", -2020, 4)
  assert not result
  result = is_valid_move(board, "xx", 0, 4)
  assert result
  print("test_is_rank_valid passed")

def test_is_file_valid():
  board = create_board()
  result = is_valid_move(board, "xx", 0, 22)
  assert not result
  result = is_valid_move(board, "xx", 0, -2020)
  assert not result
  result = is_valid_move(board, "xx", 0, 4)
  assert result
  print("test_is_file_valid passed")

def test_is_target_square_taken():
  board = create_board()
  result = is_valid_move(board, "Wx", 0, 1)
  assert not result
  print("test_is_target_square_taken passed")

def test_rook_is_blocked():
  board = create_board()
  result = is_valid_rook_move(board, "BR", 5, 0)
  assert not result
  board[7][5] = EMPTY
  result = is_valid_rook_move(board, "BR", 7, 5)
  assert not result
  print("test_rook_is_blocked passed")

def test_rook_is_not_blocked():
  board = create_board()
  board[1][0] = EMPTY
  result = is_valid_rook_move(board, "WR", 2, 0)
  assert result
  board[0][1] = EMPTY
  result = is_valid_rook_move(board, "WR", 0, 1)
  assert result 
  print("test_rook_is_not_blocked passed")

def run_tests():
  test_is_rank_valid()
  test_is_file_valid()
  test_is_target_square_taken()
  test_rook_is_blocked()
  test_rook_is_not_blocked()
run_tests()
Enter fullscreen mode Exit fullscreen mode

Output:

test_is_rank_valid passed
test_is_file_valid passed
test_is_target_square_taken passed
test_rook_is_blocked passed
test_rook_is_not_blocked passed
Enter fullscreen mode Exit fullscreen mode

YEAH!!!


One last thing I just had to deal with is that incredibly nested if-and-loop in is_valid_rook_move:

if found_rank == target_rank:
  if found_file > target_file:
    for i in range(target_file+1, found_file):
      if board[target_rank][i] != "  ":
        return False
  else: # found_file < target_file
    for i in range(found_file+1, target_file):
      if board[target_rank][i] != "  ":
        return False
else: # found_file == target_file
  if found_rank > target_rank:
    for i in range(target_rank+1, found_rank):
      if board[i][target_file] != "  ":
        return False
  else: # found_rank < target_rank
    for i in range(found_rank+1, target_rank):
      if board[i][target_file] != "  ":
        return False
Enter fullscreen mode Exit fullscreen mode

I asked 12yo if there was some common logic we could extract here, and to be completely honest he didn't. But that's OK, it is a bit hard to see. I explained that it didn't matter if the piece was found before or after the target square, the logic was the same - we just needed to tell the code where to start and when to finish. We extracted the indexes like so (I wrote this part):

if found_rank == target_rank:
    start_file = min(target_file+1, found_file+1)
    end_file = max(target_file, found_file)
    for i in range(start_file, end_file):
        if board[target_rank][i] != EMPTY:
          return False
  else: # found_file == target_file
    start_rank = min(target_rank+1, found_rank+1)
    end_rank = max(target_rank, found_rank)
    for i in range(start_rank, end_rank):
        if board[i][target_file] != EMPTY:
          return False
Enter fullscreen mode Exit fullscreen mode

And re-ran the tests:

test_is_rank_valid passed
test_is_file_valid passed
test_is_target_square_taken passed
test_rook_is_blocked passed
test_rook_is_not_blocked passed
Enter fullscreen mode Exit fullscreen mode

DOUBLE YEAH!!!

Final code for today (plus that magnificent test suite above):

EMPTY = "  "

def print_board(board):
  row_number = 8
  print("  ", end="")
  print(" ----"*8)
  for row in reversed(board):
      print(row_number, end=" ")
      row_number -= 1
      for cell in row:
          print("| {} ".format(cell), end="")
      print("|")
      print("  ", end="")
      print(" ----"*8)
  print("  ", end="")
  for letter in ['a','b','c','d','e','f','g','h']:
      print("  {}  ".format(letter), end="")
  print("")

def create_board():
  board = [[EMPTY]*8]*8

  board[0] = ["WR","WN","WB","WQ","WK","WB","WN","WR"]
  board[1] = ["WP","WP","WP","WP","WP","WP","WP","WP"]
  board[6] = ["BP","BP","BP","BP","BP","BP","BP","BP"]
  board[7] = ["BR","BN","BB","BQ","BK","BB","BN","BR"]

  return board

def is_valid_move(board, piece, target_rank, target_file):
  # out of bounds
  if  target_rank < 0 or target_rank > 7:
    return False
  if  target_file < 0 or target_file > 7:
    return False
  # piece with same color is in the target cell
  if board[target_rank][target_file][0] == piece[0]:
    return False

  if piece in ("WR", "BR"):
    return is_valid_rook_move(piece, target_rank, target_file)

  return True

def is_valid_rook_move(board, piece, target_rank, target_file):
  found_rank = -1
  found_file = -1
  for i in range(8):
    if board[target_rank][i] == piece:
      found_rank = target_rank
      found_file = i
      break

  if found_rank == -1:
    for i in range(8):
      if board[i][target_file] == piece:
        found_rank = i
        found_file = target_file
        break

  if found_rank < 0 or found_file < 0:
    return False 

  if found_rank == target_rank:
    start_file = min(target_file+1, found_file+1)
    end_file = max(target_file, found_file)
    for i in range(start_file, end_file):
        if board[target_rank][i] != EMPTY:
          return False
  else: # found_file == target_file
    start_rank = min(target_rank+1, found_rank+1)
    end_rank = max(target_rank, found_rank)
    for i in range(start_rank, end_rank):
        if board[i][target_file] != EMPTY:
          return False
  return True
Enter fullscreen mode Exit fullscreen mode

And that's it for today. Join us tomorrow when we implement actually moving rooks around on the board...

Top comments (0)