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.