← 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
README.md— the full function table, both families side by side, and the world-scale constants (SQRT3,HEX_SIZE,HEX_LEN,GRID_LEN) a consumer works in.tests/03-worked-examples.loft—@HXG-001…@HXG-006, the contracts above as running tests, each cited from the doc comment of the function it demonstrates.tests/02-parity.loft— the proof that thehex_*andcell_*families answer consistently, which is what lets you learn one and read the other.hex_fieldfor occupancy and outlines over these coordinates,hex_shapefor lines, boxes and arcs, andhex_drawto render the result.