Battleship Part 1: Local Battles
One of my goals for this summer is to get out of my Ruby rut. I love Ruby. I like how the language feels. I like the principle of least surprise. I appreciate that it has both strong object oriented and functional roots. And of course, I love the Ruby community. As much as I love Ruby, I believe most successful programmers are polyglots and can be productive in multiple languages. So I came up with what I’m calling the battleship project. My goal for the next few months is to implement an API for the game Battleship, deploy it on a server, and then build clients in a variety of languages. If I do it right, I can have other folks play their clients against my clients. There could be battleship tournaments. I can use it for tutorials on deployment and monitoring. I hope it will be awesome.
I’m starting with the server. For the first version, I plan to use Sinatra with a Postgres backend. For something this simple I don’t need the full power of Rails. Before I can dig into the web side of the implementation, I need to have the game working locally. My local version has three classes, Board, Game, and Client. Board is responsible for representing and maintaining the state of a Battleship board. Client is where the logic for placing ships and making moves lives. Game keeps track of which clients are playing and how they interact. I expect that many of these classes will end up as models in the online, multi-player version of the game but I’m not sure yet.
Board
I’ve implemented parts of Battleship before. While it is tempting to use nested arrays to represent the board (it is a grid after all) a hash is actually easier. To represent an empty cell I’m using “.” and to represent hits, misses, and ships I use symbols. Here’s the new method for my board class with its tests.
class Board
def initialize
self.height = 10
self.width = 10
@board = Hash.new(".")
end
end
class TestBoard < Minitest::Test
def test_new
b = Board.new
assert_equal 10, b.height
assert_equal 10, b.width
assert_equal ".", b["C7"]
end
end
After initialization, the next thing to implement was getters and setters for each cell. I tried using Java-style set_cell
and get_cell
methods, but that didn’t feel right. Once I started implementing Client#place_ships
, I realized I wanted to use square brackets to access cells of the board. Here’s what iteration three of board cell accessors looks like.
class Board
def [] location
@board[location]
end
def []= location, value
@board[location] = value
end
end
class TestBoard < Minitest::Test
def test_set_cell
b = Board.new
b["B7"] = :submarine
assert_equal :submarine, b["B7"]
end
end
The other thing I know from previous experience was that I need a way to visualize the board. In Ruby, we do this with to to_s
. I’m not thrilled with this implementation. It feels a bit too clever with the nested loops and joins. The line adding the last newline, in particular, offends my sensibilities. But it works, and I think it is readable to the average Rubyist, so I’m leaving it alone.
def to_s
str = ""
str = ('A'..'J').map { |l|
(1..10).map { |n| @board["#{l}#{n}"][0] }.join
}.join("\n")
str << "\n"
str
end
def test_to_s
b = Board.new
expected = ""
10.times do
expected << "..........\n"
end
str = b.to_s
assert_equal expected, b.to_s
end
Client
The Client class has three responsibilities: placing ships, making guesses, and handling guesses. When the Game class initializes a client, it provides a game ID and a fleet of ships. The client then creates two board objects. One represents my board (the bottom one in the Milton Bradley version of the game), and the other represents the opponent’s board.
class Client
def initialize game_id, fleet
self.game_id = game_id
self.fleet = fleet
self.my_board = Board.new
self.their_board = Board.new
end
end
def test_initialize
c = Client.new("gameID", [[:battleship, 5]])
assert_equal "gameID", c.game_id
assert_equal [[:battleship, 5]], c.fleet
assert c.my_board
assert c.their_board
end
Placing ships is a little bit tricky. I broke it up into two methods. place_ship
puts a single ship on the board. place_ships
puts the entire fleet on the board, one ship at a time. Writing the code to place the ships is easier if there’s some way to detect out of range errors. Adding in_range?
to Board solves this problem.
class Board
LETTERS = ('A'..'J').to_a
NUMBERS = ('1'..'10').to_a
def in_range? location
m = /([A-J])(\d+)/.match(location)
return false unless m
letter, number = /([A-J])(\d+)/.match(location).captures
LETTERS.include?(letter) and NUMBERS.include?(number)
end
end
def test_in_range?
b = Board.new
assert b.in_range?("B7")
assert b.in_range?("A1")
assert b.in_range?("A10")
assert b.in_range?("J1")
assert b.in_range?("J10")
refute b.in_range?("X7")
refute b.in_range?("B0")
refute b.in_range?("B11")
refute b.in_range?("")
refute b.in_range?("142342")
end
The code to actually place a ship is complicated. I start by getting a random direction and location. Then using that starting point and direction, I generate the cells that the ship is going to occupy (lines 13 - 26). If all the locations are in the range allowed I place the ship thereby setting the cells equal to the ships name. If some of the cells are out of range, I generate a new starting point and direction. Here is the implementation and the test I wrote.
class Client
DIRECTIONS = [:up, :down, :left, :right]
LETTERS = ('A'..'J').to_a
def place_ship name, length
loop do
dir = DIRECTIONS.sample
letter = LETTERS.sample
number = Random.rand(10)
locations = []
length.times do
locations << "#{letter}#{number}"
case dir
when :right
number += 1
when :left
number -= 1
when :up
letter = (letter.ord - 1).chr
when :down
letter = letter.next
end
end
if locations.all? { |l| self.my_board.in_range?(l) }
locations.each do |l|
self.my_board[l] = name
end
return
end
end
end
end
def test_place_ship
c = Client.new("gameID", [])
c.place_ship :cruiser, 3
assert_equal 3, c.my_board.to_s.each_char.count { |l| l == "c" }
end
To place the entire fleet you just loop over the fleet, placing each ship in turn.
def place_ships
self.fleet.each do |name, length|
place_ship name, length
end
end
def test_place_ships
c = Client.new("gameID", [[:battleship, 5], [:cruiser, 3]])
c.place_ships
assert_equal 5, c.my_board.to_s.each_char.count { |l| l == "b" }
assert_equal 3, c.my_board.to_s.each_char.count { |l| l == "c" }
end
Finally, I need a method to make guesses. I took the lazy way out here and just picked a random letter and number.
def guess
"#{LETTERS.sample}#{Random.rand(10)}"
end
def test_guess
c = Client.new()
g = c.guess
assert c.their_board.in_range?(g)
end
Game
The final class in my system is Game. When I instantiate a game object, it creates two clients and a game ID.
class Game
FLEET = [[:battleship, 5],
[:cruiser, 4],
[:submarine, 3],
[:frigate, 3],
[:destroyer, 2]]
attr_accessor :id, :client_a, :client_b
def initialize
@id = SecureRandom.uuid
@client_a = Client.new(@id, FLEET)
@client_b = Client.new(@id, FLEET)
end
end
class TestGame < Minitest::Test
def test_initialize
g = Game.new
assert g.id
assert g.client_a
assert g.client_b
end
end
This is most of the logic necessary to run my battleship server. In the next post, I’ll implement Game#run
to actually run the game and Client#take_turn
to respond to the other player’s guess and produce my own guess.