Why Battleship?
As part of Codecademy's Computer Science career path, the first portfolio project offered several options. I decided to go for a challenge and build a terminal version of the classic Battleship game, where you place your fleet, the computer hides its own, and you take turns firing until one side has nothing left afloat.
It turned out to be a more complex task than I first imagined. As a bioinformatician and data scientist, I'm used to writing scripts that run from start to finish and produce an output: a plot, a table, a CSV file, an accuracy metric. Until this project I had never had to build something interactive: a board that changes every turn, two players with their own hidden information, and a program that keeps asking for input until someone wins.
So I started with what I do best: breaking a big problem into smaller ones. First I split the game into scripts by responsibility, and then I split each script into small functions that do one thing each.
The project structure
| File | Responsibility |
|---|---|
board.py |
Creates the grid, assigns coordinates and prints the boards |
ships.py |
The Ship class and each ship type |
game_functions.py |
Game setup: fleet size, building the fleet and placing ships |
play_functions.py |
Playthrough: shooting, taking turns, checking for a sunk fleet |
computer.py |
How the computer places its ships and decides where to shoot |
main.py |
Puts everything together and runs the rounds |
** Step 1: The board and its coordinates**
The first decision was how to represent the board. The player chooses the size (anything from 5x5 to 10x10), and I build a simple grid as a list of lists, where every cell starts as water (~):
grid = [['~' for _ in range(int(cols))] for _ in range(int(rows))]
A list of lists is easy to print, but it's awkward to work with during the game: players think in coordinates like B3, not in grid[2][1]. So I converted the grid into a dictionary where each key is a coordinate (column letter + row number) and each value is what's in that cell:
def set_coordinates(grid):
col_labels = [chr(i) for i in range(ord('A'), ord('A') + len(grid[0]))]
row_labels = [str(i + 1) for i in range(len(grid))]
board = {}
for i, row in enumerate(grid):
for j, cell in enumerate(row):
coordinate = f"{col_labels[j]}{row_labels[i]}"
board[coordinate] = cell
return board
The chr(ord('A') + n) trick generates the column letters (A, B, C...) from numbers. This dictionary ended up being one of the best decisions of the project, because many checks became one-liners. For example, "is this coordinate on the board?" is just coordinate in board.
Step 2: The ships
From the beginning I knew I wanted the ships to be built from classes. I created a Ship blueprint and then one subclass per ship type: the Aircraft Carrier, the Submarine, the Cruiser and the Destroyer. Each subclass only needs to define its name and size:
class Ship:
name = "Ship"
size = 0
def __init__(self):
self.hits = 0
self.cells = [] # Filled in when the ship is placed on the board
self.representation = self.name[0] # Letter shown on the board
@classmethod
def size_name(cls):
return "small" if cls.size == 2 else "medium" if cls.size == 3 else "large"
def hit(self):
self.hits += 1
def is_sunk(self):
return self.hits >= self.size
class AircraftCarrier(Ship):
name = "Aircraft Carrier"
size = 5
class Destroyer(Ship):
name = "Destroyer"
size = 2
The size of a ship is also the number of hits it can take before sinking, and the representation is the first letter of its name, which is what appears on the board (A, S, C or D).
This class went through a lot of back and forth. As I wrote the game functions, I kept discovering things a ship needed to know about itself. I added the is_sunk() method so the game could announce when a ship goes down, and a cells attribute that stores the coordinates the ship occupies. Without cells, a hit on the board was just a ! symbol; with it, the game knows which ship was hit.
The size_name() class method is also worth mentioning: because it works on the class itself (no instance needed), I could use it to group ship types into small, medium and large categories before creating any ships.
Step 3: Setting up the game
game_functions.py holds everything related to the setup. First, the fleet depends on the size of the board, so a tiny board isn't crowded with ships:
if total_cells >= 81:
ships = {'small': 3, 'medium': 2, 'large': 1}
elif total_cells >= 49:
ships = {'small': 3, 'medium': 2}
else:
ships = {'small': 3}
Then, to place a ship, the player gives a starting coordinate and a direction, and get_ship_cells calculates every cell the ship would occupy. Horizontal ships move along the letters, vertical ships move along the numbers:
def get_ship_cells(start, direction, size):
col = start[0] # ex. B
row = int(start[1:]) # [1:] so that row 10 also works
cells = []
for i in range(size):
if direction == 'horizontal':
cells.append(f"{chr(ord(col) + i)}{row}") # B3, C3, D3
else:
cells.append(f"{col}{row + i}") # B3, B4, B5
return cells
Before placing the ship, is_valid_placement checks that every one of those cells exists and is still water. Thanks to the dictionary, this handles both "off the board" and "overlapping another ship" in a single condition:
def is_valid_placement(board, cells):
for cell in cells:
if cell not in board or board[cell] != '~':
return False
return True
If the placement is invalid, the game simply asks again. I also accept h and v as shortcuts for the direction, because typing "horizontal" six times gets old fast.
Step 4: Playing the game
play_functions.py contains the playthrough logic. The core is the shoot function. The board itself remembers every shot: ! marks a hit and X marks a miss, so the same symbols are used to stop players from shooting the same cell twice:
def shoot(board, fleet, coordinate):
if coordinate not in board:
print("Invalid coordinate. Please enter a valid coordinate.")
return False
if board[coordinate] in ['!', 'X']:
print("Already targeted! Choose another cell.")
return False
for ship in fleet:
if coordinate in ship.cells:
ship.hit()
board[coordinate] = '!'
print(f"Kaboom! {coordinate} is a hit!")
if ship.is_sunk():
print(f"The {ship.name} has been sunk!")
return True
board[coordinate] = 'X'
print(f"Splaaash! {coordinate} is water!")
return True
The return value is what makes the turns work: True means the shot counted, False means it has to be repeated. take_turn keeps asking for a coordinate until shoot returns True, so a typo never costs you your turn. Checking for the end of the game is a single line with all():
def fleet_sunk(fleet):
return all(ship.is_sunk() for ship in fleet)
Step 5: Building the opponent
Now came the fun part: creating the computer the player would be up against.
For ship placement, I recycled the functions I had already written for the player. The only difference is that random.choice replaces input(). If the random spot doesn't fit, the computer just tries another one:
def place_computer_ships(board, fleet):
for ship in fleet:
while True:
start = random.choice(list(board))
direction = random.choice(['horizontal', 'vertical'])
cells = get_ship_cells(start, direction, ship.size)
if not is_valid_placement(board, cells):
continue # doesn't fit, try another random spot
add_ship_to_board(board, ship, cells)
break
Shooting was a different story. My first version fired completely at random, which meant that after hitting one of your ships, it would happily shoot somewhere on the opposite corner of the board. Not much of a challenge.
To fix it, I gave the computer two modes, like a human player would have:
- Hunt mode: no damaged ship to follow, so it shoots a random cell it hasn't targeted yet.
- Target mode: it has hit a ship that isn't sunk yet, so it shoots the cells around that hit.
The key piece is get_neighbors, which returns the cells to the left, right, above and below a given cell. It can also return only the horizontal or only the vertical neighbors:
def get_neighbors(cell, direction=None):
col = cell[0]
row = int(cell[1:])
neighbors = []
if direction in [None, 'horizontal']:
neighbors.append(f"{chr(ord(col) - 1)}{row}") # left
neighbors.append(f"{chr(ord(col) + 1)}{row}") # right
if direction in [None, 'vertical']:
neighbors.append(f"{col}{row - 1}") # up
neighbors.append(f"{col}{row + 1}") # down
return neighbors
Notice that this function doesn't worry about cells that fall outside the board (like A0). Those are filtered later with neighbor in board, again thanks to the dictionary.
get_target_cells uses it to decide where to shoot next. After two hits on the same ship, the computer can deduce its orientation: if both hits share a letter, the ship is vertical; if they share a number, it's horizontal. From then on, it only shoots along that line:
direction = None
if len(hits) >= 2:
direction = 'vertical' if hits[0][0] == hits[1][0] else 'horizontal'
for hit in hits:
for neighbor in get_neighbors(hit, direction):
if neighbor in board and board[neighbor] not in ['!', 'X'] and neighbor not in targets:
targets.append(neighbor)
So how does the computer choose between hunting and targeting? There's no flag or variable that stores the current mode. The computer decides fresh every turn, based on what it can see on the board. computer_turn first asks get_target_cells for a list of cells worth shooting:
def computer_turn(board, fleet):
targets = get_target_cells(board, fleet)
if targets:
coordinate = random.choice(targets) # target mode
else:
# Only cells without '!' or 'X', so the shot is always valid
available = [cell for cell in board if board[cell] not in ['!', 'X']]
coordinate = random.choice(available) # hunt mode
shoot(board, fleet, coordinate)
- If the list has cells in it, there's a damaged ship still afloat, so the computer picks one of those cells: target mode.
- If the list is empty, either it hasn't hit anything yet or every ship it hit has already sunk. Then it picks any cell it hasn't shot yet: hunt mode.
Switching back to hunt mode happens on its own. get_target_cells skips ships that are already sunk (if ship.is_sunk(): continue), so once the computer finishes off a ship, its hits stop producing targets and the computer goes back to hunting.
And it doesn't cheat: it only follows hits on ships that haven't been announced as sunk, which is the same information a human player has. In my tests on a 10x10 board, the smarter computer needed about 67 shots on average to sink a full fleet, compared to about 95 when it was shooting completely at random.
Step 6: Seeing both boards without spoilers
To make the game easier to follow, I wanted to show both boards side by side every round: yours on the left, with your ships visible, and the enemy's on the right, showing only your shots.
YOUR BOARD ENEMY BOARD
| A | B | C | D | E | | A | B | C | D | E |
1 | D | D | ~ | ~ | ~ | 1 | X | ~ | ~ | ~ | ~ |
2 | ~ | ~ | X | ~ | ~ | 2 | ~ | ! | ~ | ~ | ~ |
3 | ~ | ! | D | ~ | ~ | 3 | ~ | ~ | ~ | ~ | ~ |
The symbols are:
-
~water (or a cell you haven't shot yet) -
Xa miss -
!a hit -
A,S,C,Dyour own ships
To make this work, I first changed the printing function so it builds the lines of a board instead of printing them directly. It has a hide_ships option that replaces every ship letter with ~, so only the shots (! and X) are visible on the enemy board:
if hide_ships and value not in ['!', 'X']:
value = '~'
Then print_two_boards takes the lines of both boards and uses zip() to join line 1 with line 1, line 2 with line 2, and so on:
left = board_lines(grid, player_board)
right = board_lines(grid, enemy_board, hide_ships=True)
width = len(left[0])
gap = " "
print("YOUR BOARD".ljust(width) + gap + "ENEMY BOARD")
for left_line, right_line in zip(left, right):
print(left_line + gap + right_line)
The computer's ships are still on its board the whole time; they're just never shown to you.
Step 7: Putting it all together
Finally, everything comes together in main.py. Both players share the same grid size, but each one gets its own board and its own fleet. The fleets have the same kind of ships, but they are different instances, so damage to one player's Destroyer doesn't affect the other's.
grid = create_grid()
player_board = set_coordinates(grid)
computer_board = set_coordinates(grid)
allowed_ships = check_board_size(grid)
player_fleet = build_fleet(allowed_ships)
computer_fleet = build_fleet(allowed_ships)
After both fleets are placed, the game loop runs: the player shoots, the game checks whether the computer's fleet is sunk, the computer shoots, and the game checks the player's fleet. This repeats round after round until someone wins.
while True:
print_two_boards(grid, player_board, computer_board)
take_turn(computer_board, computer_fleet)
if fleet_sunk(computer_fleet):
print("You sunk the whole enemy fleet. You win!")
break
computer_turn(player_board, player_fleet)
if fleet_sunk(player_fleet):
print("The computer sunk your whole fleet. You lose!")
break
Because every piece was already its own small function, main.py reads almost like a description of the game rules.
Check out the code
The full project is on GitHub: github.com/AGVF/battleship_project
You only need Python 3, no extra libraries:
git clone https://github.com/AGVF/battleship_project.git
cd battleship_project
python main.py
... and here is a representative GIF of my version of the game! let me know what you think!

Top comments (0)