Birdoggydog's Builds

Building rivers and waterfalls from a waterways table

A 10 km island retuned in its generator's settings, river sheets laid out in seconds of travel in Godot, a tiered tower, and one folder for every file.

I rebuilt Blobber’s island as a 10.24 km square by retuning the generator’s settings. Its rivers, lakes and waterfalls are now built from a table of 4 m curves that the generator writes. The stand-in Tower is a building, and every file of the project sits under one folder. The work is done by AI coding agents that I direct.

Retuning the generator for a 10 km square

The Wishbone, the island the game is set on, is a 10.24 km square with 47.8 square km of land. The first build was 16.4 km a side with 121.

Two maps of the same C-shaped island at one scale. The upper is 16.4 km square with 121 square km of land. The lower is 10.2 km square with 48 square km. Beside it, a square for Skyrim’s land, 41 square km.
The island I built first, the one I meant to build, and Skyrim's land as a square.

I’d read Skyrim’s 16 square miles as square km, so the first island had three Skyrims of land.

The island is one JSON file, tools/worldgen/islands/wishbone.json, with positions in units, 32 to a side. A variant is a block laid over the base file, and it can now hold a world block, so wishbone.small builds at its own size by name. A smaller size alone only shrinks the island (a unit goes from 512 m to 320 m), so the variant changes the land’s settings too:

Setting16 km variant10 km variant
world.size_m, cell_m16,384 and 1610,240 and 8
Grid, regions of 512 m1,024 cells, 32 a side1,280 cells, 20 a side
tectonics.top_m950430, between held summits
base_uplift0.040.015
range_sharpness2.33.0
half_width of the Long Arm, the Arch, the Thin Arm (units)4.4, 3.4, 1.93.2, 2.2, 1.3
erosion.hillslope_m280,00050,000
floodplain.min_km2, reach_m0.35, 1300.15, 90
coast_plain_m500400
story.snow_line_mnone600 north, 520 south

Erosion alone now leaves ranges 430 m high. Four summits stand above them as held floors: tectonics.py writes a least height for each cell, and erosion may not cut below it. A summit is a horn with peak_m at the middle, faces falling at 47 degrees, and ridges along two compass bearings falling at 27 degrees with a level top 56 m wide to walk on. Below hold_above_m a scree skirt runs down at 39 degrees to the eroded ground. Erosion rescales the land so that its highest point is top_m, and it now measures only cells with no floor. The Cleaver is asked for at 1,065 m and stands at 1,061. The old island topped out at 936.

The summits read as drawn, not eroded: the faces are even and each ridge is a ramp.

The 10 km island from above, painted. Snow on three massifs in the west, tan desert on the outer coast and green lowlands round an inner sea, and a small island in that sea washed crimson. A legend lists each biome’s share of the land.
The crimson is the meat. The biomes are stand-ins, and the map says so.

Snow follows the hand-drawn line. A stand-in climate counts a ridge as 50 m higher than it is, so snow runs along the ridges. Snow country went from 0.27 to 2.1 square km.

Writing the rivers out as a waterways table

The generator’s new last step writes tables/waterways.json: every reach of river as a curve with a point every 4 m, every lake as a polygon, and every fall with its place along its reach.

A plain map of the island: grey land on a dark blue sea, thin blue lines for 383 creeks and rivers drawn at their widths, five lakes as blue shapes, and a small ring at each river mouth.
The table as the generator draws it. Most of the lines are creeks under 2 m wide.

Until now a river was a line of cell centres with one width for its stream order, and a lake was a set of cells. The water faults left the day before were put down to that.

The hydrology step already adds up the land draining through each cell, and a reach is the channel between two joins. The new step (tools/worldgen/waterways.py) puts a uniform Catmull-Rom spline through each reach’s points, samples it every 4 m and takes a running minimum of the height, so water never runs uphill. At each point:

  • discharge is 0.42 * (km2 * 60) ^ 0.69 cubic metres a second, from the land draining to that point (60 is area_scale: the island stands for a larger country)
  • width is 1.5 * Q ^ 0.7, held between 0.8 and 40 m
  • depth is 0.25 * Q ^ 0.4, held between 0.1 and 3 m

A reach into the sea gets one more point out on the water at height 0. A reach into a lake ends at the lake’s level. A lake’s outline is its cells’ outer edges traced, with every corner cut twice.

This is reach 304, a spring-fed creek, with its line cut to three points and the fields put in reading order. A point is x, y, height, width, depth and discharge, in metres from the map’s north-west corner:

{"id": 304, "order": 1, "kind": "creek", "length_m": 88.0,
 "from": {"kind": "spring", "at": [3588.0, 6868.0, 261.15]},
 "to": {"kind": "lake", "id": 1, "at": [3676.0, 6868.0, 5.49]},
 "line": [[3588.0, 6868.0, 261.15, 1.39, 0.24, 0.893],
          [3592.0, 6868.0, 259.11, 1.39, 0.24, 0.894],
          [3596.0, 6868.0, 257.04, 1.39, 0.24, 0.894]]}

A lake has level_m, area_ha, max_depth_m, outline, holes, inflows and outflow. A fall has lip and foot as points, drop_m, run_m, why (“a step in schist”) and along_m, the distance down its reach to the lip. The table also lists confluences and mouths. This island’s holds 383 reaches, 5 lakes and 26 falls.

Cutting beds and laying river sheets from the table

The terrain painter cuts every river’s bed into the heights and writes the water’s mesh data in the same pass, so the game fits nothing to the terrain while it runs.

world/experiments/wishbone/water_ways.py joins reaches into chains: at each confluence the wider inflow runs on and the others end there. A chain is resampled every 2 m. Each point gets a water surface (0.3 m under the table’s height, never rising), half the bed’s width, and a depth.

The terrain has a vertex every metre, and each vertex within 26 m of a chain takes its shape from the nearest point of that chain. Inside the bed the ground is set to an absolute height, the surface less the depth. The banks rise over 0.8 + 1.2 * depth metres to a lip 0.35 m above the water. Past the lip the ground may not stand above a line rising 0.6 m a metre (a cut) or below one falling 0.5 m a metre (a levee).

Each chain is then written as one ribbon, five vertices to a section: the middle line, an edge in each bank, and one between on each side. A section is 27 floats in rivers.sections.f32: the five positions, the across direction, and values for the shader. An index, rivers.json, cuts the ribbon into chunks of 64 sections (128 m) that share the section where they meet, so a river is one sheet from spring to mouth.

In the game, world/experiments/explore/river_sheets.gd builds a chunk as an ArrayMesh when the party is within 520 m and the terrain under it is loaded, and frees it beyond 660 m. It spends at most 3 ms a frame on this.

Why were the waterfalls striped?

A waterfall is now a soft fall into a ragged pool with mist, and a river runs into its lake with no gap.

One waterfall twice. On the left, hard white vertical stripes drop into a straight-sided striped trough. On the right, a softer fall lands in a ragged blue pool with a glow of mist.
My snapshot of a bad waterfall, and the same view after.

The stripes came from the ribbon’s length. A section’s distance along the stream went to the shader as UV.y, measured on the map. A face that drops 20 m in 2 m of map got 2 m of pattern, stretched tenfold down the drop. The length is now measured down the slope, and a point goes into every 2.5 m of drop wherever the surface falls more than 3 m between two points, so a face has vertices.

The speckle below every fall was in the game’s water shader (world/water.gdshader), which slid the pattern like this:

	float speed = flow_speed * (1.0 + 3.0 * steep);
	vec2 running = vec2(UV.x, UV.y - TIME * speed) * vec2(0.5, mix(0.26, 0.08, fall));

steep changes from vertex to vertex. Two points a metre apart are offset by TIME times the difference in their speeds, which grows every second. After a few minutes the pattern below a fall is packed into bars a pixel wide.

River sheets now have their own shader, river_sheet.gdshader, and UV.y holds seconds: how long the water has been running when it reaches that section. The painter works it out at 1.6 m a second on the flat and four times that straight down:

        true_run = np.hypot(np.diff(chain.s), np.diff(chain.w))
        pace = FLOW * (1.0 + 3.0 * 0.5 * (steep[:-1] + steep[1:]))
        packed[:, 20] = np.concatenate([[0.0], np.cumsum(true_run / pace)])

The shader subtracts the clock once, the same amount everywhere, and draws its noise in metres across and ago seconds along:

	// Seconds upstream of now, wrapped so the numbers stay small (the noise repeats unseen).
	float ago = UV.y - mod(TIME, 3600.0);

Fast water covers more metres in each second, so the pattern moves faster there and nothing shears.

A river meeting a lake, twice. On the left, a bar of dry stones separates them and the river is streaked white. On the right, the water is one stretch.
Where a river met a lake, there was a bar of dry stones.

The dry bar was two outlines of one lake. The painter smooths each lake’s outline again, and the lake’s bed and water use that. The river’s ribbon still began at the table’s outlet point, which sits on the unsmoothed cells. Now a chain that starts or ends in a lake is carried 34 m in toward the lake’s middle, and a point-in-polygon test against the smoothed outline finds where it crosses the shore. The ribbon is whole to 2.5 m inside the shore and fades out over the next 7.5 m.

A tall fall is still a thin thread on a wide mountainside.

Photographing every fall and river mouth

A survey scene, water_joins.gd, pictures every lake inflow, lake outflow, sea mouth and fall on the island, so that I’m not the one finding faults by flying.

Ten waterfalls in two columns, each seen from below its foot: thin white falls on bare rock faces, one dropping into a blue lake under a cliff, one landing in a pool with white spray.
The first ten falls as the survey framed them.

For a join, the camera goes 40 m straight above the shore point. The script walks the stream’s line in 0.4 m steps from 16 m upstream to 10 m out into the still water. It projects each step into the picture with unproject_position and tests five pixels for blue or the pale white of fast water. A run of 1 m or more with no water is counted as dry.

For a fall, the eye stands downstream of the foot by 14 + 0.6 * drop metres and to one side by 6 + 0.15 * drop, and looks at a point 35% of the way from the foot to the lip.

By the survey’s count, lake outflows with a dry stretch went from 3 of 4 to 0 of 4.

Widening the Tower so it can hold a dungeon

The stand-in Tower is five eight-sided tiers, 500 m tall and 240 m across at the ground.

Ten views in two columns. On the left, the Tower seen from five places on the island. On the right, the same views enlarged four times, where a stepped dark tower stands among snowy mountains.
The wider Tower from five places, each with the same view at four times the size.

The first stand-in was a 70 m column. It couldn’t really be a dungeon, and from far away it was a sliver.

tower_stand_in.gd makes each tier from a CylinderMesh with radial_segments = 8. A tier’s radius at its foot is a share of the base’s (1.0, 0.86, 0.72, 0.58, 0.44), and it leans in to 0.9 of that at its top, so each tier stands back from the one below. At 240 m the ground floor is 4.1 hectares and the top one 0.6. F1 steps the width through 120, 180, 240 and 320 m, and F4 the height through 300, 500 and 700 m.

It had to move 244 m. A creek runs 83 m from the middle of the vale, so anything 180 m wide or more stood in it.

A second scene, tower_sight.gd, measures how much of it shows. From an eye 1.7 m over the ground it draws a line to each 5 m of the Tower’s height and tests it against the terrain every 15 m. From the landing across the inner sea, 6.2 km away, 490 m show, 19.6 pixels wide on a picture 1,280 wide. The column was 5.8. From the slipway on the starting island nothing shows at any size: that island’s own ridge is in the way. I haven’t picked the final size yet.

Moving everything into one folder

Every file of the project is under one workspace folder now, and nothing is written anywhere else.

Two folder trees side by side. Before: the checkout, its worktrees, the tools and another project in separate folders on one drive, with temporary files on the system drive. After: one workspace folder holding Projects, Worktrees, Tools and Tmp.
Before and after.

No script holds a location. Each asks a helper, which walks up from its own file to the nearest folder holding Projects, Worktrees, Tools and Tmp. The rule is written in shell, Python and GDScript, one per language the tools use. This is tools/workspace.py, without the last-resort default it ends on:

_PARTS = ("Projects", "Worktrees", "Tools", "Tmp")
_HERE = os.path.dirname(os.path.abspath(__file__))


def _find_root() -> str:
    named = os.environ.get("GAMEDEV_WORKSPACE", "")
    if named:
        return named
    at = os.path.dirname(_HERE)
    while True:
        if all(os.path.isdir(os.path.join(at, part)) for part in _PARTS):
            return at
        up = os.path.dirname(at)
        if up == at:
            break
        at = up

A checker, tools/check_workspace.py, reads every text file git tracks and fails on a line that names another drive, the user profile or the system temp folder. A plain drive-letter pattern also matches the end of a string like "error:\n" (a letter, a colon, a backslash), so the pattern needs a lookahead that rejects n, t and r after the slash.

Git Bash’s /tmp is the user’s temp folder on the system drive, and bash writes here-documents there. The shell helper exports TMPDIR, TMP and TEMP pointing at Tmp/<Project>/scratch, and every wrapper calls it before starting Godot, Python or Blender.

The move is a PowerShell script with a -WhatIf mode. Its order:

  1. Refuse if Godot, Blender or git is running, or a process runs from a folder to be moved.
  2. Rename the tool folders and the main checkout. A rename on one drive happens whole or not at all.
  3. Run git worktree repair: a worktree and the main repository each hold the other’s absolute path.
  4. Move each worktree with git worktree move, and stop if its count of uncommitted files changed.
  5. Copy the old temp folders off the system drive, check each file’s size, delete the originals.
  6. Leave a directory junction at the old checkout. It comes off with cmd /c rmdir: a recursive delete follows it and empties the real checkout.

The script refused at the main checkout, because the session running it was a process inside that folder. I closed every session and ran it by hand.

Setting aside a trial of AI-generated models

I had an agent research an open-source image-to-3D workflow with nothing installed. It gives one fused mesh of about 500,000 triangles with no parts and no rig, and my pipeline builds a creature from named parts on a rig. I set it aside because of the potential licensing issues.

Next: one orc holding a sword that’s its own model.