hex_terrain guide

← 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