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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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.
1 2 3 4 5 6 7 8 9 10 11 |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
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.