Bird Sound Generator

The Generative Adversarial Network (GAN) is a recent framework proposed to improve the performance of generative models by introducing adversarial training techniques. For this, two different Artificial Neural Networks are trained simultaneously: a Generator, G, and a Discriminator, D. The Generator learns the distribution of the training data and tries to generate new samples from a random noise input, as similar as possible to the original samples. At the same time, the Discriminator wants to distinguish between real and generated samples. In conclusion, a GAN is a two-player game between the two ANNs.

The training of a GAN is made by alternatively passing the real samples and then the generated samples through the Discriminator, computing the classification loss. The Discriminator is then updated by backpropagation to minimize the classification loss of all samples and the Generator is updated to maximize the Discriminator’s loss for the generated samples, that is, is updated to generate better and better samples, increasingly similar to the real ones.

The most common applications for GANs are with image data, and these models are currently capable of generating unbelievably real and high quality pictures. Moreover, the quality of style transfer results became spectacular with the use of Cycle-GANs, a more recent variant of these generative models. Finally, the integration of GANs with NLP (Natural Language Processing) methods even made possible for computers to create images just by reading a text label. Sounds like science fiction right? Look at the examples below!

In the first image we can see hyper realistic pictures of imaginary celebrities, entirely generated by a GAN. The second one is an example of style transfer done by a Cycle-GAN, allowing toggling between Monet like paintings and photos, and the last example shows the potential of fusing NLP with GANs.


GANs and Sounds

Having some experience with GANs and images (check my Master Thesis on this page), I thought it would be cool to try the capabilities of these models with sound data, that is, simple 1D arrays. I’ve seen some cool experiments like this, so why not do it myself?

After some research, I decided to use the British Bird Song Dataset from Kaggle to get the sound data, and Tensorflow as the main library for deep learning.

First of all, these were all the imports needed to run this project,:

import tensorflow as tf
for g in tf.config.experimental.list_physical_devices('GPU'):
    tf.config.experimental.set_memory_growth(g, True)
import numpy as np
import os
import soundfile as sf 
import glob
import random
import sys
import pandas as pd

Dataset

The dataset is composed of 264 sound files (.flac extension) with different durations and bird songs. To open the files as arrays in Python I used the soundfile library.

Example of a sound file from the dataset

Loading and preparing the data

Now we have to load and prepare the data! The files have different sizes and are too big to be fed to a simple Neural Network, so let’s also create a function to split the files in 5 chunks:

split_size = 5

def chunks(x, n):
    n = max(1, n)
    return [x[i:i+n] for i in range(0, len(x), n) if len(x[i:i+n]) == n]

def load_dataset():
    sounds = []
    rates = []
    min_chunk_size = float('inf')
    for filename in glob.glob('dataset/songs/*.flac'):                                              
        data, samplerate = sf.read(filename, dtype='float32')   
        seconds = len(data)/samplerate
        num_chunks = int(seconds/split_size)
        if num_chunks > 0:
            chunk_size = int(len(data) / num_chunks)
            if chunk_size < min_chunk_size:
                min_chunk_size = chunk_size
            splitted = chunks(data, chunk_size)    
            splitted = [np.array(c) for c in splitted]
            sounds.extend(np.array(splitted))
        rates.extend([samplerate]*len(splitted))
    sounds = [s[:min_chunk_size] for s in sounds]
    return sounds, rates, min_chunk_size

After loading all files and splitting them it into smaller pieces, we end up with a list of 3414 arrays, and each one of these is then cropped to match the size of the smaller one. That way all elements of the list have the same length and correspond to some seconds of bird sounds.

Building the models

Both the Generator and the Discriminator are a simple Multi Layer Perceptron (MLP), that is, an Artificial Neural Network with only dense layers of neurons. The loss function used for both will be the Binary Cross Entropy Loss and the optimizer will be the Adam Optimizer.

The Generator receives an input array of 500 elements (input noise) and has a total of 4 layers. The first comprises 256 neurons, the second 512 neurons and and the third 1024 neurons, all followed by a Leaky ReLU activation function. This activation function is very similar to the ReLU but with a small negative slope, allowing neurons with negative output to activate and recover from it instead of being stuck at a 0 activation. The final layer has a number of neurons equal to the size of the dataset sound arrays (so the input of the Discriminator has always the same dimension) and Tanh as activation function.

def Generator(min_chunk_size):
    
    G = tf.keras.models.Sequential()
    
    G.add(tf.keras.layers.Dense(units=256,input_dim=500))
    G.add(tf.keras.layers.LeakyReLU(0.2))
    
    G.add(tf.keras.layers.Dense(units=512))
    G.add(tf.keras.layers.LeakyReLU(0.2))
    
    G.add(tf.keras.layers.Dense(units=1024))
    G.add(tf.keras.layers.LeakyReLU(0.2))
    
    G.add(tf.keras.layers.Dense(units=min_chunk_size, activation='tanh'))
    
    G.compile(loss='binary_crossentropy', optimizer='adam')
    
    return G

The Discriminator receives the real and generated samples as input and also has a total of 4 layers. The first comprises 1024 neurons, the second 512 neurons and and the third 1024 neurons, all also followed by a Leaky ReLU activation function. The last layer has one single neuron that outputs the probability of the input being a real sample (1 corresponds to 100% probability of being a real sample), with a Sigmoid activation function.

def Discriminator(min_chunk_size):
    
    D = tf.keras.models.Sequential()
    
    D.add(tf.keras.layers.Dense(units=1024,input_dim=min_chunk_size))
    D.add(tf.keras.layers.LeakyReLU(0.2))

    D.add(tf.keras.layers.Dense(units=512))
    D.add(tf.keras.layers.LeakyReLU(0.2))
  
    D.add(tf.keras.layers.Dense(units=256))
    D.add(tf.keras.layers.LeakyReLU(0.2))
    
    D.add(tf.keras.layers.Dense(units=1, activation='sigmoid'))
    
    D.compile(loss='binary_crossentropy', optimizer='adam')
    
    return D

Training method

As explained in the beginning of this post, each training step consists on creating a random input noise, feeding it to the Generator to get the fake (generated) batch and then getting the output of the Discriminator for both fake and real batches. The two neural networks are then updated to follows their correspondent goals: the Discriminator has to distinguish between real and fake samples and the Generator has to create samples that are classified as real by the Discriminator (that’s why we use an array of ones as ground truth when computing the loss of the Generator).

def train_step(X_real):

    noise = tf.random.normal([batch_size, 500])

    with tf.GradientTape(True) as tape:

        X_fake = G(noise, training=True)
        P_fake = D(X_fake, training=True)
         
        X_real = tf.stack(X_real, axis=0)
        P_real = D(X_real, training=True)

        G_loss = bce_loss(tf.ones_like(P_fake), P_fake)
        D_loss = bce_loss(tf.zeros_like(P_fake), P_fake)
        D_loss += bce_loss(tf.ones_like(P_real), P_real)

    grads = tape.gradient(G_loss, G.trainable_variables)
    G_opt.apply_gradients(zip(grads, G.trainable_variables))

    grads = tape.gradient(D_loss, D.trainable_variables)
    D_opt.apply_gradients(zip(grads, D.trainable_variables))

    P_avg_real = tf.reduce_mean(tf.math.sigmoid(P_real))
    P_avg_fake = tf.reduce_mean(tf.math.sigmoid(P_fake))

    return P_avg_real, P_avg_fake, G_loss, D_loss

Training loop

After defining the number of epochs and the batch size, loading the dataset and building the models, the training step is called for all batches repeatedly. At the end of each epoch, three output samples are saved, so we can

epochs = 250
batch_size = 32

#load dataset
sounds, rates, min_chunk_size = load_dataset()

#build models
G = Generator(min_chunk_size)
# G.summary() #uncomment this to see the architecture of the generator

D = Discriminator(min_chunk_size)
# D.summary() #uncomment this to see the architecture of the discriminator

#define optimizers
G_opt = tf.keras.optimizers.Adam(2e-4, 0.5)
D_opt = tf.keras.optimizers.Adam(2e-4*5, 0.5)

#define loss function
bce_loss = tf.keras.losses.BinaryCrossentropy(True)

#create a random noise batch of 3 to feed to the generator at the end of each epoch
debug_noise = tf.random.normal([3, 500])

losses_g = []
losses_d = []
prob_avg_real_list = []
prob_avg_fake_list = []

#training loop
for epoch in range(epochs):

    #shuffle dataset list
    random.shuffle(sounds)

    print(f'* Epoch {epoch+1}/{epochs}')

    #initialize some temporary training variables
    avg_probs = np.zeros(2, np.float32)
    avg_losses = np.zeros(2, np.float32)
    steps = int(np.ceil(len(sounds) / batch_size))
    losses_g__ = []
    losses_d__ = []
    prob_avg_real_list__ = []
    prob_avg_fake_list__ = []
    idx = 0

    #loop through batches
    for _ in range(steps):

        sys.stdout.write("\r   - Step {}/{}".format(_, steps))
        sys.stdout.flush()

        #training step
        avg_probs += np.array(train_step(sounds[idx:idx+32])) / steps

        #save training data
        avg_probs += np.array((P_avg_real, P_avg_fake)) / steps
        avg_losses += np.array((G_loss, D_loss)) / steps
        prob_avg_real_list__.append(avg_probs[0])
        prob_avg_fake_list__.append(avg_probs[1])
        losses_g__.append(avg_losses[0])
        losses_d__.append(avg_losses[1])

        idx = idx + 32

    #save epoch summary data 
    prob_avg_real_list.append(mean(prob_avg_real_list__))
    prob_avg_fake_list.append(mean(prob_avg_fake_list__))
    losses_g.append(mean(losses_g__))
    losses_d.append(mean(losses_d__))

    print(' - %ds - D real avg: %f, D fake avg: %f' % (dt, *tuple(avg_probs)))

    #get the output of the generator for the debug noise and save it as three sound files
    output = G(debug_noise)
    for i in range(output.size[0]):
        sf.write('output_epoch{}_{}.wav'.format(epoch, i), output[i], rates[0])

After training the models, it’s a good habit to look at the evolution of the losses and the probabilities throughout the epochs, which are all saved in the lists created previously.

#save losses evolution as a plot
plt.figure()
plt.plot(losses_g, label="Generator Loss")
plt.plot(losses_d, label="Discriminator Loss")
plt.xlabel("Step")
plt.ylabel("Loss")
plt.title("Generator and Discriminator Loss")
plt.legend()
plt.savefig(path + "losses.png")
plt.close('all')

#save losses evolution as a CSV
pd.DataFrame({'D LOSS': losses_d, 'G LOSS': losses_g}).to_csv("losses.csv", index=False)

#save probabilities evolution as a plot
plt.figure()
plt.plot(prob_avg_real_list, label="Real probability")
plt.plot(prob_avg_fake_list, label="Fake probability")
plt.xlabel("Step")
plt.ylabel("Probability")
plt.title("Real and Fake Probabilities")
plt.legend()
plt.savefig(path + "probabilities.png")
plt.close('all')

#save probabilities evolution as a CSV
pd.DataFrame({'REAL PROB': prob_avg_real_list, 'FAKE PROB': prob_avg_fake_list}).to_csv("probs.csv", index=False)

Results

Firstly, looking at the training plots:

  • It’s difficult to interpret the losses plot because of the 3 peaks.
  • The fake and real probabilities remained always between 0.26 and 0.36, which can mean that the discriminator not only struggled to distinguish between real and fake samples but also to learn the distribution of the real sounds, and was never sure about the prediction.

As it is difficult to get information from the losses plot, let’s look at the CSV with the data and remove those peaks to check the actual train evolution. Now it’s possible to see that the discriminator’s loss was low during the entire training in relation to the generator’s loss, which increased in general.

Regardless of the technical characteristics of the training, the important is for the generator to be capable of creating bird sounds from just random noise. I know you are curious to hear the generator outputs, so here you go!

Generator’s input noise

Generator’s output after 1 epoch

Generator’s output after 50 epochs

Generator’s output after 150 epoch

Generator’s output after 250 epochs – last output

So, despite the not-perfect technical training metrics, the generator was actually capable of generating sound since the first epoch! The first output is very soft and has a lot of noise, but it’s possible to hear some sounds that resemble birds singing, which is amazing! Naturally, as the training evolves, the sounds become sharper and more intense, and after the last epoch it’s possible to hear lots of different birds singing at the same time and even some of the sounds appear and disappear in the middle of the 5 seconds. A good sound cleaning process would make it even better!

Conclusion

If I close my eyes and listen to the last generated output I can imagine myself exploring some isolated rain forest, which is enough to consider this project a complete success!

Battleship with GUI in Python

Following up the last post, where a Battleship game was built entirely with Python, the next step is to give the complete experience to the player with a graphic user interface (GUI). Tkinter is the standard Python package to build GUIs, having a lot of support and working on different operating systems (Windows, macOS, Linux, …).

Before starting, these are all the needed imports to make this project:

import numpy as np 
import random
import scipy.ndimage as ndimage
import matplotlib.pyplot as plt
import matplotlib
matplotlib.use("Agg")
import tkinter as tk
import os
from PIL import ImageTk, Image
import time
from tkinter import messagebox
import shutil

The backend of this game is the same as the last post and the main structure will remain as a class “Battleship”, which possesses several methods:

  • __init__ (constructor) initialize the game window and add some start widgets inside
  • on_closing – manage the closing action (click on exit cross)
  • start – manage game initialization and insert the remaining widgets in the window
  • create_boards – create the solution and player’s boards
  • add_boat – add one 3×3 boat to the board (not changed)
  • save_board_pic – save a board as PNG in a defined directory
  • show_board – show the player’s board in the window
  • add_guess – get a new guess, validate it and manage the state of the game
  • check_boats_destroyed – check the number of boats destroyed (not changed)
  • check_win – check if the player has won (not changed)
  • see_solution – momentarily show the solution board in the window

__init__ (constructor)

The constructor creates the game window, maximizes it, configures the closing action to call the on_closing method and then inserts the first widgets. These widgets let the player choose the number of boats (1 to 5) and click on the start button to begin the game (calling the start method).

def __init__(self):

    #create root window, and configure it
    
    self.root = tk.Tk()
    
    self.root.configure(bg="white")
    self.root.state('zoomed')
    
    self.root.title("Battleship")

    self.root.protocol("WM_DELETE_WINDOW", self.on_closing)

    #create frames and initial widgets
    
    frame1 = tk.Frame(self.root, bg="#0e0ba7")
    frame1.pack(anchor="n", fill="x")
    
    label = tk.Label(frame1, text="Battleship", font="none 18 bold", bg="#0e0ba7", fg="white")
    label.pack(anchor="n", pady=20, fill="x")
    
    frame2 = tk.Frame(self.root, bg="white")
    frame2.pack(anchor="n", pady=30)
    
    label = tk.Label(frame2, text="Number of boats: ", font="none 12 bold", bg="white")
    label.pack(side="left", anchor="c", fill="y", padx=5)
    
    options = [ 
        "1", 
        "2", 
        "3", 
        "4", 
        "5"
    ] 

    self.option_boats = tk.StringVar()  
    self.option_boats.set("3") 
    drop = tk.OptionMenu(frame2, self.option_boats , *options ) 
    drop.pack(side="left", anchor="c", padx=5) 
    
    button = tk.Button(frame2, text="Start", width=8, command=self.start)
    button.pack(side="left", anchor="c", padx=5)
    
    self.game_frame = tk.Frame(self.root, bg="white")
    self.game_frame.pack(anchor="n")
    
    self.root.mainloop()

The on_closing method shows a pop up to the player to confirm the exit and then deletes the temporary folder that is created to store the board images:

def on_closing(self):

    #deal with closing event 

    if messagebox.askokcancel("Quit", "Do you want to quit?"):
        shutil.rmtree("temporary")
        self.root.destroy()

start

This method checks the number of boats to create, calls the method create_boards and insert the remaining widgets in the window, including the player’s board (show_board method).

def start(self):

    #create the remaining widgets
    
    for widget in self.game_frame.winfo_children():
        widget.destroy()
    
    self.num_boats = int(self.option_boats.get())
    
    self.create_boards()
    
    self.solution = False
    
    frame3 = tk.Frame(self.game_frame, bg="white")
    frame3.pack(anchor="n")

    label = tk.Label(frame3, text="Your guess: ", font="none 13 bold", bg="white")
    label.pack(side="left", anchor="c", fill="y", pady=30)
    
    self.entry_guess = tk.Entry(frame3, width=5, font="none 12", selectborderwidth=3, bd=3, justify=tk.CENTER)
    self.entry_guess.pack(side="right", anchor="c")
    
    btn_frame = tk.Frame(self.game_frame, bg="white")
    btn_frame.pack(anchor="n")
    
    button = tk.Button(btn_frame, text="Try guess", width=10, command=lambda: self.add_guess(self.entry_guess.get()))
    button.pack(side='left', anchor="n", padx=5)
    
    self.entry_guess.bind("<Return>", lambda x: self.add_guess(self.entry_guess.get()))
    
    self.see_sol_button = tk.Button(btn_frame, text="See solution", width=10, command=self.see_solution)
    self.see_sol_button.pack(side='left', anchor="n", padx=5)
    
    button = tk.Button(btn_frame, text="Restart", width=10, command=self.start)
    button.pack(side='left', anchor="n", padx=5)
    
    self.result = tk.Label(self.game_frame, text="", font="none 11 bold", bg="white")
    self.result.pack(anchor="n", pady=10, fill="x")     
    
    self.boats_destroyed_label = tk.Label(self.game_frame, text="{} / {} boats destroyed".format(len(self.boats_destroyed), self.num_boats), font="none 10", bg="white")
    self.boats_destroyed_label.pack(anchor="n", pady=10, fill="x")
    
    self.board_frame = tk.Frame(self.game_frame, bg="blue")
    self.board_frame.pack(anchor="n")
    
    self.show_board()

So, the player can insert a guess (letter and number) in the entry box and click on the try guess button (or simply click enter – check line 29), can see the solution and restart the game (with a new board).


create_boards

On this version of the game, with a GUI, the player’s board is no longer a string to show in a console but a 9×9 matrix just like the secret_board. The two boards are saved in a temporary folder.

def create_boards(self):
    
    #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)
    
    self.player_rows = ["A", "B", "C", "D", "E", "F", "G", "H", "I"]
    
    #create the board that will be presented to the player
    self.player_board = np.zeros_like(self.main_board)
        
    if not os.path.exists('temporary'):
        os.makedirs('temporary')
        
    self.save_board_pic(self.main_board, 'temporary/secret_board.png')
    self.save_board_pic(self.player_board, 'temporary/player_board.png')

save_board_pic

Knowing that a 0 corresponds to water, a 1 to a boat, a 2 to a hit on water and a 3 to a hit on a boat and using a dictionary to give RGB values it’s possible to create an image like array. Using the capabilities of the matplotlib package, it’s possible to create the axis of the picture with the classical letters and numbers of the Battleship game, as well as the grid like board.

def save_board_pic(self, board, path):
    
    #create image and save it as png
    
    colors = {   0:  [90,  155,  255],
                 1:  [88,  88,  88],
                 2:  [14,  11,  167],
                 3:  [255,  0,  0]}
    
    image = np.array([[colors[val] for val in row] for row in board], dtype='B')

    fig = plt.figure()
    ax = fig.add_axes([0.1, 0.1, 0.8, 0.8]) 
    ax.set_xticks([0,1,2,3,4,5,6,7,8])
    ax.set_yticks([0,1,2,3,4,5,6,7,8])
    ax.invert_yaxis()
    ax.xaxis.tick_top()
    ax.imshow(image)
    ax.set_yticklabels(["A", "B", "C", "D", "E", "F", "G", "H", "I"])
    ax.set_xticklabels(["1", "2", "3", "4", "5", "6", "7", "8", "9"])
    for i in list(np.arange(0.5, 8.5, 1)):
        plt.axvline(x = i, color = 'black', linestyle = '-', lw=0.5) 
        plt.axhline(y = i, color = 'black', linestyle = '-', lw=0.5) 
    plt.tick_params(axis='both', labelsize=12, length = 0)
    plt.savefig(path)
Player’s board (in the beginning) versus secret board

show_board

This method is the one responsible to show the board to the user. In short, the saved PNG of the board is opened, resized and then added to a Tkinter Label that is inserted in the window.

def show_board(self):
    
    for widget in self.board_frame.winfo_children():
        widget.destroy()
    
    img = Image.open('temporary/player_board.png')
    
    basewidth = 800
    wpercent = (basewidth/float(img.size[0]))
    hsize = int((float(img.size[1])*float(wpercent)))
    img = img.resize((basewidth,hsize), Image.ANTIALIAS)
    
    self.img = ImageTk.PhotoImage(img)
    
    self.panel_board = tk.Label(self.board_frame, image = self.img, bg="white")
    self.panel_board.pack(fill = "both", expand = "yes", anchor="n")
    
    self.root.update_idletasks()

add_guess

This add_guess is almost equal to the one in the original Battleship post, but instead of adding symbols to the string player’s board, let’s add numbers to the 2D player’s board array, where a 2 represents a hit on water and a 3 represents a hit on a ship. This method also checks for the number of boats destroyed and if the game was won, like in the original version.

def add_guess(self, guess):
    
    guess = guess.upper()
    
    #insert a guess
    
    #validate the guess
    if len(guess) != 2:
        self.result.configure(text = "Invalid guess!", fg="red")
        return
    
    if not guess[0].isalpha() or not guess[1].isdigit():
        self.result.configure(text = "Invalid guess!", fg="red")
        return
    
    if guess[0] not in self.player_rows or int(guess[1]) < 0 or int(guess[1]) > 9:
        self.result.configure(text = "Invalid guess!", fg="red")
        return
    
    #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:
        self.result.configure(text = "Repeated guess!", fg="red")
        return
    
    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][guess_col] = "3"
        self.hits.append((guess_row, guess_col))
        self.result.configure(text = "You hit a boat!", fg="#075fea")
        self.check_boats_destroyed()
        
    else:
        self.player_board[guess_row][guess_col] = "2"
        self.result.configure(text = "Oops...whater!", fg="#081947")
        
    self.save_board_pic(self.player_board, 'temporary/player_board.png')
    self.show_board()
    
    self.entry_guess.delete(0, tk.END)
    
    self.root.update_idletasks()
        
    #check if game was won
    self.win = self.check_win()
    
    if self.win == True:
        self.result.configure(text = "You won with {} attempts!".format(len(self.attempts)), fg="green")
        
    return

In the gallery below you can see 5 different situations that can occur after a new guess: an invalid guess, a hit on water (notice that the entry box is cleaned after a valid guess), a hit on a ship, 4 ships destroyed and the end of the game!


see_solution

Finally, it’s possible for the player to “cheat” and see the solution board, which shows where the ships are placed. For this, the secret board PNG is loaded and shown in the window. After less than 1 second (counted using the time package), the solution board is deleted and the player’s board appears again.

def see_solution(self):
    
    for widget in self.board_frame.winfo_children():
        widget.destroy()
    
    img = Image.open('temporary/secret_board.png')
    
    basewidth = 800
    wpercent = (basewidth/float(img.size[0]))
    hsize = int((float(img.size[1])*float(wpercent)))
    img = img.resize((basewidth,hsize), Image.ANTIALIAS)
    
    self.img = ImageTk.PhotoImage(img)
    
    self.panel_board = tk.Label(self.board_frame, image = self.img, bg="white")
    self.panel_board.pack(fill = "both", expand = "yes", anchor="n")
    
    self.root.update_idletasks()
    
    time.sleep(0.2)
    
    self.show_board()

The end

And that’s it! Now we have a fully functional Battleship game with a cool GUI, everything done exclusively with Python. You can check a complete game in the video below and the entire code on my GitHub.

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?