All your battleship are belong to us
Turing School of Software Design: Module 1, Week 4
This post originally published on July 21, 2016 is reposted from my original blog for reference.
Week 3 of module 1 of the Turing School of Software Design is in the books and the challenge was to code a playable version of Milton Bradley's Battleship that runs in a Read Evaluate Print Loop (REPL). The learning goals were to:
- Proficiently use TDD to drive development
- Practice breaking a program into logical components
- Practice implementing a useable REPL interface
- Apply previously learned Enumerable techniques in a real context
What I did differently
Josh Mejia, an instructor at Turing, made a comparison this week between marathon running and coding. Just as starting a marathon at too fast a pace is a terrible idea, jumping right to the code on a big project without planning or testing will lead to failure. In a clear example of Freudian compensation over my ParaMorse failure, I decided to come up with a careful organizational plan for my Battleship program. I asked myself, 'what are the nouns of Battleship?' and these became my classes. Battleship has players, boards, and ships. I knew from the start that these would be the lego blocks I would need to snap together to get my game to work. Battleship also has rules and my version of Battleship has a special kind of player - the computer player.
After deciding on my list of classes, I asked myself another set of questions. First, 'what state does this thing hold?' In other words, what are this thing's base characteristics. For example, a board has a length. A ship also has a length. A ship holds damage. A player has a state of being victorious or not. Players also can be the guesser of spots (it can be one player's turn or not). The second question I asked myself was, 'what behaviors does this thing perform?' A board can reveal the contents of a space. A ship can sink if it's damage is equal to its length. A player can place a ship and attack a space on the board. This thought exercise was tremendously helpful and resulted in a pretty significant Google Draw powered planning document.
My planning document became my to do list as I started to code. I went class by class and wrote the code that stored state and the methods that implemented behaviors. I started with the simplest classes that had the most clarity of purpose. Fundamentally, the board is comprised of individual spaces that preserve various states, such as being occupied and being attacked. Spaces also have names, like "A1" or "D4." When a space is created, it has a name corresponding to its coordinates and it is neither occupied (ships have not been placed yet) nor attacked (players have not started guessing spots).
class Space
attr_reader :coordinates
attr_accessor :occupied,
:attacked
def initialize(coordinates)
@coordinates = coordinates
@occupied = false
@attacked = false
end
end
What I Learned
I have two big takeaways from this project. First, classes are containers of data and behaviors. A ship contains data like its length, damage. It also knows where its bow and stern (see what I did there?) are located. Ships have start and end coordinates like "A1" and "A3".
class Ship
attr_reader :length,
:damage,
:start_space,
:end_space
def initialize(length)
@length = length
@damage = 0
@start_space = nil
@end_space = nil
end
# More methods here...
end
Ships also have behaviors, which are called methods. Ships can get hit. Ships can get placed. And, when you ask a ship nicely, it will tell you if it is sunk or not.
def place(start_space, end_space)
@start_space = start_space
@end_space = end_space
end
def hit
@damage += 1
end
def is_sunk?
@damage == @length
end
The second big learning from Battleship is that style not just for style's sake. Rather, writing idiomatic Ruby makes the code easier to debug and build out. For example I can create an array of hashes, where the key of each hash is a coordinate, like "A1", and the value is the instance of the Space class.
def create_spaces
spaces = {}
create_space_names.map do |name|
spaces["#{name}"] = Space.new(name)
end
spaces
end
The result of the above method create_spaces is the following two dimensional array.
@board=
[[{"A1"=>#<Space:0x007fef42b3f0e0 @attacked=false, @coordinates="A1", @occupied=false>},
{"A2"=>#<Space:0x007fef42b3f068 @attacked=false, @coordinates="A2", @occupied=false>},
{"A3"=>#<Space:0x007fef42b3eff0 @attacked=false, @coordinates="A3", @occupied=false>},
{"A4"=>#<Space:0x007fef42b3ef78 @attacked=false, @coordinates="A4", @occupied=false>}],
[{"B1"=>#<Space:0x007fef42b3ef00 @attacked=false, @coordinates="B1", @occupied=false>},
{"B2"=>#<Space:0x007fef42b3ee88 @attacked=false, @coordinates="B2", @occupied=false>},
{"B3"=>#<Space:0x007fef42b3ede8 @attacked=false, @coordinates="B3", @occupied=false>},
{"B4"=>#<Space:0x007fef42b3ed70 @attacked=false, @coordinates="B4", @occupied=false>}],
...
The same method can be rewritten more concisely and clearly and still have the exact same result:
def create_spaces
create_space_names.map do |name|
[name, Space.new(name)]
end.to_h
end
The functionality of both of these methods is the same and the latter is only two lines fewer than the original version. However, little changes like this compound as projects get longer. And Battleship is a long project.
The issue of smelly code really hit me when I was implementing my game's REPL. I had lots of nested conditionals and I definitely did not honor the programmer's concept of DRY (don't repeat yourself). The result was that when I thought I had working code and tried to show it to my wife and mother in law, I just embarrassed myself as the plethora of bugs that I overlooked manifested themselves in non-workable gameplay. If I had written easier to maintain code from the start, I would have quickly realize that the reason my game wasn't playing is that I had forgotten to have my game remember that shots were fired by a player, spaces were attacked, and ships were either damaged or not.
A difficult challenge
One difficult challenge that I came across towards the end of the project was how to get the computer to place its ships on the board. The specification called for a computer player to place ships randomly. Randomly picking spots on a board is very easy.
def pick_random_space(array_of_all_the_spaces)
array_of_all_the_spaces.sample
end
Picking acceptable spaces for legal ship placement on a board is much more interesting. Below is an illustration of the legal (green) and illegal (red) types of ship placement in the game. Legal ship placement meets the following criteria:
- All of the coordinates occupied by the ship are in the same row or column.
- The ship occupies the same number of coordinates as the length of the ship. A three unit ship takes up three spaces on the board.
- The ship occupies only contiguous coordinates.
- Two ships can not occupy the same space.
Illegal ship placement breaks one or more of the above rules.
To solve the placement puzzle in my program meant extracting judgement from the board class into a separate rules module. The way that I set it up, the board can tell the rest of the program facts about itself such as:
- Is this space on the board?
- Are these coordinates in the same row or column?
- How far apart are these two spaces?
The rules class takes the information contained in the board and passes judgement on whether or not such a placement or attack is allowable according to the game of Battleship.
To pick a spot, the AI, which is a subclass of Player, takes all of the spaces on the board and shuffles them into a random order with the pick_exhaustive method. Next it searches for the first (find method) unoccupied space. If this is the first ship being placed, the first element of the array returned by the pick_exhaustive method will be the space returned by pick_unoccupied space.
def pick_unoccupied(board)
pick_exhaustive(board).find { |coordinate| !board.space_occupied?(coordinate) }
end
Next, the AI finds out how long the ship is that needs to be placed (distance) and then re-shuffles the entire list of spaces on the board (board) into a new random order. The AI randomly selects to attempt to place the ship horizontally or vertically. If this attempt fails, e.g there are not legal spaces, it attempts to place the ship with the opposite alignment.
def pick_x_units(board, coordinate1, distance)
if [true, false].sample
pick_x_units_horizontally(board, coordinate1, distance) || pick_x_units_vertically(board, coordinate1, distance)
else
pick_x_units_vertically(board, coordinate1, distance) || pick_x_units_horizontally(board, coordinate1, distance)
end
end
It then looks for the first space that is the correct distance away from the originally selected space (coordinate1).
def pick_x_units_vertically(board, coordinate1, distance)
pick_exhaustive(board).find do |coordinate2|
!board.space_occupied?(coordinate2) && board.get_vertical_length(coordinate1, coordinate2) == distance
end
end
def pick_x_units_horizontally(board, coordinate1, distance)
pick_exhaustive(board).find do |coordinate2|
!board.space_occupied?(coordinate2) && board.get_horizontal_length(coordinate1, coordinate2) == distance
end
end
Once the AI finds two coordinates that are the correct distance apart to hold the ship and that do not already contain a ship, the AI places the ship by telling the board, "I want to place this ship at this start space and this end space." The ship's state is changed so that it saves the start and end space. The board changes the state of all of the spaces occupied by the newly placed ship to now show that they are occupied.
def place_ship(board, ship, start_space, end_space)
ship.place(start_space, end_space)
board.set_spaces_occupied(start_space, end_space)
end
his process repeats until the AI has placed all of its ships.
Helpful feedback
At Turing, every project is evaluated individually by at least one instructor in a 1 on 1 feedback session. This is an amazingly helpful part of the Turing experience because it is a chance to get a professional set of eyes on your code. My session lasted nearly an hour and included a lot of positive feedback. Positive feedback that is genuine and proportional to the difficulty of a task and the amount of work invested is a rare thing. I will definitely cherish this feedback session for a long time. Some of the specific points in my code that could use a little more polish include:
- In Minitest, a TDD framework we use, Assert_equal false or true is more robust than assert or refute because assert anything passes unless you assert nil or false.
- Stylistically, it's appropriate to use loops to setup tests and make them more readable.
- If a test contains multiple assertion and refutation statements, it is stylistiacally nice to separate these into two separate refutation and assertion tests for readability.
- With testing, do the bare minimum to prove a thing works exactly as required. It's not a good to test every single possible thing.
- In Ruby, it is conventional to separate words and numbers in variable names with underscores e.g assertion_1.
- When iterating in a test, use a helper method for readability.
- Use a setup method when tests require a lot of the same object instantiation.
- For tests with multiple value possibilities do array.include?(test value)
- Instead of using !include use exclude?
- Regarding variable names, it is okay to go long, just be clear and descriptive.
Member discussion