← hex_terrain · API reference · source
hex_terrain is the overland layer of the hex_* family. You author a COARSE hex map — one cell is a tile hundreds of metres across, with a height, a material and a moisture — and the fine world a player walks is derived from it on demand: terrain_sample(…, x, y) answers the height, material and water at any point in metres, as a pure function of the map. Between authoring and sampling run three passes, in this order: hydrology (pits fill, water flows), relief (how rugged each tile is), and rivers (the courses, with their valleys).
The coarse map is hex_grid's odd-r lattice scaled to your tile size; reach for hex_grid itself for lattice arithmetic, and for hex_world when you want stored cells rather than a derived surface.
use hex_terrain::*;
fn close(a: float, b: float) -> boolean { abs(a - b) < 0.000001 }
The game's terrain types are content: a key, how much a tile's relief lifts it (tt_rise), how jagged it is inside (tt_steep), and whether it is water.
fn types() -> vector<TerrainType> {
[TerrainType { tt_key: "grass", tt_rise: 20.0, tt_steep: 4.0, tt_wet: false },
TerrainType { tt_key: "lake", tt_rise: 0.0, tt_steep: 0.0, tt_wet: true }]
}
LAKE = 1; // the lake's index in `types()`
A 3×3 map of 500 m tiles, 100 m high everywhere except the middle tile.
fn plateau(middle: float) -> Terrain {
t = terrain_new(3, 3);
hs = &t.tr_h;
for i in 0..9 { hs[i] = 100.0; }
hs[terrain_idx(t, 1, 1)] = middle;
t
}
fn main() {
p = terrain_params(42, 500.0);
(x1, y1) = terrain_center(p, 1, 0);
(x2, y2) = terrain_center(p, 0, 1);
println("tile (1, 0) at ({x1:.1}, {y1:.1}) m, tile (0, 1) at ({x2:.1}, {y2:.1}) m");
assert(close(x1, 500.0) && close(y1, 0.0), "the next tile along a row is one tile size east");
A row is 1.5 hex sizes down, and a hex size is the tile over √3: 750 / √3 = 433.0127… m.
assert(close(x2, 250.0) && close(y2, 433.0127018922193), "an odd row is shifted half a tile");
Every section below is a function, and main calls each one: a section nothing calls still compiles, but its assertions never run.
water_fills_the_pits();
relief_counts_steep_neighbours();
the_blend_overlaps();
sampling_is_pure();
}
That prints:
tile (1, 0) at (500.0, 0.0) m, tile (0, 1) at (250.0, 433.0) m
Water fills the pits
terrain_hydrology makes every land tile drain to the edge of the map or the sea. A tile lower than everything around it is a pit: the flood raises it to just above its lowest rim (rim + 0.05 m), and a tile raised by more than tp_lake_fill (4 m) becomes a lake. It REWRITES the heights you authored — the 50 m middle tile below ends at 100.05 m — and then sets each tile's downhill neighbour and how many tiles drain through it.
fn water_fills_the_pits() {
p = terrain_params(42, 500.0);
t = plateau(50.0);
terrain_hydrology(t, p, LAKE);
mid = terrain_idx(t, 1, 1);
assert(close(t.tr_h[mid]?, 100.05), "the pit is filled to its rim");
assert(t.tr_mat[mid] == LAKE, "and, raised 50 m, it is a lake");
assert(terrain_flow_to(t, 1, 1) == (2, 1), "it drains east, the first of six equal rims");
assert(t.tr_acc[terrain_idx(t, 2, 1)] == 2.0, "so that tile carries two tiles' water");
println("water : the 50 m pit fills to {t.tr_h[mid]:.2} m and becomes a lake");
}
Relief counts steep neighbours
terrain_relief_pass gives each dry tile a relief: its biggest height difference to a neighbour, times 0.6 + 0.2 × n, where n is how many neighbours differ by more than 60 m. A 200 m tile among 100 m tiles has six steep neighbours: 100 × 1.8 = 180. A tile beside it has one: 100 × 0.8 = 80. A wet tile takes no relief at all, so rivers and lakes stay low.
fn relief_counts_steep_neighbours() {
p = terrain_params(42, 500.0);
t = plateau(200.0);
ms = &t.tr_mat;
ms[terrain_idx(t, 2, 2)] = LAKE;
terrain_relief_pass(t, types(), p);
assert(close(t.tr_relief[terrain_idx(t, 1, 1)]?, 180.0), "six steep neighbours");
assert(close(t.tr_relief[terrain_idx(t, 1, 0)]?, 80.0), "one steep neighbour");
assert(t.tr_relief[terrain_idx(t, 2, 2)] == 0.0, "beside the peak too, but wet: none");
println("relief : peak 180, its neighbour 80, a lake beside it 0");
}
The blend overlaps
The fine height at a point is a weighted blend of the tiles within 1.02 tile sizes of it, so neighbouring tiles merge with no seam. The kernels overlap on purpose: at the middle tile's own centre its six neighbours still weigh 0.0015 each, so a 200 m tile among 100 m tiles reads 199.10 m there, not 200. Outside the map the edge tiles continue as a plateau.
fn the_blend_overlaps() {
p = terrain_params(42, 500.0);
(cx, cy) = terrain_center(p, 1, 1);
h = terrain_blend_h(plateau(200.0), p, cx, cy);
assert(close(h, 199.103394), "the centre reads a little of its neighbours");
assert(close(terrain_blend_h(plateau(100.0), p, -5000.0, -5000.0), 100.0), "outside: the edge, continued");
println("blend : {h:.2} m at the centre of a 200 m tile");
}
Sampling is pure
terrain_sample is a function of the map, the types, the params, the rivers and the point — nothing else. So the fine world derives in any order, from any viewport, and the same seed rebuilds the same world. The map itself is NOT independent of its extent: hydrology is global, so generate the whole overland map once, and sample windows of it.
fn build(p: TerrainParams) -> (Terrain, vector<TerrainRiver>) {
t = plateau(50.0);
terrain_hydrology(t, p, LAKE);
terrain_relief_pass(t, types(), p);
(t, terrain_rivers(t, types(), p))
}
fn sampling_is_pure() {
p = terrain_params(42, 500.0);
(a, ra) = build(p);
(b, rb) = build(p);
s1 = terrain_sample(a, types(), p, ra, 600.0, 300.0);
s2 = terrain_sample(b, types(), p, rb, 600.0, 300.0);
s3 = terrain_sample(a, types(), p, ra, 600.0, 300.0);
assert(s1.ts_h == s2.ts_h && s1.ts_h == s3.ts_h && s1.ts_mat == s2.ts_mat, "same inputs, same sample");
println("sample : the same point answers the same, from a rebuilt world too");
}
Where to go next
tests/02-worked-examples.loft—@HXT-001…@HXT-008: the overland lattice in metres, the record that depends on the map's extent, the order of the passes, relief, the overlapping kernel, the plateau outside the map, shoreline against finished water, and the type boundary that is a line rather than a crossfade.