hex_grid guide

← hex_grid · API reference · source

hex_grid is the coordinate layer the whole hex_* family rests on: hex ↔ world-plane conversion, the six neighbours, distance, corners, and the canonical name of an edge. Pure functions over (q, r) — no structs, no state, nothing to construct or free.

It is not the map. hex_field holds occupancy and outlines, hex_shape the line/box/arc vocabulary, hex_form turtle forms, hex_terrain heights and water. Reach for hex_grid when you need to know WHERE a cell is; reach for one of those when you need to know what is in it.

The convention is pointy-top, odd-r offset, L = √3 per hex step. It is shared executable ground with the games built on it, which is the point of the package: two programs that disagree about where a hex centre is do not interoperate.

use hex_grid::*;
fn main() {
  (x, y) = hex_grid::hex_to_px(2, 3);
  (q, r) = hex_grid::px_to_hex(x, y);
  println("centre  : ({x}, {y})");
  println("back    : ({q}, {r})");
  println("distance: {hex_grid::hex_distance(0, 0, 2, 3)}");
  assert(y == 4.5, "y is 1.5 · row, exactly");
  assert(q == 2 and r == 3, "hex_to_px and px_to_hex round-trip");
  assert(hex_grid::hex_distance(0, 0, 2, 3) == 4, "four steps, not the five a square grid counts");

Every section below is a function, and main calls each one. A section main does not call still COMPILES — so a renamed API is caught, and the assertions that check the example is RIGHT never run.

  axial_and_offset_are_both_two_integers();
  minus_one_is_a_direction_not_a_refusal();
  the_odd_rows_are_the_ones_that_break();
  an_edge_has_one_name_and_two_owners();
  the_square_basis_points_the_other_way();
}

That prints:

  centre  : (4.330127018922193, 4.5)
  back    : (2, 3)
  distance: 4

The one that bites: axial and offset are both (integer, integer)

Two coordinate systems run through hex code, and this package speaks one of them. (q, r) here is always the ODD-R OFFSET pair — (col, row). hex_round is the exception, and it says so: it answers AXIAL, because that is the space cube rounding happens in.

Both are (integer, integer). So handing an axial pair to hex_to_px type-checks, runs, and answers the centre of a DIFFERENT HEX — one step away, every time, silently.

⚠ And it is invisible on row 0, where the two systems coincide: col = q + (r - (r&1))/2 is q when r is 0. A test written along the top row passes under either reading, which is how this survives to the first diagonal.

px_to_hex is the door to reach for — it takes world coordinates and answers the offset pair everything else here expects. Use hex_round only when you already hold axial.

fn axial_and_offset_are_both_two_integers() {

Read as OFFSET (2,3) — what every function in this package means by (2,3).

  (ox, oy) = hex_grid::hex_to_px(2, 3);

The hex that AXIAL (2,3) actually names: col = 2 + (3 - 1)/2 = 3.

  (ax, ay) = hex_grid::hex_to_px(3, 3);
  assert(ox != ax, "one integer pair, two different places: {ox} vs {ax}");
  assert(oy == ay, "…on the same row, which is what makes it look right");
  assert(hex_grid::hex_distance(2, 3, 3, 3) == 1, "exactly one step wrong — never far enough to look broken");

Row 0 is where the two readings agree, so it cannot witness the mistake.

  (za, _zb) = hex_grid::hex_to_px(2, 0);
  assert(za == hex_grid::SQRT3 * 2.0, "on row 0, axial (2,0) and offset (2,0) are the same hex");
  println("axial   : offset(2,3) at x={ox}, axial(2,3) at x={ax} — one step apart");
}

-1 is a refusal that every direction-taking function accepts

hex_neighbor_dir answers which of the six directions leads from one hex to an adjacent one, and -1 when they are not adjacent. That -1 is an ordinary integer, not a null — nothing stops it flowing onward.

And onward it goes: hex_neighbor and hex_edge_corners both fall through to their direction-5 arm rather than raising, so an unchecked -1 does not fail. It answers the NORTH-EAST neighbour, confidently, for a pair that were never neighbours at all.

Check it at the call site. There is no second answer to look for.

fn minus_one_is_a_direction_not_a_refusal() {
  assert(hex_grid::hex_neighbor_dir(2, 3, 3, 3) == 0, "adjacent: direction 0, due east");
  assert(hex_grid::hex_neighbor_dir(0, 0, 9, 9) == -1, "not adjacent: -1");

What -1 does next, if nobody looks at it.

  (mq, mr) = hex_grid::hex_neighbor(2, 3, -1);
  (fq, fr) = hex_grid::hex_neighbor(2, 3, 5);
  assert(mq == fq and mr == fr, "-1 is absorbed as direction 5 — a real neighbour, wrongly");
  (ma, mb) = hex_grid::hex_edge_corners(-1);
  (fa, fb) = hex_grid::hex_edge_corners(5);
  assert(ma == fa and mb == fb, "…and the edge lookup does the same");
  println("refusal : -1 answers ({mq}, {mr}) — the same as direction 5, and nothing says so");
}

The odd rows are the ones that break

Odd-r offset shifts every odd row half a hex to the right, so four of the six neighbour deltas depend on the row's parity. Only EAST and WEST are parity-free.

This is why a hardcoded six-entry delta table is the classic bug here: it is correct on every even row, and on odd rows it hands back the wrong neighbour — not an invalid cell, not a null, a real hex one place over. Use hex_neighbor.

fn the_odd_rows_are_the_ones_that_break() {

Direction 0 (east) is parity-free: +1 column on either row.

  (e0q, _e0r) = hex_grid::hex_neighbor(2, 2, 0);
  (o0q, _o0r) = hex_grid::hex_neighbor(2, 3, 0);
  assert(e0q == 3 and o0q == 3, "east is +1 column whatever the row");

Direction 1 (south-east) is not.

  (e1q, e1r) = hex_grid::hex_neighbor(2, 2, 1);
  (o1q, o1r) = hex_grid::hex_neighbor(2, 3, 1);
  assert(e1q == 2 and e1r == 1, "even row: SE keeps the column");
  assert(o1q == 3 and o1r == 2, "odd row: SE advances it — a fixed table gets this one wrong");

Whatever the parity, all six are one step away.

  ring = true;
  for d in 0..6 {
    (nq, nr) = hex_grid::hex_neighbor(2, 3, d);
    if hex_grid::hex_distance(2, 3, nq, nr) != 1 { ring = false; }
  }
  assert(ring, "all six neighbours are at distance 1");
  println("parity  : SE from an even row is ({e1q}, {e1r}), from an odd row ({o1q}, {o1r})");
}

An edge has one name and two owners

The edge between two hexes is direction 0 from one and direction 3 from the other. Key a wall on the (q, r, dir) a caller happened to approach from and you get TWO half-walls: blocking from one side, open from the other, and each side's own tests pass.

hex_canon_edge resolves any of the six directions to the one triple both owners agree on — directions 0, 1, 2 stay on this hex, 3, 4, 5 belong to the neighbour. Call it before you key an edge on anything: a wall, a door, a river, a shared boundary.

fn an_edge_has_one_name_and_two_owners() {
  (aq, ar, ad) = hex_grid::hex_canon_edge(2, 3, 0);   // approached from the west hex
  (bq, br, bd) = hex_grid::hex_canon_edge(3, 3, 3);   // …and from the east one
  assert(aq == bq and ar == br and ad == bd, "both sides resolve to one key: ({aq}, {ar}, {ad})");
  assert(ad < 3, "the canonical index is always 0, 1 or 2");

The canonical owner is not always the hex you asked from: directions 3, 4 and 5 hand the edge to the neighbour, which is the half a caller keying on its own (q, r) would miss.

  assert(bq != 3, "asked from (3,3), the edge is owned by ({bq}, {br})");
  assert(aq == 2 and ad == 0, "asked from (2,3) dir 0, it is owned here — and those two agree");
  println("edges   : (2,3,dir 0) and (3,3,dir 3) both key ({aq}, {ar}, {ad})");
}

The square basis points the other way

cell_* is a square lattice for right-angled things placed on the hex world — corridors, rooms, anything a hex lattice makes painful. It mirrors the hex API name for name, which is what makes the two traps below easy to walk into.

- **`r` increases NORTH in `hex_*` and SOUTH in `cell_*`.** Same `(integer, integer)`,
  same `(float, float)` for corners, opposite compass. `hex_neighbor(q, r, 5)` and
  `cell_neighbor(q, r, 1)` answer the identical pair for opposite directions.
- **`cell_distance` is Chebyshev**, matching 8-directional movement, while
  `cell_neighbor` offers 4. So `cell_distance(a, b) == 1` is TRUE for a diagonal no
  single step can reach, and is not an adjacency test. `cell_neighbor_dir(…) >= 0` is.
fn the_square_basis_points_the_other_way() {
  (hq, hr) = hex_grid::hex_neighbor(4, 4, 5);    // hex direction 5 = north-east
  (cq, cr) = hex_grid::cell_neighbor(4, 4, 1);   // cell direction 1 = south
  assert(hq == cq and hr == cr, "one pair, ({hq}, {hr}), reached going opposite ways");
  (_hx, hy) = hex_grid::hex_corner_offset(0);
  (_cx, cy) = hex_grid::cell_corner_offset(0);
  assert(hy > 0.0 and cy < 0.0, "corner 0 is +y in the hex family and -y in the square one");

The adjacency trap.

  assert(hex_grid::cell_distance(0, 0, 1, 1) == 1, "Chebyshev calls the diagonal one step…");
  assert(hex_grid::cell_neighbor_dir(0, 0, 1, 1) == -1, "…and no single cell_neighbor reaches it");
  assert(hex_grid::cell_neighbor_dir(0, 0, 1, 0) == 0, "an orthogonal step is direction 0");
  println("square  : distance says 1 for a diagonal, neighbor_dir says -1 — use neighbor_dir");
}

Where to go next