Refactoring Python: Clean Game Logic and Class Design in Battleship
Overview
Refactoring isn't just about making code look pretty; it's about making it maintainable and clear. In this second phase of our
Prerequisites
To get the most out of this tutorial, you should be comfortable with
Key Libraries & Tools
- Python 3.x: The core programming language used for the implementation.
- Random Module: A standard Pythonlibrary used to generate the hidden ship's coordinates.
- List Comprehensions: A concise Pythonsyntax used for initializing the game grid efficiently.

Moving Magic Numbers to Constants
Hardcoded values, or "magic numbers," are a maintenance nightmare. If you decide to change your board from 5x5 to 10x10, you don't want to hunt through twenty lines of code to update every instance of the number five. We start by defining clear constants at the top of our module.
GUESSES_COUNT = 5
BOARD_SIZE_X = 5
BOARD_SIZE_Y = 5
HIDDEN = "O"
SHIP = "S"
GUESS = "X"
By using uppercase naming conventions, we signal to other developers that these values are configuration points, not variables that should change during runtime. This immediately makes the code more readable—seeing SHIP is far more descriptive than seeing a random string "S" buried in an if statement.
Encapsulating the BattleshipBoard
The original code mixed game rules with grid management. We solve this by creating a dedicated BattleshipBoard class. This class acts as the single source of truth for the game state. Instead of the game logic manually checking nested lists, it asks the board questions like "Is this a ship?" or "Has this spot been guessed?"
class BattleshipBoard:
def __init__(self, size_x: int, size_y: int):
self.grid = [[HIDDEN for _ in range(size_x)] for _ in range(size_y)]
self.ship_row = random.randint(0, size_x - 1)
self.ship_col = random.randint(0, size_y - 1)
self.grid[self.ship_row][self.ship_col] = SHIP
def is_ship(self, row: int, col: int) -> bool:
return self.grid[row][col] == SHIP
def to_string(self, show_ship: bool = False) -> str:
rows_string = []
for row in self.grid:
row_repr = [HIDDEN if col == SHIP and not show_ship else col for col in row]
rows_string.append(" ".join(row_repr))
return "\n".join(rows_string)
The to_string method is a key design choice. By allowing a show_ship toggle, we separate the internal data (the actual grid) from the visual representation (what the player sees). This keeps the logic clean and avoids the need for maintaining two separate "visible" and "hidden" boards.
Simplifying Game Logic with Math
Managing player turns often involves messy index tracking. We can replace complex conditional blocks with simple math using the modulo operator (%). This allows us to rotate through any number of players seamlessly within a single while loop.
def play_game(player_count: int, board: BattleshipBoard):
total_guesses = 0
max_guesses = GUESSES_COUNT * player_count
while total_guesses < max_guesses:
current_player = (total_guesses % player_count) + 1
# ... turn logic ...
total_guesses += 1
This math ensures that current_player always cycles correctly (1, 2, 1, 2 for a two-player game) without needing to manually reset a counter. It’s a robust pattern that reduces the surface area for bugs.
Syntax Notes
- List Comprehensions: Used
[[HIDDEN for _ in range(x)] for _ in range(y)]to avoid reference copying issues that occur with simple list multiplication. - Double Slash Division (
//): Used for integer division to ensure guess counts remain whole numbers. - Dunder Methods: We avoided
__str__in favor ofto_stringbecause we needed to pass theshow_shipargument, which standard string conversion doesn't support.
Tips & Gotchas
Avoid the temptation to put print statements inside your core logic classes. Notice how BattleshipBoard returns strings but doesn't print them. This makes the class portable; you could theoretically use the same board class in a web app or a GUI without changing a line of code because it doesn't care how its data is displayed.

Fancy watching it?
Watch the full video and context