Battleship game in Python

Who has never drew two grids on a paper and played Battleship with a friend? It’s a childhood memory of almost everyone and the rules are known all over the world! This board game has its origins between the end of the 19th century and the beginning of the 20th century, with signs of having been played even before World War I.

Example of Battleship board, with the ships in grey, bad hits in blue (water) and good hits in red (ships).

The rules are simple. Each one of the two players has a grid of equal size and has to define the secret position of the ships in the grid, knowing that these can’t overlap nor, sometimes, be attached to one another. Each ship corresponds to a consecutive line of n squares, where n is the size of the vessel (normally between 2 and 5). In turns, each player has to choose a cell to drop a bomb on (for example, A4), and the opponent says if it hit the water or a part of a ship. The winner is the first player to destroy all ships of the opponent.

Retro version of Battleship. From Sentimental As Anything.

Battleship started as a simple paper and pencil game, later appearing in three-dimensional plastic versions and, finally, it was one of the first games to be adapted for a computer version. To honor this last part, let’s try to build a simple interactive Battleship game with Python!

Making the board

The first thing to think about must be the board game, and there are several requirements we have to meet:

  • The board must be a square 2D array. Let’s decide for a 9×9 matrix!
  • The ships must be placed randomly, so each time we generate a new board the layout of the fleet must be random and different.
  • The ships cannot overlap or lean against each other. To make the job easier, let’s start with only 3-cell boats.

The first thing to do is to start the general class of the game with the initial necessary methods: a constructor, a ship generator and some methods to return attributes the user:

class Battleship():
    
    def __init__(self, boats=3):
        #class constructor with the number of 
        #boats as a parameter (default of 3 boats)

    def add_boat(self):
        #add a 3-cell boat to the board
               
    def get_board(self):     
        #return the board 2D array

    def get_boats(self):
        #return a list with the ships locations
    
    def show_board(self):
        #show the board as an image

The idea is to generate the board in the constructor and add each ship at a time calling the method add_boat inside a for loop. So, before that, let’s see the ship generator method. As we will only create 3-cell ships, the function can be divided into three main parts:

  1. Add the fist ship cell in a random cell of the board that is not part of another ship or in the direct neighborhood of a ship.
  2. Add the second cell (consecutive) in one of the possible directions (randomly chosen).
  3. Add the third and last cell in one of the possible directions (randomly chosen).

Check the code below:

def add_boat(self):
        
        #add a 3 cell boat to the board
        
        done = False
        while done == False:
            
            #create temporary board
            board = np.zeros((9,9))
            
            boat = []
            
            #check if initial boat cell is valid
            invalid = True
            while invalid:
                row = random.randint(0, 8)
                col = random.randint(0, 8)
                if self.main_board[row, col] == 0:
                    invalid = False
            
            #get initial cell
            board[row, col] = 1
            boat.append((row, col))
            
            #possible second cell
            possible = [(boat[-1][0]-1, boat[-1][1]), (boat[-1][0]+1, boat[-1][1]), (boat[-1][0], boat[-1][1]-1), (boat[-1][0], boat[-1][1]+1)]
            
            #remove the invalid second cells
            possible = [coordinates for coordinates in possible if -1 not in coordinates and self.main_board.shape[0] not in coordinates and coordinates not in self.not_possible]
            
            #get the random second cell
            row, col = random.sample(possible, 1)[0]
            board[row, col] = 1
            boat.append((row, col))
            
            #possible third cell
            if boat[-1][0] == boat[0][0]:
                if boat[-1][1] > boat[0][1]:
                    possible = [(boat[-1][0], boat[0][1]-1), (boat[-1][0], boat[-1][1]+1)]
                else:
                    possible = [(boat[-1][0], boat[0][1]+1), (boat[-1][0], boat[-1][1]-1)]
            else:
                if boat[-1][0] > boat[0][0]:
                    possible = [(boat[0][0]-1, boat[-1][1]), (boat[-1][0]+1, boat[-1][1])]
                else:
                    possible = [(boat[0][0]+1, boat[-1][1]), (boat[-1][0]-1, boat[-1][1])]
            
            #validate possibilities
            possible = [coordinates for coordinates in possible if -1 not in coordinates and self.main_board.shape[0] not in coordinates and coordinates not in boat and coordinates not in self.not_possible]
            
            #add third cell
            try:
                row, col = random.sample(possible, 1)[0]
            except:
                continue
            board[row, col] = 1    
            boat.append((row, col))
        
            #get neighbors of the boat
            footprint = np.array([[1,1,1],
                                  [1,0,1],
                                  [1,1,1]])
            
            board = ndimage.generic_filter(board, get_neighbors, footprint=footprint)
            
            #define the neighbors and boats as -1
            board = np.where(board!=0, -1, board)
            
            #define boat cells as 1
            for row, col in boat:
                board[row, col] = 1
                
            #join the temporary board to the main board
            self.main_board = self.main_board + board
            
            done = True
    
        return boat

During the board creation, a ship cell is represented by a 1 in the matrix, the water is 0 and the neighborhood of a ship -1. At the end of the creation, the -1 cells are changed to 0, leaving only two types of cell: ship (1) and water (0). By calling the ship generator inside a for loop, we end up with a complete random board:

    def __init__(self, boats = 3):
        
        self.num_boats = boats

        #the board starts as 2D array with 9 rows, 9 columns and all values set to zero
        self.main_board = np.zeros((9,9))
        
        #initialization of some variables of the game
        self.win = False
        self.hits = []
        self.attempts = []
        self.boats_destroyed = []
        
        #create the board, getting the list of boats and the list of blocked cells (not possible to insert a boat cell) 
        self.boat_list = []
        self.not_possible = []
        for _ in range(self.num_boats):
            self.boat_list.append(self.add_boat())
            rows, cols = np.where(self.main_board == -1)
            self.not_possible = [(rows[i], cols[i]) for i in range(len(rows))]
        
        #sort the cells of each boat
        for boat in self.boat_list:
            boat = boat.sort()
        
        #replace all -1 in the board by 0, so now a boat cell is represented by one and the rest is 0
        self.main_board = np.where(self.main_board!=1, 0, self.main_board)

By the end of the constructor execution, we get 2 main attributes: the board and the list of boats, which is a list of lists of tuples, where each tuple has the row and the column of the boat cell (for example, [ [ (1,2), (1,3), (1,4) ], [ (5,6), (6,6), (7,6) ], [ (9,1), (9,2), (9,3) ] ]).

We can return these attributes to the user using two different methods of the class:

    def get_board(self):     
        return self.main_board
    
    def get_boats(self):
        return self.boat_list

And, finally, we can show the board with the boats by using the matplotlib package and use a dictionary to transform the 9×9 array into a 9x9x3 RGB array with colors:

    def show_board(self):
        
        #show board as image with matplotlib 
        
        colors = {   0:  [90,  155,  255],
                     1:  [88,  88,  88]}    
        
        image = np.array([[colors[val] for val in row] for row in self.main_board], dtype='B')
        plt.imshow(image)
        plt.axis('off')
        plt.show()

And that’s it! Now we have a simple Battleship class that can generate random game boards and everything was done with hard code! By creating an object and calling the show function, we can see the board with the chosen colors:

def main():   

    game = Battleship()  
    game.show_board()
    
if __name__ == '__main__':
    
    main()

Creating the game

Now that we have the boards, let’s create an interactive game using only the python console.

The player has to choose cells to drop bombs, so we have to create a board specific for the player. This board has to show the labels of the rows / columns (letters / numbers) and must be updated each time the player tries to hit a ship. Let’s had 5 rows at the end of the class constructor, to build this second board:

self.player_cols = [str(n) for n in range(1, 10)]
self.player_rows = ["A", "B", "C", "D", "E", "F", "G", "H", "I"]
self.player_board = [list(" ") + self.player_cols]
for i in range(len(self.player_rows)):
     self.player_board.append(list(self.player_rows[i]) + [" "]*9)

Now let’s add a class method to print the player’s board.

def print_player_board(self):
     print()
     for x in self.player_board:
          print(*x, sep=' ')

Regarding the playing itself, I’ll show the three methods that make up the entire game process, and then we can talk about the code:

def play(self):
    
    #top level method
    
    print("Welcome to Battleship!")
    print("You have 5 ships to destroy (all with 3 cells of size). Good luck!")
    
    self.print_player_board()
    
    while self.win==False:
        
        #ask for a guess
        guess = input("\n{} - Insert your guess (ROWCOL) or 'exit': ".format(len(self.attempts) + 1))
        
        #let the user insert lower or upper letters
        guess = guess.upper()
        
        #check for exit
        if guess == "EXIT":
            
            sys.exit()
            
        else:
            
            #add guess
            valid = self.add_guess(guess)
            
            #check if guess was valid
            if valid == "yes":
                self.print_player_board()
            else:
                print(valid)

    #won game  
    print("\n\nCongratulations, you won with {} attempts!".format(len(self.attempts)))   
        
def add_guess(self, guess):
    
    #insert a guess
    
    #validate the guess
    if len(guess) != 2:
        return ">> Invalid guess!"
    
    if not guess[0].isalpha() or not guess[1].isdigit():
        return ">> Invalid guess!"
    
    if guess[0] not in self.player_rows or int(guess[1]) < 0 or int(guess[1]) > 9:
        return ">> Invalid guess!"
    
    #get the row and column of the guess (correspondent to the game board, not the player board)
    guess_row = self.player_rows.index(guess[0])
    guess_col = int(guess[1])-1
    
    #check if is repeated guess
    if (guess_row, guess_col) in self.attempts:
        return ">> Repeated guess!"
    
    self.attempts.append((guess_row, guess_col))
    
    #check if the guess hit a boat
    is_boat = any((guess_row, guess_col) in sublist for sublist in self.boat_list) 
    if is_boat:       
        self.player_board[guess_row+1][guess_col+1] = "x"
        self.hits.append((guess_row, guess_col))
        print(">> You hit a boat!")
        self.check_boats_destroyed()
        
    else:
        self.player_board[guess_row+1][guess_col+1] = "o"
        print(">> Oops...whater!")
        
    #check if game was won
    self.win = self.check_win()
    
    return "yes"

def check_boats_destroyed(self):
    
    #check for destroyed boats
    
    for i in range(len(self.boat_list)):
        if all(i in self.hits for i in self.boat_list[i]) and i not in self.boats_destroyed:
            self.boats_destroyed.append(i)
            print(">> {} boat(s) destroyed!".format(len(self.boats_destroyed)))
    
def check_win(self):
    
    #check if game was won
    
    win = False
    if self.num_boats == len(self.boats_destroyed):
        win = True
        
    return win

In short, the play method is a loop that repeatedly asks the player for a guess and shows the updated player’s board until all ships are destroyed. Each guess is passed to the add_guess method, which has several jobs:

  • Validate the guess (two character string, letter and number between the ranges, not repeated, etc)
  • Check if the valid guess hit a ship or not
  • Update the player’s board, where a hit on water is represented as an o and a hit on ship is represented as an x.
  • Check the number of boats already destroyed (calling the method check_boats_destroyed)
  • Check if the game was won (calling the method check_win)

And that’s it! Now let’s check a video of the game in action, with the corresponding board on the right:

You can check the complete code on my Github!

Future work

Now that we can play battleship in the Python console, why not try to create a GUI just for this game?

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: