Jamie Lawrence

Implementing HexGrids in Ruby

One of my favourite board games is Tantrix. I can be played as a puzzle like Solitaire or as a competitive game. We picked it up in New Zealand over 10 years ago (it was invented there) and we played it in the evenings as we travelled around. The rules are pretty simple but the game can be frustrating and very tactical.

Anyway, I reckon that Tantrix (or Minesweeper, for that matter) would make a great compsci project instead of the usual projects: there’s a data structure representation problem, gameplay rules to be enforced and non-obvious rules of thumb to apply.

Implementing Hex Space

The first step in a creating a game is representing the game state. For Tantrix we need to represent an arbitrary sized collection of hexagonal hexs. We can’t work off a traditional 2D square grid, instead we need a hexagonal grid. That’s when I discovered the amazing collection of work by Red Blob Games on HexGrids. The credit for all the maths and C++ implementations below goes to them; I wasn’t figuring it out from first principles.

Coordinates in hex-space are much like those in 3D space — because they are 3D coordinates. I’m using the ‘Cube Coordinate’ system which uses a constrained 3D space to represent hexagons. Seriously: go and check out the brilliant animation here.

http://www.redblobgames.com/grids/hexagons/#coordinates Representing hex coordinates by taking a slice through 3D space

Once you get your head around the basic coordinate concepts, the implementation guide provides some C code illustrating the basic concepts.

Making the Point

They use a Hex struct to represent coordinates in hex-space and perform operations on it. I decided that it made more sense to separate the location of something and the actual thing itself. So I have a Point class which is responsible for the location in hex-space.

Here’s the original C++ implementation

struct Hex { // Axial storage, cube constructor
    const int q_, r_;
    Hex(int q, int r, int s): q_(q), r_(r) {
        assert (q + r + s == 0);
    }

    inline int q() { return q_; }
    inline int r() { return r_; }
    inline int s() { return - q_ - r_; }
};

and my Ruby version

class Point
  attr_reader :q, :r, :s

  def initialize q, r, s = -q-r
    @q = q
    @r = r
    @s = s
    raise InvalidPointError unless valid?
  end

q, r, and s are our Hex coordinates (like, x,y,z in 3D space). The difference is that the s coordinate is technically unnecessary since it can be calculate from q and r. In Ruby we can easily provide an optional s argument which is calculated from the other constructor args.

It seemed easier for the moment to store s instead of calculating each time it’s required. I might still change my mind (and its easy to define a s method as -q-r but I generally prefer to store the variable as it makes debugging a little simpler.

Equality

Next we need to define equality for Point. The C++ implementation looks like

bool operator == (Hex a, Hex b) {
    return a.q == b.q && a.r == b.r && a.s == b.s;
}

bool operator != (Hex a, Hex b) {
    return !(a == b);
}

and in Ruby we can do

def == other
  @q == other.q && @r == other.r && @s == other.s
end
alias :eql? :==

def hash
  [@q, @r, @s].hash
end

We implement equality as simply comparing the 3 coordinate values. We alias eql? to == so that we can use Point as keys in a Hash. Unlike C++, we don’t need to define != because that shit is crazy.

Since we want to use Point in a Hash, we also implement the hash method which use the fairly common idiom of combining the values into an array and using that hash value.

One last “dumb” thing I do is provide a comparison operator for sorting points. There isn’t really a sensible way to sort points so we’ll just sort them based on the hash value — it has no meaning but it means we can consistently sort points which makes comparing arrays of points much easier!

include Comparable
def <=> other
  hash <=> other.hash
end

Coordinate Arithmetic

Now we need to do coordinate arithmetic which looks like this in C++

Hex hex_add(Hex a, Hex b) {
    return Hex(a.q + b.q, a.r + b.r, a.s + b.s);
}

Hex hex_subtract(Hex a, Hex b) {
    return Hex(a.q - b.q, a.r - b.r, a.s - b.s);
}

Hex hex_multiply(Hex a, int k) {
    return Hex(a.q * k, a.r * k, a.s * k);
}

and like this in Ruby

def + point
  Point.new(@q + point.q, @r + point.r, @s + point.s)
end

def - point
  Point.new(@q - point.q, @r - point.r, @s - point.s)
end

def * n
  Point.new(@q * n, @r * n, @s * n)
end

Not exactly rocket science. Implementing the operator methods means we can do things like this

a = Point.new 1, 2, -3
b = Point.new 0, -1, 1

a+b                     #=> Point.new(1, 1, -2)

Distance

Implementing length and is pretty simple too

int hex_length(Hex hex) {
    return int((abs(hex.q) + abs(hex.r) + abs(hex.s)) / 2);
}

int hex_distance(Hex a, Hex b) {
    return hex_length(hex_subtract(a, b));
}

and there’s not much difference in Ruby except the distance method reads a bit better

def length
  (@q.abs + @r.abs + @s.abs).to_f / 2.0
end

def distance point
  (self - point).length
end

The Neighbourhood

The next important functionality is to calculate the neighbouring points. The idea is we have a number of fixed directions corresponding to each of the six hexagon edges. In C++ we might do…

const vector<Hex> hex_directions = {
    Hex(1, 0, -1), Hex(1, -1, 0), Hex(0, -1, 1),
    Hex(-1, 0, 1), Hex(-1, 1, 0), Hex(0, 1, -1)
};

Hex hex_direction(int direction /* 0 to 5 */) {
    assert (0 <= direction && direction < 6);
    return hex_directions[direction];
}

Hex hex_neighbor(Hex hex, int direction) {
    return hex_add(hex, hex_direction(direction));
}

I’m not much of a fan of passing magic numbers like int direction into functions so I’d rather name these directions. Let’s use the compass points. I’m assuming our hexagons are “pointy-topped” so we’ll have East and West as usual, and use North-West/North-East/South-West/South-East to represent the other faces. I know it’s not technically correct according to the compass points but it’s instantly understandable.

EAST = Point.new(-1, 1, 0)
WEST = Point.new(1, -1, 0)
SOUTH_WEST = Point.new(0, -1, 1)
SOUTH_EAST = Point.new(-1, 0, 1)
NORTH_EAST = Point.new(0, 1, -1)
NORTH_WEST = Point.new(1, 0, -1)

VALID_DIRECTIONS = [
    Point::EAST,
    Point::SOUTH_EAST,
    Point::SOUTH_WEST,
    Point::WEST,
    Point::NORTH_WEST,
    Point::NORTH_EAST
]

def neighbor direction
  raise InvalidDirectionError unless Point::VALID_DIRECTIONS.include? direction
  self + direction
end
alias :neighbour :neighbor

def neighbors
  Point::VALID_DIRECTIONS.map { |direction| neighbor direction }
end
alias :neighbours :neighbors

I also alias the neighbor method to the British English equivalent because. Just because. Grrr…

This lets us do things like this, which is pretty neat

a = Point.new(1, 2, -3)
a.neighbour(Point::EAST)             #=> Point.new(0, 3, -3)

And fetching all the neighbouring points is as simple as

def neighbors
  Point::VALID_DIRECTIONS.map { |direction| neighbor direction }
end

Hexs

Now that we have our coordinate structure, we need something to place at those coordinates. Since I’m thinking about Tantrix, I’m going to call this a Hex. Clearly a hex is located somewhere in our hex space so it will need a Point attribute:

class Hex
  attr_accessor :point

  def initialize *args
    if args.length == 1
      @point = args[0]
    elsif args.length > 1
      @point = Point.new *args
    end
  end

As I was implementing the tests for this class I realised that writing Hex.new(Point.new(1,2,-3)) was pretty crufty. So I allow multiple arguments to the initialiser: if we pass one argument, we can assume it’s an instance of Point; otherwise, we’ll use those arguments to make a Point.

Grid

A hex doesn’t just belong somewhere in coordinate space, it belongs on a particular instance of a grid (or “board”, but I’m trying not to make this too game-specific)

A Grid is just a collection of hexagons

class Grid
  def initialize
    @hexs = {}
  end

We use a Hash of Point -> Hex to store the hexs so we can easily look them up based on their location. Actually storing the hexs works like this:

def add_hex hex
  @hexs[hex.point] = hex
  hex.grid = self
end

Obviously enough, we’re storing the hexagons in the hexs hash using its location. We also store the grid in the hex instance — I’ll come back to that later.

def << hex
  if hex.respond_to? :each
    hex.each { |t| add_hex t }
  else
    add_hex hex
  end
end

We use << in a similar way to the Array although we can also pass in Enumerable objects like Array and incrementally add those hexagons to the grid.

hex = Hex.new(1, 2, -3)
@grid << hex

hexs = [Hex.new(1,2,-3), Hex.new(1, 0, -1)]
@grid << hexs

Retrieving Hexagons

We use the [] method to retrieve a hex from the grid. No surprise, this just does a hash lookup

def [] point
  @hexs[point]
end

We also implement the each method to make our grid work as an Enumerable:

include Enumerable
def each
  if block_given?
    hexs.each { |hex| yield hex }
  end
end

This means we can iterate over all the hexagons in the grid and perform operations on them. In fact, we’ve already use this functionality in << which lets us move all the pieces from one grid to another.

Next Steps

I’ll be coming back to this topic to implement route finding, loop detection and the beginnings of a Tantrix game. The current hexgrid implementation is available on Github if you’d like to play around with it.