<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Birdoggydog's Builds</title><link>https://birdoggydogs-builds.pages.dev/</link><description>My Journey in AI Assisted Game Development</description><language>en</language><generator>Hugo</generator><lastBuildDate>Fri, 09 Oct 2026 08:00:00 -0700</lastBuildDate><atom:link href="https://birdoggydogs-builds.pages.dev/index.xml" rel="self" type="application/rss+xml"/><item><title>Building rivers and waterfalls from a waterways table</title><link>https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/</link><guid isPermaLink="true">https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/</guid><pubDate>Fri, 09 Oct 2026 08:00:00 -0700</pubDate><category>blobber</category><category>worldgen</category><category>water</category><category>tooling</category><description>&lt;p&gt;I rebuilt Blobber&amp;rsquo;s island as a 10.24 km square by retuning the generator&amp;rsquo;s settings. Its&#10;rivers, lakes and waterfalls are now built from a table of 4 m curves that the generator&#10;writes. The stand-in Tower is a building, and every file of the project sits under one&#10;folder. The work is done by AI coding agents that I direct.&lt;/p&gt;&#10;&lt;h2 id="retuning-the-generator-for-a-10-km-square"&gt;Retuning the generator for a 10 km square&lt;/h2&gt;&#10;&lt;p&gt;The Wishbone, the island the game is set on, is a 10.24 km square with 47.8 square km of&#10;land. The first build was 16.4 km a side with 121.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/island-two-sizes.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/island-two-sizes.png" alt="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." width="1440" height="2798" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;The island I built first, the one I meant to build, and Skyrim&amp;#39;s land as a square.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;I&amp;rsquo;d read Skyrim&amp;rsquo;s 16 square miles as square km, so the first island had three Skyrims of&#10;land.&lt;/p&gt;&#10;&lt;p&gt;The island is one JSON file, &lt;code&gt;tools/worldgen/islands/wishbone.json&lt;/code&gt;, with positions in&#10;units, 32 to a side. A variant is a block laid over the base file, and it can now hold a&#10;&lt;code&gt;world&lt;/code&gt; block, so &lt;code&gt;wishbone.small&lt;/code&gt; builds at its own size by name. A smaller size alone&#10;only shrinks the island (a unit goes from 512 m to 320 m), so the variant changes the&#10;land&amp;rsquo;s settings too:&lt;/p&gt;&#10;&lt;table&gt;&#10; &lt;thead&gt;&#10; &lt;tr&gt;&#10; &lt;th&gt;Setting&lt;/th&gt;&#10; &lt;th&gt;16 km variant&lt;/th&gt;&#10; &lt;th&gt;10 km variant&lt;/th&gt;&#10; &lt;/tr&gt;&#10; &lt;/thead&gt;&#10; &lt;tbody&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;&lt;code&gt;world.size_m&lt;/code&gt;, &lt;code&gt;cell_m&lt;/code&gt;&lt;/td&gt;&#10; &lt;td&gt;16,384 and 16&lt;/td&gt;&#10; &lt;td&gt;10,240 and 8&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;Grid, regions of 512 m&lt;/td&gt;&#10; &lt;td&gt;1,024 cells, 32 a side&lt;/td&gt;&#10; &lt;td&gt;1,280 cells, 20 a side&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;&lt;code&gt;tectonics.top_m&lt;/code&gt;&lt;/td&gt;&#10; &lt;td&gt;950&lt;/td&gt;&#10; &lt;td&gt;430, between held summits&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;&lt;code&gt;base_uplift&lt;/code&gt;&lt;/td&gt;&#10; &lt;td&gt;0.04&lt;/td&gt;&#10; &lt;td&gt;0.015&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;&lt;code&gt;range_sharpness&lt;/code&gt;&lt;/td&gt;&#10; &lt;td&gt;2.3&lt;/td&gt;&#10; &lt;td&gt;3.0&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;&lt;code&gt;half_width&lt;/code&gt; of the Long Arm, the Arch, the Thin Arm (units)&lt;/td&gt;&#10; &lt;td&gt;4.4, 3.4, 1.9&lt;/td&gt;&#10; &lt;td&gt;3.2, 2.2, 1.3&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;&lt;code&gt;erosion.hillslope_m2&lt;/code&gt;&lt;/td&gt;&#10; &lt;td&gt;80,000&lt;/td&gt;&#10; &lt;td&gt;50,000&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;&lt;code&gt;floodplain.min_km2&lt;/code&gt;, &lt;code&gt;reach_m&lt;/code&gt;&lt;/td&gt;&#10; &lt;td&gt;0.35, 130&lt;/td&gt;&#10; &lt;td&gt;0.15, 90&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;&lt;code&gt;coast_plain_m&lt;/code&gt;&lt;/td&gt;&#10; &lt;td&gt;500&lt;/td&gt;&#10; &lt;td&gt;400&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;&lt;code&gt;story.snow_line_m&lt;/code&gt;&lt;/td&gt;&#10; &lt;td&gt;none&lt;/td&gt;&#10; &lt;td&gt;600 north, 520 south&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Erosion alone now leaves ranges 430 m high. Four summits stand above them as held floors:&#10;&lt;code&gt;tectonics.py&lt;/code&gt; writes a least height for each cell, and erosion may not cut below it. A&#10;summit is a horn with &lt;code&gt;peak_m&lt;/code&gt; at the middle, faces falling at 47 degrees, and ridges along&#10;two compass bearings falling at 27 degrees with a level top 56 m wide to walk on. Below&#10;&lt;code&gt;hold_above_m&lt;/code&gt; a scree skirt runs down at 39 degrees to the eroded ground. Erosion rescales&#10;the land so that its highest point is &lt;code&gt;top_m&lt;/code&gt;, and it now measures only cells with no&#10;floor. The Cleaver is asked for at 1,065 m and stands at 1,061. The old island topped out&#10;at 936.&lt;/p&gt;&#10;&lt;p&gt;The summits read as drawn, not eroded: the faces are even and each ridge is a ramp.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/painted-map.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/painted-map_hu_583ce5719cfc6cd1.png" alt="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." width="1440" height="1440" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;The crimson is the meat. The biomes are stand-ins, and the map says so.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;Snow follows the hand-drawn line. A stand-in climate counts a ridge as 50 m higher than it&#10;is, so snow runs along the ridges. Snow country went from 0.27 to 2.1 square km.&lt;/p&gt;&#10;&lt;h2 id="writing-the-rivers-out-as-a-waterways-table"&gt;Writing the rivers out as a waterways table&lt;/h2&gt;&#10;&lt;p&gt;The generator&amp;rsquo;s new last step writes &lt;code&gt;tables/waterways.json&lt;/code&gt;: every reach of river as a&#10;curve with a point every 4 m, every lake as a polygon, and every fall with its place along&#10;its reach.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/waterways-table.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/waterways-table_hu_23d0660530a6354e.png" alt="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." width="1440" height="1440" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;The table as the generator draws it. Most of the lines are creeks under 2 m wide.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;Until now a river was a line of cell centres with one width for its stream order, and a&#10;lake was a set of cells. The water faults left the day before were put down to that.&lt;/p&gt;&#10;&lt;p&gt;The hydrology step already adds up the land draining through each cell, and a reach is the&#10;channel between two joins. The new step (&lt;code&gt;tools/worldgen/waterways.py&lt;/code&gt;) puts a uniform&#10;Catmull-Rom spline through each reach&amp;rsquo;s points, samples it every 4 m and takes a running&#10;minimum of the height, so water never runs uphill. At each point:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;discharge is &lt;code&gt;0.42 * (km2 * 60) ^ 0.69&lt;/code&gt; cubic metres a second, from the land draining to&#10;that point (60 is &lt;code&gt;area_scale&lt;/code&gt;: the island stands for a larger country)&lt;/li&gt;&#10;&lt;li&gt;width is &lt;code&gt;1.5 * Q ^ 0.7&lt;/code&gt;, held between 0.8 and 40 m&lt;/li&gt;&#10;&lt;li&gt;depth is &lt;code&gt;0.25 * Q ^ 0.4&lt;/code&gt;, held between 0.1 and 3 m&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;A reach into the sea gets one more point out on the water at height 0. A reach into a lake&#10;ends at the lake&amp;rsquo;s level. A lake&amp;rsquo;s outline is its cells&amp;rsquo; outer edges traced, with every&#10;corner cut twice.&lt;/p&gt;&#10;&lt;p&gt;This is reach 304, a spring-fed creek, with its line cut to three points and the fields&#10;put in reading order. A point is x, y, height, width, depth and discharge, in metres from&#10;the map&amp;rsquo;s north-west corner:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;#34;id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;304&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;order&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;kind&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;creek&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;length_m&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;88.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;from&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;#34;kind&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;spring&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;at&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;3588.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6868.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;261.15&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;to&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;#34;kind&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;lake&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;at&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;3676.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6868.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;5.49&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;line&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="mf"&gt;3588.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6868.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;261.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.39&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.893&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;3592.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6868.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;259.11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.39&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.894&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;3596.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6868.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;257.04&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.39&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.894&lt;/span&gt;&lt;span class="p"&gt;]]}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A lake has &lt;code&gt;level_m&lt;/code&gt;, &lt;code&gt;area_ha&lt;/code&gt;, &lt;code&gt;max_depth_m&lt;/code&gt;, &lt;code&gt;outline&lt;/code&gt;, &lt;code&gt;holes&lt;/code&gt;, &lt;code&gt;inflows&lt;/code&gt; and&#10;&lt;code&gt;outflow&lt;/code&gt;. A fall has &lt;code&gt;lip&lt;/code&gt; and &lt;code&gt;foot&lt;/code&gt; as points, &lt;code&gt;drop_m&lt;/code&gt;, &lt;code&gt;run_m&lt;/code&gt;, &lt;code&gt;why&lt;/code&gt; (&amp;ldquo;a step in&#10;schist&amp;rdquo;) and &lt;code&gt;along_m&lt;/code&gt;, the distance down its reach to the lip. The table also lists&#10;&lt;code&gt;confluences&lt;/code&gt; and &lt;code&gt;mouths&lt;/code&gt;. This island&amp;rsquo;s holds 383 reaches, 5 lakes and 26 falls.&lt;/p&gt;&#10;&lt;h2 id="cutting-beds-and-laying-river-sheets-from-the-table"&gt;Cutting beds and laying river sheets from the table&lt;/h2&gt;&#10;&lt;p&gt;The terrain painter cuts every river&amp;rsquo;s bed into the heights and writes the water&amp;rsquo;s mesh&#10;data in the same pass, so the game fits nothing to the terrain while it runs.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;world/experiments/wishbone/water_ways.py&lt;/code&gt; joins reaches into chains: at each confluence&#10;the wider inflow runs on and the others end there. A chain is resampled every 2 m. Each&#10;point gets a water surface (0.3 m under the table&amp;rsquo;s height, never rising), half the bed&amp;rsquo;s&#10;width, and a depth.&lt;/p&gt;&#10;&lt;p&gt;The terrain has a vertex every metre, and each vertex within 26 m of a chain takes its&#10;shape from the nearest point of that chain. Inside the bed the ground is set to an absolute&#10;height, the surface less the depth. The banks rise over &lt;code&gt;0.8 + 1.2 * depth&lt;/code&gt; metres to a lip&#10;0.35 m above the water. Past the lip the ground may not stand above a line rising 0.6 m a&#10;metre (a cut) or below one falling 0.5 m a metre (a levee).&lt;/p&gt;&#10;&lt;p&gt;Each chain is then written as one ribbon, five vertices to a section: the middle line, an&#10;edge in each bank, and one between on each side. A section is 27 floats in&#10;&lt;code&gt;rivers.sections.f32&lt;/code&gt;: the five positions, the across direction, and values for the&#10;shader. An index, &lt;code&gt;rivers.json&lt;/code&gt;, cuts the ribbon into chunks of 64 sections (128 m) that&#10;share the section where they meet, so a river is one sheet from spring to mouth.&lt;/p&gt;&#10;&lt;p&gt;In the game, &lt;code&gt;world/experiments/explore/river_sheets.gd&lt;/code&gt; builds a chunk as an &lt;code&gt;ArrayMesh&lt;/code&gt;&#10;when the party is within 520 m and the terrain under it is loaded, and frees it beyond&#10;660 m. It spends at most 3 ms a frame on this.&lt;/p&gt;&#10;&lt;h2 id="why-were-the-waterfalls-striped"&gt;Why were the waterfalls striped?&lt;/h2&gt;&#10;&lt;p&gt;A waterfall is now a soft fall into a ragged pool with mist, and a river runs into its lake&#10;with no gap.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/waterfall-before-after.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/waterfall-before-after_hu_eb077809a06e953a.png" alt="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." width="1440" height="404" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;My snapshot of a bad waterfall, and the same view after.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;The stripes came from the ribbon&amp;rsquo;s length. A section&amp;rsquo;s distance along the stream went to&#10;the shader as &lt;code&gt;UV.y&lt;/code&gt;, measured on the map. A face that drops 20 m in 2 m of map got 2 m of&#10;pattern, stretched tenfold down the drop. The length is now measured down the slope, and a&#10;point goes into every 2.5 m of drop wherever the surface falls more than 3 m between two&#10;points, so a face has vertices.&lt;/p&gt;&#10;&lt;p&gt;The speckle below every fall was in the game&amp;rsquo;s water shader (&lt;code&gt;world/water.gdshader&lt;/code&gt;),&#10;which slid the pattern like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-glsl" data-lang="glsl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;float&lt;/span&gt; &lt;span class="n"&gt;speed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flow_speed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;3.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;steep&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;vec2&lt;/span&gt; &lt;span class="n"&gt;running&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;vec2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;TIME&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;vec2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.26&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fall&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;steep&lt;/code&gt; changes from vertex to vertex. Two points a metre apart are offset by &lt;code&gt;TIME&lt;/code&gt; times&#10;the difference in their speeds, which grows every second. After a few minutes the pattern&#10;below a fall is packed into bars a pixel wide.&lt;/p&gt;&#10;&lt;p&gt;River sheets now have their own shader, &lt;code&gt;river_sheet.gdshader&lt;/code&gt;, and &lt;code&gt;UV.y&lt;/code&gt; holds seconds:&#10;how long the water has been running when it reaches that section. The painter works it out&#10;at 1.6 m a second on the flat and four times that straight down:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;true_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hypot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;pace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FLOW&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;3.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;steep&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;steep&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;packed&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;concatenate&lt;/span&gt;&lt;span class="p"&gt;([[&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cumsum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;true_run&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;pace&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The shader subtracts the clock once, the same amount everywhere, and draws its noise in&#10;metres across and &lt;code&gt;ago&lt;/code&gt; seconds along:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-glsl" data-lang="glsl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="c1"&gt;// Seconds upstream of now, wrapped so the numbers stay small (the noise repeats unseen).&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;float&lt;/span&gt; &lt;span class="n"&gt;ago&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;UV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TIME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;3600.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Fast water covers more metres in each second, so the pattern moves faster there and&#10;nothing shears.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/river-lake-before-after.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/river-lake-before-after_hu_f4a9cc160bd89571.png" alt="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." width="1440" height="404" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;Where a river met a lake, there was a bar of dry stones.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;The dry bar was two outlines of one lake. The painter smooths each lake&amp;rsquo;s outline again,&#10;and the lake&amp;rsquo;s bed and water use that. The river&amp;rsquo;s ribbon still began at the table&amp;rsquo;s outlet&#10;point, which sits on the unsmoothed cells. Now a chain that starts or ends in a lake is&#10;carried 34 m in toward the lake&amp;rsquo;s middle, and a point-in-polygon test against the smoothed&#10;outline finds where it crosses the shore. The ribbon is whole to 2.5 m inside the shore and&#10;fades out over the next 7.5 m.&lt;/p&gt;&#10;&lt;p&gt;A tall fall is still a thin thread on a wide mountainside.&lt;/p&gt;&#10;&lt;h2 id="photographing-every-fall-and-river-mouth"&gt;Photographing every fall and river mouth&lt;/h2&gt;&#10;&lt;p&gt;A survey scene, &lt;code&gt;water_joins.gd&lt;/code&gt;, pictures every lake inflow, lake outflow, sea mouth and&#10;fall on the island, so that I&amp;rsquo;m not the one finding faults by flying.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/falls-survey.jpg"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/falls-survey_hu_2584e613db357ea4.jpg" alt="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." width="1440" height="2025" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;The first ten falls as the survey framed them.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;For a join, the camera goes 40 m straight above the shore point. The script walks the&#10;stream&amp;rsquo;s line in 0.4 m steps from 16 m upstream to 10 m out into the still water. It&#10;projects each step into the picture with &lt;code&gt;unproject_position&lt;/code&gt; and tests five pixels for&#10;blue or the pale white of fast water. A run of 1 m or more with no water is counted as dry.&lt;/p&gt;&#10;&lt;p&gt;For a fall, the eye stands downstream of the foot by &lt;code&gt;14 + 0.6 * drop&lt;/code&gt; metres and to one&#10;side by &lt;code&gt;6 + 0.15 * drop&lt;/code&gt;, and looks at a point 35% of the way from the foot to the lip.&lt;/p&gt;&#10;&lt;p&gt;By the survey&amp;rsquo;s count, lake outflows with a dry stretch went from 3 of 4 to 0 of 4.&lt;/p&gt;&#10;&lt;h2 id="widening-the-tower-so-it-can-hold-a-dungeon"&gt;Widening the Tower so it can hold a dungeon&lt;/h2&gt;&#10;&lt;p&gt;The stand-in Tower is five eight-sided tiers, 500 m tall and 240 m across at the ground.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/wider-tower-sight.jpg"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/wider-tower-sight_hu_1308c80ed43279dd.jpg" alt="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." width="1440" height="2025" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;The wider Tower from five places, each with the same view at four times the size.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;The first stand-in was a 70 m column. It couldn&amp;rsquo;t really be a dungeon, and from far away it&#10;was a sliver.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;tower_stand_in.gd&lt;/code&gt; makes each tier from a &lt;code&gt;CylinderMesh&lt;/code&gt; with &lt;code&gt;radial_segments = 8&lt;/code&gt;. A&#10;tier&amp;rsquo;s radius at its foot is a share of the base&amp;rsquo;s (1.0, 0.86, 0.72, 0.58, 0.44), and it&#10;leans in to 0.9 of that at its top, so each tier stands back from the one below. At 240 m&#10;the ground floor is 4.1 hectares and the top one 0.6. F1 steps the width through 120, 180,&#10;240 and 320 m, and F4 the height through 300, 500 and 700 m.&lt;/p&gt;&#10;&lt;p&gt;It had to move 244 m. A creek runs 83 m from the middle of the vale, so anything 180 m wide&#10;or more stood in it.&lt;/p&gt;&#10;&lt;p&gt;A second scene, &lt;code&gt;tower_sight.gd&lt;/code&gt;, measures how much of it shows. From an eye 1.7 m over the&#10;ground it draws a line to each 5 m of the Tower&amp;rsquo;s height and tests it against the terrain&#10;every 15 m. From the landing across the inner sea, 6.2 km away, 490 m show, 19.6 pixels&#10;wide on a picture 1,280 wide. The column was 5.8. From the slipway on the starting island&#10;nothing shows at any size: that island&amp;rsquo;s own ridge is in the way. I haven&amp;rsquo;t picked the&#10;final size yet.&lt;/p&gt;&#10;&lt;h2 id="moving-everything-into-one-folder"&gt;Moving everything into one folder&lt;/h2&gt;&#10;&lt;p&gt;Every file of the project is under one workspace folder now, and nothing is written&#10;anywhere else.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/layout.svg"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-09-rebuilding-the-island-at-10-km/layout.svg" alt="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." loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;Before and after.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;No script holds a location. Each asks a helper, which walks up from its own file to the&#10;nearest folder holding &lt;code&gt;Projects&lt;/code&gt;, &lt;code&gt;Worktrees&lt;/code&gt;, &lt;code&gt;Tools&lt;/code&gt; and &lt;code&gt;Tmp&lt;/code&gt;. The rule is written in&#10;shell, Python and GDScript, one per language the tools use. This is &lt;code&gt;tools/workspace.py&lt;/code&gt;,&#10;without the last-resort default it ends on:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;_PARTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Projects&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Worktrees&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Tools&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Tmp&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;_HERE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;abspath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vm"&gt;__file__&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_find_root&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;named&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;GAMEDEV_WORKSPACE&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;named&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;named&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_HERE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_PARTS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;break&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A checker, &lt;code&gt;tools/check_workspace.py&lt;/code&gt;, reads every text file git tracks and fails on a line&#10;that names another drive, the user profile or the system temp folder. A plain drive-letter&#10;pattern also matches the end of a string like &lt;code&gt;&amp;quot;error:\n&amp;quot;&lt;/code&gt; (a letter, a colon, a&#10;backslash), so the pattern needs a lookahead that rejects &lt;code&gt;n&lt;/code&gt;, &lt;code&gt;t&lt;/code&gt; and &lt;code&gt;r&lt;/code&gt; after the slash.&lt;/p&gt;&#10;&lt;p&gt;Git Bash&amp;rsquo;s &lt;code&gt;/tmp&lt;/code&gt; is the user&amp;rsquo;s temp folder on the system drive, and bash writes&#10;here-documents there. The shell helper exports &lt;code&gt;TMPDIR&lt;/code&gt;, &lt;code&gt;TMP&lt;/code&gt; and &lt;code&gt;TEMP&lt;/code&gt; pointing at&#10;&lt;code&gt;Tmp/&amp;lt;Project&amp;gt;/scratch&lt;/code&gt;, and every wrapper calls it before starting Godot, Python or&#10;Blender.&lt;/p&gt;&#10;&lt;p&gt;The move is a PowerShell script with a &lt;code&gt;-WhatIf&lt;/code&gt; mode. Its order:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Refuse if Godot, Blender or git is running, or a process runs from a folder to be moved.&lt;/li&gt;&#10;&lt;li&gt;Rename the tool folders and the main checkout. A rename on one drive happens whole or&#10;not at all.&lt;/li&gt;&#10;&lt;li&gt;Run &lt;code&gt;git worktree repair&lt;/code&gt;: a worktree and the main repository each hold the other&amp;rsquo;s&#10;absolute path.&lt;/li&gt;&#10;&lt;li&gt;Move each worktree with &lt;code&gt;git worktree move&lt;/code&gt;, and stop if its count of uncommitted files&#10;changed.&lt;/li&gt;&#10;&lt;li&gt;Copy the old temp folders off the system drive, check each file&amp;rsquo;s size, delete the&#10;originals.&lt;/li&gt;&#10;&lt;li&gt;Leave a directory junction at the old checkout. It comes off with &lt;code&gt;cmd /c rmdir&lt;/code&gt;: a&#10;recursive delete follows it and empties the real checkout.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The script refused at the main checkout, because the session running it was a process&#10;inside that folder. I closed every session and ran it by hand.&lt;/p&gt;&#10;&lt;h2 id="setting-aside-a-trial-of-ai-generated-models"&gt;Setting aside a trial of AI-generated models&lt;/h2&gt;&#10;&lt;p&gt;I had an agent research an open-source image-to-3D workflow with nothing installed. It&#10;gives one fused mesh of about 500,000 triangles with no parts and no rig, and my pipeline&#10;builds a creature from named parts on a rig. I set it aside because of the potential&#10;licensing issues.&lt;/p&gt;&#10;&lt;p&gt;Next: one orc holding a sword that&amp;rsquo;s its own model.&lt;/p&gt;&#10;</description></item><item><title>Generating an island with stream-power erosion for Godot</title><link>https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/</link><guid isPermaLink="true">https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/</guid><pubDate>Thu, 08 Oct 2026 20:00:00 -0700</pubDate><category>blobber</category><category>world</category><category>terrain</category><category>water</category><category>models</category><description>&lt;p&gt;Blobber has an island: 121 square km of land that a Python generator builds from one JSON&#10;file by uplift and stream-power erosion, with 973 river reaches that all drain to the sea.&#10;Godot streams it round the party as 512 m Terrain3D regions. I build the game by directing&#10;AI coding agents, and this is what they built on 8 October.&lt;/p&gt;&#10;&lt;h2 id="laying-out-a-knowledge-base-for-world-building"&gt;Laying out a knowledge base for world building&lt;/h2&gt;&#10;&lt;p&gt;Three research agents wrote a knowledge base in parallel before any code, and the&#10;generator&amp;rsquo;s design comes from named pages in it.&lt;/p&gt;&#10;&lt;p&gt;It is a folder of Markdown outside the game&amp;rsquo;s repository, &lt;code&gt;&amp;lt;workspace&amp;gt;/Tools/Blobber/kb_world/&lt;/code&gt;:&#10;86 notes and 15 folder READMEs as it stands now, about 121,000 words. Fourteen numbered&#10;folders run from &lt;code&gt;00_policy&lt;/code&gt; through world generation, engine limits, ground textures,&#10;water and case studies of shipped games to &lt;code&gt;12_plan&lt;/code&gt;. &lt;code&gt;INDEX.md&lt;/code&gt; maps a question to the&#10;file to start at, and an agent greps the rest.&lt;/p&gt;&#10;&lt;p&gt;Every file opens with the same front matter, here the erosion note&amp;rsquo;s:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;source_authority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;peer-reviewed papers (high); developers&amp;#39; write-ups and repositories (medium)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;software&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;none (method notes); reference code in C++/OpenGL and Python&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;01_worldgen/erosion&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;asset_class&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;terrain&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;target_engine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Godot 4.7&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;license&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;notes are the project&amp;#39;s; code licences as listed&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;medium (methods high; costs at 64 km are extrapolations and marked so)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;00_policy/README.md&lt;/code&gt; ranks sources (papers and official documentation high, blogs medium,&#10;forum posts low) and licences: MIT code may be ported into the game, a GPL tool may only&#10;be run as a separate program. A &lt;code&gt;*.note.md&lt;/code&gt; is&#10;the research, each claim tied to a URL and its fetch date, or marked &amp;ldquo;from memory, not&#10;fetched&amp;rdquo;. A folder&amp;rsquo;s &lt;code&gt;README.md&lt;/code&gt; is the conclusion: options in a table, a recommendation,&#10;and numbered work items with their dependencies.&lt;/p&gt;&#10;&lt;p&gt;A fourth agent read only the last section of each README and wrote &lt;code&gt;12_plan/PLAN.md&lt;/code&gt;, 547&#10;lines in eight stages, with every number tagged measured, published, estimated or its own&#10;arithmetic.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;01_worldgen/README.md&lt;/code&gt; compares seven ways to make the large shape of land and recommends&#10;authored uplift maps with stream-power erosion, in Python and NumPy as a build step beside&#10;the game. Its work items W1 to W5 are the next three sections.&lt;/p&gt;&#10;&lt;h2 id="drawing-the-coast-from-control-points-and-noise"&gt;Drawing the coast from control points and noise&lt;/h2&gt;&#10;&lt;p&gt;The Wishbone&amp;rsquo;s coast is 34 points in &lt;code&gt;tools/worldgen/islands/wishbone.json&lt;/code&gt;.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/coast-from-outline.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/coast-from-outline.png" alt="A map of the Wishbone as a distance field: sand-colored land in two arms round a dark blue inner sea, with a small island in the gap between them, and contour lines of distance from the coast." width="1024" height="1024" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;The coast after the noise: the signed distance field, pale inland and dark out to sea.&lt;/figcaption&gt;&lt;/figure&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;coast&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.5&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;3.2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;24.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.6&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;27.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;7.5&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;29.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;11.5&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;28.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;17.6&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;28.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;20.4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;29.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;28.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;25.6&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;25.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;26.2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;24.4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;24.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;21.4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;25.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;18.4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;24.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;23.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;13.6&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;21.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;11.4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;10.4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;13.2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;12.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;16.4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;13.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;19.6&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;14.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;13.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;26.4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;11.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;29.4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;8.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;30.2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;28.6&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;4.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;25.4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;3.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;21.6&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;2.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;17.5&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;2.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;12.5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Positions are in units, 32 to a side: a unit is 512 m of the 16,384 m square, one&#10;Terrain3D region. Every map is a 1,024 by 1,024 field at 16 m a cell.&lt;/p&gt;&#10;&lt;p&gt;A smooth closed curve through the points becomes a signed distance field, positive inland.&#10;The field is domain-warped: it is read at positions pushed by two 3-octave fBm fields, then&#10;a 5-octave fBm is added, 800 m of amplitude at a 3,400 m wavelength for the Wishbone. A&#10;smooth maximum joins on named necks the noise must not cut, and a smooth minimum cuts the&#10;straits and a harbor. Land is where the field is over zero.&lt;/p&gt;&#10;&lt;p&gt;The noise is gradient noise from an integer hash of lattice points, so a value depends only&#10;on seed and position, and two builds are byte for byte identical. Each step is a pure&#10;function that writes fields as raw little-endian &lt;code&gt;.bin&lt;/code&gt; files with a JSON header, and runs&#10;again only when a hash of its inputs changes.&lt;/p&gt;&#10;&lt;p&gt;I had six outlines built this way and picked B.&lt;/p&gt;&#10;&lt;h2 id="raising-the-land-and-eroding-it-with-the-stream-power-law"&gt;Raising the land and eroding it with the stream power law&lt;/h2&gt;&#10;&lt;p&gt;The land&amp;rsquo;s height comes from a map of how fast the ground rises, worn down by the rivers&#10;that form on it. Noise alone has no drainage.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/uplift-map.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/uplift-map.png" alt="The island colored by uplift on a dark ground: orange and red blocks along the west arm and a red bar across the north-west valley mouth, yellow along the north arch and east arm, almost none on the south-east lobe." width="1024" height="1024" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;Uplift, drawn from the block below. Red rises fastest. The red bar is the Sill, which the gorge cuts.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;The uplift map is drawn from a tectonic story, data in the same file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;tectonics&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;note&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;The inner sea is a rift that the sea has entered. The two arms are its shoulders, tilted up toward the water and falling away gently to the ocean; the Arch is its closed head, older and lower; the Plug&amp;#39;s volcano stands on the rift&amp;#39;s floor; the Hook is a low block dropped across a fault at the Wrist.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;top_m&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;base_uplift&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.065&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;basement&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;schist&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;lowland&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;mudstone&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;lowland_below&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;valley_floor&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;mudstone&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;ranges&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;name&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;the Long Arm&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;why&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;the rift&amp;#39;s western shoulder: steep to the inner sea, long slopes to the ocean, highest and coldest at its southern end&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;spine&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="mf"&gt;6.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;11.8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;6.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;13.4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;7.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;8.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;9.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;27.4&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;uplift&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;along&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;half_width&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;4.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;skew&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;-0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;core&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;granite&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;core_width&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.28&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;skew&lt;/code&gt; steepens one side of a range. Six rock types each carry a hardness and an angle of&#10;rest, from granite (2.4, 50 degrees) to mudstone (0.55, 30 degrees).&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;erosion.py&lt;/code&gt; solves the stream power law, &lt;code&gt;dh/dt = u - K * A^0.5 * s&lt;/code&gt;: uplift &lt;code&gt;u&lt;/code&gt; raises a&#10;cell and its river cuts it down by drainage area &lt;code&gt;A&lt;/code&gt; and slope &lt;code&gt;s&lt;/code&gt;. Each step sends every&#10;land cell&amp;rsquo;s water to the steepest of its eight neighbors, joins each pit to the sea through&#10;its lowest pass, and sums area down that tree. Then it solves heights from the sea upward:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hillslope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;One implicit step of uplift and stream-power erosion. Returns the new heights.&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;grid&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;N&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;rt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Routing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;area&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accumulate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cell&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;pull&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hard&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;hillslope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;rt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;water&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;reach&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tan&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;near&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cell&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;hn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;recv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recv&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deepest&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;nodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nodes_at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;below&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maximum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="n"&gt;water&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="c1"&gt;# a lake&amp;#39;s surface is its bed&amp;#39;s base level&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pull&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;below&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;near&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minimum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;below&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;reach&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;hn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;relax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hn&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A cell&amp;rsquo;s new height uses its receiver&amp;rsquo;s new height, already solved. That makes the step&#10;implicit, so it is stable at a time step of 40. The &lt;code&gt;np.minimum&lt;/code&gt;&#10;line is talus: no cell stands over its receiver by more than its rock&amp;rsquo;s angle of rest.&lt;/p&gt;&#10;&lt;p&gt;It runs coarse to fine, each grid started from the last:&lt;/p&gt;&#10;&lt;table&gt;&#10; &lt;thead&gt;&#10; &lt;tr&gt;&#10; &lt;th&gt;Grid&lt;/th&gt;&#10; &lt;th&gt;Steps&lt;/th&gt;&#10; &lt;th&gt;Land cells&lt;/th&gt;&#10; &lt;/tr&gt;&#10; &lt;/thead&gt;&#10; &lt;tbody&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;64 m&lt;/td&gt;&#10; &lt;td&gt;150&lt;/td&gt;&#10; &lt;td&gt;29,523&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;32 m&lt;/td&gt;&#10; &lt;td&gt;40&lt;/td&gt;&#10; &lt;td&gt;118,185&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;16 m&lt;/td&gt;&#10; &lt;td&gt;14&lt;/td&gt;&#10; &lt;td&gt;471,426&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;&lt;code&gt;K&lt;/code&gt; is rescaled during the run so the highest point lands on &lt;code&gt;top_m&lt;/code&gt;. The valley, the&#10;gorge and the passes are held by fields the erosion obeys every step (a height cap, a&#10;height floor, kept lakes), and one command checks 18 constraints on the result.&lt;/p&gt;&#10;&lt;p&gt;The last pass is Priority-Flood (Barnes 2014). It fills every hollow with 2 mm of fall&#10;toward its outlet, then gives back as lakes the hollows of at least 3,000 square m and&#10;1.5 m deep. The generator&amp;rsquo;s own&#10;notes put the erosion at about 11 seconds and the whole island at about a minute.&lt;/p&gt;&#10;&lt;p&gt;Streams run straight for hundreds of metres, because each cell has one receiver.&lt;/p&gt;&#10;&lt;h2 id="routing-rivers-and-finding-lakes"&gt;Routing rivers and finding lakes&lt;/h2&gt;&#10;&lt;p&gt;Rivers, lakes, springs and waterfalls are read off the eroded heights. Nothing is drawn.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/rivers-and-basins.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/rivers-and-basins.png" alt="The island shaded grey with drainage basins tinted in pastel colors and thin blue streams branching through each to the coast, with a dozen blue lakes." width="1024" height="1024" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;Every stream and lake of the tuned land, and its larger drainage basins.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;A cell is a creek once 0.045 square km drains through it and a river at 0.45. Strahler&#10;order sets the width: 1.5, 3, 6, 12, 22 or 40 m. A reach is the channel between two joins,&#10;kept as a line of &lt;code&gt;[x, y, bed height]&lt;/code&gt; from its head down. This is the first in&#10;&lt;code&gt;tables/rivers.json&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;discharge_m3s&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.011&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;drains_km2&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1613&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;drop_m&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.19&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;from&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;join&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;kind&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;creek&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;length_m&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;48.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;meander_m&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;order&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;points&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="mf"&gt;7384.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="mf"&gt;1592.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="mf"&gt;2.19&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="mf"&gt;7384.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="mf"&gt;1576.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="mf"&gt;7384.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="mf"&gt;1560.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="mf"&gt;0.87&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;to&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;sea&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;width_m&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3.0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A lake is the cells under the level their hollow spills at. A waterfall candidate is a run&#10;of links at least 0.42 steep and 2.2 times their reach&amp;rsquo;s usual grade that drops 10 m. The&#10;tuned land has 90.5 km of river, 208.9 km of creek, 12 lakes and 612 springs.&lt;/p&gt;&#10;&lt;h2 id="was-the-land-flat-enough-to-play-on"&gt;Was the land flat enough to play on?&lt;/h2&gt;&#10;&lt;p&gt;The first land wasn&amp;rsquo;t: 35% of it was under 10 degrees. A second, tuned for play, is 53%.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/slope-bands-first-land.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/slope-bands-first-land.png" alt="The first land colored by slope: green only along the coasts and in the round valley, with wide orange and dark red bands along every range." width="1024" height="1024" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;The first land. Green is under 10 degrees, yellow to 25, orange to 42, dark red can&amp;#39;t be climbed.&lt;/figcaption&gt;&lt;/figure&gt;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/slope-bands-tuned-land.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/slope-bands-tuned-land.png" alt="The tuned land colored by slope: green covers most of the island, and the orange and dark red are narrow strips along the range crests." width="1024" height="1024" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;The land tuned for play, same coast and same story.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;&lt;code&gt;play.py&lt;/code&gt; bands every cell by slope at the angles the game uses: the party slides on&#10;ground over 42 degrees. It then runs Dijkstra over a 32 m grid from the starter valley to&#10;eight named places, where a step costs its length over the speed the slope allows (9 m/s&#10;to 10 degrees, 5 by 25, 2 by 42). A test in Godot drives the party&amp;rsquo;s real body along those&#10;routes with the game&amp;rsquo;s own keys and counts how often it is slowed, stopped or slides.&lt;/p&gt;&#10;&lt;table&gt;&#10; &lt;thead&gt;&#10; &lt;tr&gt;&#10; &lt;th&gt;&lt;/th&gt;&#10; &lt;th&gt;First land&lt;/th&gt;&#10; &lt;th&gt;Tuned for play&lt;/th&gt;&#10; &lt;/tr&gt;&#10; &lt;/thead&gt;&#10; &lt;tbody&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;Easy, under 10 degrees&lt;/td&gt;&#10; &lt;td&gt;34.7%&lt;/td&gt;&#10; &lt;td&gt;52.8%&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;Hard going, 25 to 42&lt;/td&gt;&#10; &lt;td&gt;35.7%&lt;/td&gt;&#10; &lt;td&gt;19.6%&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;Impassable&lt;/td&gt;&#10; &lt;td&gt;10.1%&lt;/td&gt;&#10; &lt;td&gt;8.0%&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;Highest point&lt;/td&gt;&#10; &lt;td&gt;1,102 m&lt;/td&gt;&#10; &lt;td&gt;936 m&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;Gorge stopped the party (the agent&amp;rsquo;s test run)&lt;/td&gt;&#10; &lt;td&gt;14% of the time&lt;/td&gt;&#10; &lt;td&gt;never&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The tuned land is a named variant, a block laid over the island file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;top_m&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;950&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;base_uplift&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.04&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;range_sharpness&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.3&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;coast_plain_m&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;floodplain&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;#34;min_km2&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;reach_m&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;130&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;grade&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;max_fall&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.09&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;sea_cliffs&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;#34;shelter_under&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.62&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;inland_m&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;110&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;share&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;min_m&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;valley_ragged&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;valley_roll_m&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;valley_bar_m&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;passes&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;#34;name&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;the Saddle&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;half_width&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;cut&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;#34;name&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;the Arch Gap&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;half_width&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;cut&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;#34;name&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;the White Gap&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;half_width&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;cut&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;erosion&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;#34;hillslope_m2&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80000&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;range_sharpness&lt;/code&gt; (1.6 before) narrows each range and leaves wider foothills. The stream&#10;power law only cuts, so its valleys are V-shaped: &lt;code&gt;floodplain&lt;/code&gt; lowers the ground beside a&#10;river draining 0.35 square km or more to the river&amp;rsquo;s height plus a 3% rise.&lt;/p&gt;&#10;&lt;h2 id="painting-the-ground-from-slope-wetness-and-aspect"&gt;Painting the ground from slope, wetness and aspect&lt;/h2&gt;&#10;&lt;p&gt;A painter in Python picks the ground textures at every vertex from what the land is doing&#10;there.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/valley-plain-and-painted.jpg"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/valley-plain-and-painted_hu_65a9042b8a454c24.jpg" alt="Three views of the same valley, unpainted on the left and painted on the right. Flat green ground becomes grass with a winding stony river bed, mud patches and a track, and the pale cliffs gain layered rock." width="1440" height="1215" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;The starting valley before and after the ground was painted by rule.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;The first version stamped noise and covered the plain in same-sized brown blobs. Now&#10;&lt;code&gt;paint.py&lt;/code&gt; works out causes, each a number per vertex at 1 m. Ridge or hollow is height&#10;against its mean 48 m round. Wet is&#10;&lt;code&gt;exp(-hand / 1.5)&lt;/code&gt; times a ramp on the wetness index &lt;code&gt;ln(flow / slope)&lt;/code&gt;, where &lt;code&gt;hand&lt;/code&gt; is&#10;height above the nearest drainage. Sunny is how far the slope faces the sun&amp;rsquo;s bearing.&lt;/p&gt;&#10;&lt;p&gt;A biome in &lt;code&gt;biomes.json&lt;/code&gt; lists its grounds. The first lies everywhere. Each later one lies&#10;where its causes times their amounts, plus noise and a bias, pass a threshold: bare earth&#10;on the dry plain weighs &lt;code&gt;ridge&lt;/code&gt; 1.2, &lt;code&gt;sunny&lt;/code&gt; 0.8 and &lt;code&gt;wet&lt;/code&gt; -1.0. Terrain3D blends two textures a vertex, so&#10;the painter keeps the strongest two and packs a 32-bit control word:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minimum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uint32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maximum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uint32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;blend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;share&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;share&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;control&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;27&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blend&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;255.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uint32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Textures are stored pale and neutral and the terrain&amp;rsquo;s color map multiplies them, so one&#10;grass serves a dry plain and a wet valley. That keeps the count under Terrain3D&amp;rsquo;s 32&#10;slots. Python can&amp;rsquo;t write Terrain3D&amp;rsquo;s region files. It writes raw float32 heights, uint32&#10;control words and RGBA color, and a Godot script wraps each in an &lt;code&gt;Image&lt;/code&gt; and saves a&#10;&lt;code&gt;Terrain3DRegion&lt;/code&gt;. A 512 m region takes 1.16 s to paint and 45 ms to import.&lt;/p&gt;&#10;&lt;h2 id="streaming-the-terrain-round-the-party"&gt;Streaming the terrain round the party&lt;/h2&gt;&#10;&lt;p&gt;Only the terrain near the party is in memory. The island is 576 regions of 512 m at a&#10;vertex a metre.&lt;/p&gt;&#10;&lt;p&gt;Terrain3D 1.0.2 loads every region in its &lt;code&gt;data_directory&lt;/code&gt; as its node enters the scene&#10;tree. &lt;code&gt;TerrainStream&lt;/code&gt; empties that property at run time, before the node enters, so the&#10;editor still loads and sculpts the whole terrain and the game starts with none. Then,&#10;every frame:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;location&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;_wanted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;point&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;load_distance&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;loaded&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;_loading&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="n"&gt;_loading&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="nc"&gt;ResourceLoader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load_threaded_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ResourceLoader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CACHE_MODE_IGNORE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Vector2i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_loading&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_loading&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nc"&gt;ResourceLoader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load_threaded_get_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;ResourceLoader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;THREAD_LOAD_IN_PROGRESS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="k"&gt;continue&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;_loading&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;erase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nc"&gt;ResourceLoader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load_threaded_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;as&lt;/span&gt; &lt;span class="n"&gt;Terrain3DRegion&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kt"&gt;null&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;loaded&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="nf"&gt;_take_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="n"&gt;arrived&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;dropped&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nf"&gt;_drop_far&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;point&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dropped&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;arrived&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_empty&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nf"&gt;_tell&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arrived&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A region loads on a thread when its nearest edge is within 512 m and is dropped beyond&#10;704 m. &lt;code&gt;_tell&lt;/code&gt; calls the plugin&amp;rsquo;s &lt;code&gt;update_maps&lt;/code&gt; once a frame, 2 to 8 ms.&lt;/p&gt;&#10;&lt;p&gt;The plugin&amp;rsquo;s collision is off: its dynamic mode rebuilt everything within 256 m whenever a&#10;region came or went, 265 ms each time. The stream builds its own 64 m squares of&#10;&lt;code&gt;HeightMapShape3D&lt;/code&gt; within 288 m, 1 ms of building a frame. Physics is Jolt, because holes&#10;in Terrain3D hang Godot&amp;rsquo;s built-in physics.&lt;/p&gt;&#10;&lt;p&gt;A bake writes one image of the whole world&amp;rsquo;s heights beside the regions. It answers height&#10;queries where nothing is loaded and feeds the map and the far ground: six square grids of&#10;128 by 128 that follow the camera, 16 m between vertices in the first and double in each&#10;next, out to 32 km. One &lt;code&gt;view_distance&lt;/code&gt; of 12 km sets the fog&amp;rsquo;s density to 3 over it and&#10;the camera&amp;rsquo;s far plane to 2.5 times it.&lt;/p&gt;&#10;&lt;p&gt;Placed things stream in 64 m chunks, built within 160 m and freed beyond 224 m. At the&#10;start only what is near is built: 0.3 s for an 8 km test world with 8,000 things. A save&#10;keeps only what the party changed, so a new game&amp;rsquo;s is 51 KB.&lt;/p&gt;&#10;&lt;h2 id="why-were-the-rivers-dry-at-every-join"&gt;Why were the rivers dry at every join?&lt;/h2&gt;&#10;&lt;p&gt;Every piece of river faded to nothing over 3.5 m at both ends, so two pieces met at a&#10;strip of dry bed.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/water-snapshots-before-after.jpg"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/water-snapshots-before-after_hu_3b8c15bd825b6216.jpg" alt="Four pairs of in-game views, before on the left and after on the right. A bar of stones across a river is gone, a dry strip across a bend is gone but leaves a seam, a stepped join is still stepped, and a stream that ended in a pan of stones now runs on." width="1440" height="1620" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;Four snapshots and the same views after the fix. The third is still wrong.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;&lt;code&gt;river_stream.gd&lt;/code&gt; cuts each reach into pieces of 14 points. A piece becomes a&#10;&lt;code&gt;WaterCourse&lt;/code&gt;, a &lt;code&gt;Path3D&lt;/code&gt; whose script builds a sheet of water fitted to the bed, when it&#10;is within 420 m and its ground is loaded. It is freed beyond 640 m.&lt;/p&gt;&#10;&lt;p&gt;The agent fixing the water can&amp;rsquo;t see my screen, so F3 saves the frame and a JSON file&#10;beside it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="s2"&gt;&amp;#34;lake&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nt"&gt;&amp;#34;level&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;169.18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nt"&gt;&amp;#34;metres&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;576.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nt"&gt;&amp;#34;sheet&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;159&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nt"&gt;&amp;#34;text&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;lake sheet 159 at level 169.18 m, 576.2 m away&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="s2"&gt;&amp;#34;mode&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;flying&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="s2"&gt;&amp;#34;note&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="s2"&gt;&amp;#34;number&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="s2"&gt;&amp;#34;over_ground&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.11&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="s2"&gt;&amp;#34;pace&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;36 m/s&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="s2"&gt;&amp;#34;pitch&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;-14.17&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="s2"&gt;&amp;#34;position&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="mf"&gt;-2917.28&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="mf"&gt;169.92&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="mf"&gt;-2569.49&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="s2"&gt;&amp;#34;region&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="mi"&gt;-6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="mi"&gt;-6&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="s2"&gt;&amp;#34;reproduce&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;tools/gd.sh shots world/experiments/explore/explore.tscn -- --at=-2917.28,169.92,-2569.49,33.63,-14.17 --hour=9.02 --shot=again.png&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="s2"&gt;&amp;#34;river&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nt"&gt;&amp;#34;has_water&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nt"&gt;&amp;#34;kind&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;river&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nt"&gt;&amp;#34;metres&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;13.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nt"&gt;&amp;#34;reach&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;382&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nt"&gt;&amp;#34;text&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;river reach 382, 12.0 m wide, 13.5 m away, water built&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nt"&gt;&amp;#34;width&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;12.0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each of my 24 pictures came with a reach id and a command that retakes the view with no&#10;window. An agent sorted them into seven kinds of fault and fixed the causes:&lt;/p&gt;&#10;&lt;table&gt;&#10; &lt;thead&gt;&#10; &lt;tr&gt;&#10; &lt;th&gt;Fault&lt;/th&gt;&#10; &lt;th&gt;Cause&lt;/th&gt;&#10; &lt;th&gt;Fix&lt;/th&gt;&#10; &lt;/tr&gt;&#10; &lt;/thead&gt;&#10; &lt;tbody&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;Dry bar across a river&lt;/td&gt;&#10; &lt;td&gt;each piece faded at both ends&lt;/td&gt;&#10; &lt;td&gt;an end that meets more water doesn&amp;rsquo;t fade&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;Sheets crossing at a join&lt;/td&gt;&#10; &lt;td&gt;every stream ran to the join&amp;rsquo;s middle&lt;/td&gt;&#10; &lt;td&gt;the widest runs on, a tributary stops at its bank&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;River ends in a pan of stones&lt;/td&gt;&#10; &lt;td&gt;a spring starting at full width&lt;/td&gt;&#10; &lt;td&gt;its bed grows from nothing over about 30 m&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;Rectangular lakes&lt;/td&gt;&#10; &lt;td&gt;drawn as the generator&amp;rsquo;s 16 m cells&lt;/td&gt;&#10; &lt;td&gt;outline flood-filled on the painted ground at 4 m&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td&gt;Water wider than its bed&lt;/td&gt;&#10; &lt;td&gt;bed 0.5 m deep under 0.45 m of water&lt;/td&gt;&#10; &lt;td&gt;bed cut 0.75 to 1.8 m, banks sloped over 2 to 4 m&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;By the agent&amp;rsquo;s own count 12 of the 24 are fixed and 9 are better, and in a survey of 64&#10;other spots dry stretches went from 31 to 4. Seams and stepped banks are left.&lt;/p&gt;&#10;&lt;h2 id="why-didnt-the-river-match-the-lake"&gt;Why didn&amp;rsquo;t the river match the lake?&lt;/h2&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/river-lake-color-before-after.jpg"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-08-generating-an-island-procedurally/river-lake-color-before-after_hu_a073bebd0a5edbb.jpg" alt="Before and after pairs of the map, the river mouth, the lake and a pond. Before, a dark slate river meets a pale cyan lake with a visible step. After, all of it is one blue." width="1440" height="2025" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;Left, the river lit from underneath. Right, wound the right way and set to the river&amp;#39;s blue.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;The river&amp;rsquo;s triangles were wound back to front. Godot turns a back face&amp;rsquo;s normal round, so&#10;the river was lit from underneath and came out darker than the lake under the same shader.&#10;The fix is the index order in &lt;code&gt;water_course.gd&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append_array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Wound right, the river turned the lake&amp;rsquo;s pale cyan. I liked the wrong color, so all water&#10;is now the blue the river had.&lt;/p&gt;&#10;&lt;h2 id="also-done"&gt;Also done&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;One approved model now comes out in five themes by four states of decay: twenty barrels,&#10;then twenty orcs on the same 18 bones. The orcs were 59 MB because every texture was&#10;stored twice, in the &lt;code&gt;.glb&lt;/code&gt; and in a copy Godot wrote out. They are 15 MB.&lt;/li&gt;&#10;&lt;li&gt;The island is three times the size I meant. I&amp;rsquo;d read Skyrim as 16 square km when it is&#10;about 16 square miles (37 to 41 square km), and the Wishbone&amp;rsquo;s land is 121.&lt;/li&gt;&#10;&lt;li&gt;Seven test areas of the island are painted, one with crimson meat ground.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Next is the island again in a 10.2 km square, about 47 square km of land.&lt;/p&gt;&#10;</description></item><item><title>Modeling and rigging an orc in Blender with Python scripts</title><link>https://birdoggydogs-builds.pages.dev/posts/2026-10-07-modeling-an-orc-in-blender-with-python/</link><guid isPermaLink="true">https://birdoggydogs-builds.pages.dev/posts/2026-10-07-modeling-an-orc-in-blender-with-python/</guid><pubDate>Wed, 07 Oct 2026 20:00:00 -0700</pubDate><category>blobber</category><category>blender</category><category>art</category><category>godot</category><description>&lt;p&gt;Blobber&amp;rsquo;s walking monsters are an orc now: 2.04 m tall, 7,999 triangles, 18 bones, five&#10;clips. An agent built him by writing Python against Blender&amp;rsquo;s API and running Blender&#10;headless, with no modelling by hand and no MCP connector. The script is the source, and&#10;the &lt;code&gt;.blend&lt;/code&gt; and &lt;code&gt;.glb&lt;/code&gt; are rebuilt from it.&lt;/p&gt;&#10;&lt;h2 id="building-the-body-from-a-stick-figure-and-a-skin-modifier"&gt;Building the body from a stick figure and a Skin modifier&lt;/h2&gt;&#10;&lt;p&gt;The body starts as 20 points joined by edges, each point with a radius.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-07-modeling-an-orc-in-blender-with-python/orc-first-and-second.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-07-modeling-an-orc-in-blender-with-python/orc-first-and-second_hu_fde425820e094de2.png" alt="Two rows of renders of an orc from the front, three-quarter, side and back, with a close-up of the face. The top row, labelled first attempt, is an upright green orc with cracked scaly skin. The bottom row, labelled second attempt, is a lopsided, sagging orc with a bigger cleaver, teeth and a yellow ear tag." width="1440" height="891" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;Top: the first orc. Bottom: the second, from a written brief with one arm huge and one withered.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;Blender&amp;rsquo;s Skin modifier wraps a tube round every edge at the radius of its ends, and a&#10;Subdivision modifier at level 2 rounds it off. The points are a Python dictionary at the&#10;top of the generator, so the proportions are numbers: the right elbow has a radius of&#10;0.165 m and the left 0.095 m. This is from &lt;code&gt;tools/blender/SK_creature_orc/gen_orc.py&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;me&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bpy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meshes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;skin&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;me&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from_pydata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;stick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bpy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;skin&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;me&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;stick&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modifiers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Skin&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;SKIN&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;me&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;skin_vertices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;radius&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;me&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;skin_vertices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;use_root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;pelvis&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;stick&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modifiers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Sub&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;SUBSURF&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;levels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Every vertex is then pushed along its normal by two scales of &lt;code&gt;mathutils.noise&lt;/code&gt;, 2.2 cm&#10;and 0.7 cm, so nothing is a clean tube. Chest sacks, jowls, tusks and boils are separate&#10;UV spheres and cones sunk into the surface at &lt;code&gt;body.closest_point_on_mesh()&lt;/code&gt;. The hip&#10;wrap is a copy of the body&amp;rsquo;s own faces, pushed out 2.5 cm with &lt;code&gt;bmesh&lt;/code&gt;. Color is a&#10;vertex attribute, and its alpha marks flesh against cloth and metal.&lt;/p&gt;&#10;&lt;p&gt;The first orc&amp;rsquo;s skin looked like cracked mud because Voronoi cell edges were used as&#10;creases at full strength. The generator now mixes them in at 14% and adds a second&#10;Voronoi, stretched 3.6 times along the height, for folds that lie across the body.&lt;/p&gt;&#10;&lt;h2 id="why-did-the-feet-and-hands-look-wrong"&gt;Why did the feet and hands look wrong?&lt;/h2&gt;&#10;&lt;p&gt;Feet are now solid wedges and each hand is a small stick figure of its own.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-07-modeling-an-orc-in-blender-with-python/orc-hands-and-feet.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-07-modeling-an-orc-in-blender-with-python/orc-hands-and-feet_hu_8ddd65c0811056aa.png" alt="Six renders of the orc after the fourth round: the front and three-quarter views, a close-up of both feet, the open left hand from the front and from behind, and the right fist closed around the cleaver’s handle." width="1440" height="1403" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;Each foot is one wedge with three toes on it. Each hand has three fingers and a thumb.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;A Skin limb ends in a round cap close to its last vertex. The first legs ran on into the&#10;toes, so the toes floated past the cap and left a gap in front of the heel. Now the leg&#10;ends straight down under the ankle and the foot is overlapping spheres on the ground: a&#10;flattened one from heel to ball, one for the heel, and three toes sunk into the front&#10;edge.&lt;/p&gt;&#10;&lt;p&gt;The hands were loose balls with a thumb in the middle of the palm. Each is now skinned&#10;like the body: a root 20 cm up inside the forearm, three knuckles fanned across the&#10;palm, three joints a finger, and a thumb that leaves low on the index side. Each finger&#10;joint has a curl angle. 14, 26 and 22 degrees is the slack left hand. 72, 92 and 68 is&#10;the fist on the cleaver. Three fingers, because four melted into a mitten at this size.&lt;/p&gt;&#10;&lt;p&gt;The Decimate modifier fused the fingers too. The body is cut first, to 8,000 triangles&#10;less the hands&amp;rsquo; count, and the hands are joined after.&lt;/p&gt;&#10;&lt;h2 id="how-is-the-knowledge-base-set-up"&gt;How is the knowledge base set up?&lt;/h2&gt;&#10;&lt;p&gt;It&amp;rsquo;s 2.3 GB of free reference in a folder outside the game&amp;rsquo;s repository, sorted so that&#10;an agent can find a page with a text search.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;kb/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; INDEX.md order of authority, and a table of &amp;#34;question -&amp;gt; start here&amp;#34;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 00_policy/ 12 core rules, which sources are allowed, licences&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 01_blender/ modeling topology sculpting uv texturing materials shaders&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; rigging animation geometry_nodes python asset_browser&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 02_gltf/ the specification by chapter&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 03_godot/ import meshes materials animation foliage terrain lod ...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 04_game_art/ notes by asset class (creatures, props, trees ...)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 05_pipeline/ the twelve steps, naming, manifests, checks, export&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 06_style/ Blobber&amp;#39;s look: camera, silhouettes, palettes&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 07_project/ budgets by class, approved assets, rejected patterns&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 99_examples/ good, bad, before and after, with pictures&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; _sources/ the raw downloads&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; _tools/ build_tree.py&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;_sources/&lt;/code&gt; holds the Blender 5.2 manual twice (the HTML with figures, and 2,167 pages as&#10;plain text), the Python API reference as HTML, Godot&amp;rsquo;s documentation as its &lt;code&gt;.rst&lt;/code&gt; source,&#10;the glTF 2.0 specification, Wikipedia articles and six example repositories with their&#10;binaries stripped. &lt;code&gt;_sources/books/&lt;/code&gt; is empty: the agent wouldn&amp;rsquo;t pirate commercial&#10;books, and the policy page says a book goes in when I buy it.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;_tools/build_tree.py&lt;/code&gt; sorts those into the numbered folders. It has a table of path&#10;prefixes, and the first match wins: a manual page under &lt;code&gt;modeling/meshes/uv&lt;/code&gt; goes to&#10;&lt;code&gt;01_blender/uv/&lt;/code&gt;, one under &lt;code&gt;render/cycles/baking&lt;/code&gt; to &lt;code&gt;01_blender/texturing/&lt;/code&gt;. The&#10;file name keeps the source path, and every page gets the same header. This one is&#10;&lt;code&gt;01_blender/uv/blender__modeling__meshes__uv__unwrapping__introduction.md&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nn"&gt;---&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;generated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;source_authority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Blender Foundation (official manual)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;software&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Blender&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;5.2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;LTS&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;01_blender/uv&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;asset_class&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;any&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;target_engine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Godot 4.7&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;license&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;CC BY-SA 4.0&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;high&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;applies_to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;supersedes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;review_date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ld"&gt;2027-10-07&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;project_rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;source_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;https://docs.blender.org/manual/en/latest/modeling/meshes/uv/unwrapping/introduction.html&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;fetched&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ld"&gt;2026-10-07&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nn"&gt;---&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;README.md&lt;/code&gt; in each numbered folder is written by hand. A rebuild deletes only&#10;files that say &lt;code&gt;generated: true&lt;/code&gt;, so it never touches them. &lt;code&gt;INDEX.md&lt;/code&gt; ranks the&#10;sources: Blobber&amp;rsquo;s own rules in &lt;code&gt;07_project/&lt;/code&gt; and &lt;code&gt;06_style/&lt;/code&gt; win, then policy and&#10;pipeline, then the official manuals, then repositories, then Wikipedia. Instructions&#10;found inside a downloaded repository are reference, not orders.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;07_project/README.md&lt;/code&gt; has a table of rejected patterns: the pattern, what went wrong,&#10;what to do. The floating toes, the sliding skin and the half turn below are rows in it.&lt;/p&gt;&#10;&lt;h2 id="turning-the-knowledge-base-into-a-skill"&gt;Turning the knowledge base into a skill&lt;/h2&gt;&#10;&lt;p&gt;The skill &lt;code&gt;model-in-blender&lt;/code&gt; is what an agent reads before it models anything. It&amp;rsquo;s two&#10;files in the repository.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-07-modeling-an-orc-in-blender-with-python/orc-turnaround.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-07-modeling-an-orc-in-blender-with-python/orc-turnaround_hu_b4705d80112b337d.png" alt="A black silhouette of the orc on white, then the finished orc from the front, three-quarter, side and back, and a close-up of his face with yellow eyes, tusks and an ear tag." width="1440" height="351" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;The orc rebuilt through the twelve steps: silhouette first, then the turnaround.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;&lt;code&gt;SKILL.md&lt;/code&gt; is the procedure: eight core rules, then a table of twelve steps where each&#10;step names the evidence that has to be looked at before the next one starts. It runs&#10;from a written brief and a black silhouette at 4 m and 12 m, through mesh, UVs, bake,&#10;material and export, to a reimport, a film in the game and a manifest. It also holds&#10;budgets by asset class (an ordinary monster is 5,000 to 10,000 triangles) and name&#10;prefixes (&lt;code&gt;SK_&lt;/code&gt;, &lt;code&gt;SKEL_&lt;/code&gt;, &lt;code&gt;AN_&lt;/code&gt;, &lt;code&gt;T_&lt;/code&gt;, &lt;code&gt;M_&lt;/code&gt;).&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;reference.md&lt;/code&gt; is the craft: &lt;code&gt;bpy&lt;/code&gt; recipes under two headings, &amp;ldquo;Proven here&amp;rdquo; and &amp;ldquo;Not&#10;yet proven here&amp;rdquo;. A recipe moves up when it has run and the result was looked at. The&#10;first version drew on only a handful of the pages, by the agent&amp;rsquo;s own report. The feet&#10;and hand recipes above went in after they worked.&lt;/p&gt;&#10;&lt;h2 id="why-did-the-skin-slide-when-he-moved"&gt;Why did the skin slide when he moved?&lt;/h2&gt;&#10;&lt;p&gt;The skin was worked out from positions in space, so when a bone moved the body went&#10;through its own texture.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-07-modeling-an-orc-in-blender-with-python/orc-textures-and-play-size.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-07-modeling-an-orc-in-blender-with-python/orc-textures-and-play-size_hu_d3b838b66951ad95.png" alt="A sheet with the orc at the size he has on screen at 4 m and 12 m, the orc in a checker pattern, three square texture images (a mottled green color map, a blue normal map and an orange and green packed map), and close-ups of the left hand, the fist on the cleaver and the feet." width="1440" height="385" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;Bottom left: the checker render and the three baked 1024 textures, cut into 576 small islands.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;The material&amp;rsquo;s noise and Voronoi nodes read the Texture Coordinate node&amp;rsquo;s Object output.&#10;The fix is to bake in the rest pose, before the rig exists. &lt;code&gt;bpy.ops.uv.smart_project&lt;/code&gt;&#10;unwraps at an angle limit of 78 degrees, islands near the head are scaled 1.6 times, and&#10;Cycles bakes three 1024 images on the CPU: base color, normal, and one with occlusion,&#10;roughness and metallic in its red, green and blue, which is how glTF wants them. Base&#10;color and the metal mask are baked through emission, because a diffuse bake of metal&#10;comes out black. A second material that only reads the three images replaces the first.&lt;/p&gt;&#10;&lt;p&gt;Smart Project cuts a body made of overlapping parts into 576 islands, so the skin is&#10;softer than it should be. That needs seams placed by the generator, part by part.&lt;/p&gt;&#10;&lt;h2 id="rigging-and-animating-without-touching-a-bone"&gt;Rigging and animating without touching a bone&lt;/h2&gt;&#10;&lt;p&gt;He has 18 bones and five clips at 24 frames a second: idle 72 frames, walk 32, attack&#10;34, hit 16 and death 56.&lt;/p&gt;&#10;&lt;figure class="shot clip"&gt;&#10; &lt;video controls playsinline preload="none" muted poster="https://birdoggydogs-builds.pages.dev/posts/2026-10-07-modeling-an-orc-in-blender-with-python/orc-animations.png"&gt;&#10; &lt;source src="https://birdoggydogs-builds.pages.dev/posts/2026-10-07-modeling-an-orc-in-blender-with-python/orc-animations.mp4" type="video/mp4"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-07-modeling-an-orc-in-blender-with-python/orc-animations.mp4"&gt;Download the clip (mp4)&lt;/a&gt;&#10; &lt;/video&gt;&lt;figcaption&gt;Idle, walk, overhead chop, struck and death, rendered in Blender after the bake.&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;p&gt;The bones use the stick figure&amp;rsquo;s own points: hips, spine, chest, head and jaw, an upper&#10;arm, forearm, hand, thigh, shin and foot on each side, and a belly bone off the spine so&#10;that the gut lags behind the hips.&lt;/p&gt;&#10;&lt;p&gt;Blender&amp;rsquo;s automatic weights fail on overlapping shells, so &lt;code&gt;rig.py&lt;/code&gt; does its own. Rigid&#10;parts (teeth, tusks, cleaver, hands, feet) go wholly into their bone&amp;rsquo;s vertex group&#10;before the join. Every other vertex is weighted by distance to each bone&amp;rsquo;s segment,&#10;measured in that bone&amp;rsquo;s radius, so that a thick trunk outvotes a thin arm beside it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seg_dist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;co&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mf"&gt;1e-4&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;soft&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;tot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;tot&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.04&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;orc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vertex_groups&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;tot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;REPLACE&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A key is a frame, a dictionary such as &lt;code&gt;{&amp;quot;chest&amp;quot;: &amp;quot;X-14 Z-12&amp;quot;}&lt;/code&gt; and an offset for the&#10;hips. The turns are degrees about the model&amp;rsquo;s own axes, converted to the bone&amp;rsquo;s space as&#10;&lt;code&gt;rest.inverted() @ q @ rest&lt;/code&gt;. The walk is a function of phase, sampled every 4 frames:&#10;the thigh gets &lt;code&gt;-26 * cos(phase) - 2&lt;/code&gt; degrees. Each action sits on its own NLA track&#10;named like &lt;code&gt;AN_orc_walk-loop&lt;/code&gt;, and the exporter runs in &lt;code&gt;NLA_TRACKS&lt;/code&gt; mode with&#10;&lt;code&gt;export_optimize_animation_size=True&lt;/code&gt;, which drops tracks for bones a clip never moves.&lt;/p&gt;&#10;&lt;h2 id="checking-the-exported-file-not-the-blender-file"&gt;Checking the exported file, not the Blender file&lt;/h2&gt;&#10;&lt;p&gt;Three scripts check what was exported, and one checks the joins.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;validate_glb.py&lt;/code&gt; is plain Python. It parses the &lt;code&gt;.glb&lt;/code&gt; with &lt;code&gt;struct&lt;/code&gt; and ran 22 checks&#10;on the orc: triangles against the budget, normals and UVs on every mesh, the lowest&#10;point on y = 0, height within 1% of the brief, skin weights that sum to one with at most&#10;four a vertex, no bone without influence, no unused track in any clip, both loops&#10;closing on their first pose, textures a power of two, opaque materials and the name&#10;prefixes. It writes &lt;code&gt;manifest.json&lt;/code&gt; with every result. &lt;code&gt;roundtrip.py&lt;/code&gt; imports the file&#10;into an empty Blender scene and compares counts with the manifest. A small Godot scene&#10;loads it and checks the skeleton, the texture slots and the loop flags.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;join_check.py&lt;/code&gt; exists because the hands were held to the arms by nothing and no render&#10;showed it. Before the join it tags every vertex with its part. Then, at rest and at&#10;chosen frames of every clip, it builds a BVH tree for each part and counts vertices of&#10;one inside another by ray casting, and faces that cut through. A join is solid at 2 for&#10;a part under 60 vertices and 6 for a larger one, and every part needs a chain of solid&#10;joins back to the body.&lt;/p&gt;&#10;&lt;h2 id="why-did-he-walk-into-the-game-backwards"&gt;Why did he walk into the game backwards?&lt;/h2&gt;&#10;&lt;p&gt;In the first film from the game the orc walked in with his back to the party.&lt;/p&gt;&#10;&lt;p&gt;Blender models face -Y with Z up. The exporter&amp;rsquo;s &lt;code&gt;export_yup&lt;/code&gt; turns that into Y up&#10;facing +Z, which is what glTF asks, and &lt;code&gt;Monster.face()&lt;/code&gt; already turned +Z toward its&#10;target:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;_model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rotation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;atan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_point&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_point&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;But the game was built round a bought alien model that faced -Z, so a constant&#10;&lt;code&gt;MODEL_YAW_OFFSET_DEG := 180.0&lt;/code&gt; was added to every monster and saved into every body and&#10;thrall. The orc faces +Z, and the half turn pointed his back at me. The test missed it&#10;because it checked which way the file faced. It now turns a monster toward three points&#10;with &lt;code&gt;face()&lt;/code&gt; and wants the dot product of his front and the direction over 0.95.&lt;/p&gt;&#10;&lt;p&gt;The constant is gone, and save version 8 subtracts pi from each saved yaw. The rule went&#10;into a second skill, &lt;code&gt;model-into-game&lt;/code&gt;: a model that faces the wrong way is fixed in its&#10;file, never in the game.&lt;/p&gt;&#10;&lt;h2 id="why-did-an-idle-animation-change-who-got-hit"&gt;Why did an idle animation change who got hit?&lt;/h2&gt;&#10;&lt;p&gt;Each monster starts its idle at a random offset so that a pack doesn&amp;rsquo;t breathe in step.&#10;That was a call to &lt;code&gt;randf()&lt;/code&gt;, which draws from the same random stream as the combat&#10;rules. The tests seed that stream, so one extra draw per monster changed who got hit in&#10;a seeded fight and the fight log test failed. The offset now comes from the node&amp;rsquo;s&#10;instance id, in &lt;code&gt;scripts/monster_anim.gd&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# A pack does not breathe, or flap, in step. The offset comes from which node this is, not&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# from the game&amp;#39;s dice: looks must never use up a roll that a rule was going to make.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;_player&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seek&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;get_instance_id&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;997&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;997.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;_player&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_animation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="also-done"&gt;Also done&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;A dead orc lies flat. A function in &lt;code&gt;rig.py&lt;/code&gt; prints the highest point of each part at&#10;the last death frame: his wrists were 45 cm off the floor and his feet 65 cm. Now&#10;they&amp;rsquo;re at about 25 cm and 14 cm.&lt;/li&gt;&#10;&lt;li&gt;The flyers are a harpy, built on a shared kit that holds the steps above as functions.&#10;Its wing roots opened a gap when it flapped, and the join check had exempted wings as&#10;open sheets. They aren&amp;rsquo;t exempt now. Every new model has to use the kit.&lt;/li&gt;&#10;&lt;li&gt;The game stands in a streamed 2 km world scene on Terrain3D terrain, and moved to Jolt&#10;physics, because holes in the terrain hung under Godot&amp;rsquo;s default physics.&lt;/li&gt;&#10;&lt;li&gt;A private page for drawing dungeons on my phone, and an importer that turns a sheet&#10;into a playable dungeon.&lt;/li&gt;&#10;&lt;li&gt;Keys are individual items tied to the locks they open, with lock picking. A badly&#10;failed pick can jam the lock for good, but its key still opens it.&lt;/li&gt;&#10;&lt;li&gt;Dungeons have varied heights and widths, and the party can crouch.&lt;/li&gt;&#10;&lt;li&gt;The whole interface was re-themed as a rack of synthesizer modules.&lt;/li&gt;&#10;&lt;li&gt;Rivers, waterfalls and lakes as the game&amp;rsquo;s own water.&lt;/li&gt;&#10;&lt;li&gt;An 8 km world experiment, where the game never finished starting.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>Running turn-based combat on a real-time clock in Godot</title><link>https://birdoggydogs-builds.pages.dev/posts/2026-10-06-running-agents-overnight/</link><guid isPermaLink="true">https://birdoggydogs-builds.pages.dev/posts/2026-10-06-running-agents-overnight/</guid><pubDate>Tue, 06 Oct 2026 20:00:00 -0700</pubDate><category>blobber</category><category>agents</category><category>testing</category><category>combat</category><category>tooling</category><description>&lt;p&gt;Blobber has a turn-based mode now, and it has no rules of its own: it stops the game&amp;rsquo;s one&#10;clock and steps it by hand. It was one of nine branches that landed by 10:27, built&#10;overnight and through the morning by AI agents working three at a time.&lt;/p&gt;&#10;&lt;h2 id="running-turn-based-mode-on-the-real-time-clock"&gt;Running turn-based mode on the real-time clock&lt;/h2&gt;&#10;&lt;p&gt;Enter switches a fight to turn-based. The clock stops while anyone in the party is ready&#10;to act, and a strip at the top shows who goes next.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-06-running-agents-overnight/turn-based-stills.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-06-running-agents-overnight/turn-based-stills.png" alt="Twelve captioned stills from one fight against two blocky orange monsters. It starts in real time, then switches to turn-based with an order strip along the top of the screen. The spell book opens on a caster’s turn, green spell clouds land, and a record screen lists each blow." width="1280" height="2160" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;The same fight in both modes. The strip at the top is the order, soonest first.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;I wanted real time with a turn-based mode from the first message of the project, and I&#10;didn&amp;rsquo;t want a second set of combat rules to keep in step with the first.&lt;/p&gt;&#10;&lt;p&gt;The autoload &lt;code&gt;GameClock&lt;/code&gt; (&lt;code&gt;scripts/game_clock.gd&lt;/code&gt;) is the only clock. Its &lt;code&gt;advance(game_seconds)&lt;/code&gt;&#10;adds to &lt;code&gt;play_seconds&lt;/code&gt; and emits the signal &lt;code&gt;advanced(game_seconds)&lt;/code&gt;. Every gameplay timer&#10;counts down in a handler of that signal. A party member has &lt;code&gt;cooldown_remaining&lt;/code&gt;. A monster&#10;has &lt;code&gt;cooldown_remaining&lt;/code&gt;, &lt;code&gt;spell_cooldown_remaining&lt;/code&gt; and &lt;code&gt;cast_remaining&lt;/code&gt;. &amp;ldquo;Ready&amp;rdquo; means&#10;&lt;code&gt;cooldown_remaining &amp;lt;= 0.0&lt;/code&gt;, and acting sets it back to the action&amp;rsquo;s recovery time. In real&#10;time the clock&amp;rsquo;s &lt;code&gt;_process()&lt;/code&gt; calls &lt;code&gt;advance(delta * time_scale)&lt;/code&gt; every frame.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;TurnMode&lt;/code&gt; (&lt;code&gt;scripts/turns/turn_mode.gd&lt;/code&gt;, a plain &lt;code&gt;Node&lt;/code&gt; the level makes) sets&#10;&lt;code&gt;GameClock.running = false&lt;/code&gt; and calls &lt;code&gt;advance()&lt;/code&gt; itself, once per physics step. This is&#10;the whole decision, from its &lt;code&gt;_physics_process()&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="c1"&gt;# How much game time this step is worth, never past the next thing due.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;_party_moving&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MOVE_RATE&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="nf"&gt;acting&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;playback_rate&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;minf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;next_event_in&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;GameClock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stepped_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;Projectile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flush_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;playback_rate&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;_step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;_everybody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;everybody&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;GameClock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The rate is 0 while a member is ready, so the game waits. It is &lt;code&gt;MOVE_RATE&lt;/code&gt; (1.0) while a&#10;movement key is down or the party is in the air. Turning and looking are free. With nobody&#10;ready it is &lt;code&gt;playback_rate&lt;/code&gt;, 3.0 game seconds a real second.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;next_event_in()&lt;/code&gt; returns the smallest timer still running: a member&amp;rsquo;s recovery, a queued&#10;second hit, a hunting monster&amp;rsquo;s recovery or its spell wind-up. Cutting the step to that&#10;stops the clock exactly on a ready time, never past it.&lt;/p&gt;&#10;&lt;p&gt;Monsters don&amp;rsquo;t know the mode exists. They already scaled their movement by&#10;&lt;code&gt;GameClock.rate()&lt;/code&gt;, which now reads:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;time_scale&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;running&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;stepped_rate&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So whatever moves by &lt;code&gt;rate()&lt;/code&gt; moves exactly as far as the step. Shots flew on real time&#10;before, and now use &lt;code&gt;rate()&lt;/code&gt; in both modes. Traps sense only while &lt;code&gt;rate() &amp;gt; 0&lt;/code&gt;. One&#10;exception: &lt;code&gt;Projectile.flush_rate&lt;/code&gt; lets shots already in the air fly on while the clock is&#10;held, so each lands in the turn it was fired in.&lt;/p&gt;&#10;&lt;p&gt;The order inside one physics step is set with &lt;code&gt;process_physics_priority&lt;/code&gt;. &lt;code&gt;TurnMode&lt;/code&gt; runs&#10;at -100 (keys, then the clock), shots at -50, a small child node at -25 (members the step&#10;made ready act), then the party and monsters at 0.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;acting()&lt;/code&gt; picks whose turn it is: the member who is up, ready, and has been ready longest&#10;(&lt;code&gt;_due_at&lt;/code&gt;, the &lt;code&gt;play_seconds&lt;/code&gt; when their recovery ended). F calls &lt;code&gt;Party.attack(i)&lt;/code&gt; for&#10;that member, the same function hold-F runs for everyone in real time. Q waits: it sets&#10;their recovery to end 0.05 seconds after the next actor is ready. G lets every member act&#10;as they recover until each has gone once.&#10;The strip is &lt;code&gt;order()&lt;/code&gt;: members who are up and monsters hunting the party, each with the&#10;game seconds until ready, sorted soonest first. &lt;code&gt;TurnHud&lt;/code&gt;, a &lt;code&gt;CanvasLayer&lt;/code&gt;, shows the&#10;first five.&lt;/p&gt;&#10;&lt;p&gt;At 3x a monster&amp;rsquo;s 4 second recovery takes about 1.3 real seconds. Whether that&amp;rsquo;s the right&#10;pace is still open on the work list. It&amp;rsquo;s a setting, &lt;code&gt;gameplay/playback_rate&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h2 id="why-did-one-test-miss-its-shot-2-times-in-60"&gt;Why did one test miss its shot 2 times in 60?&lt;/h2&gt;&#10;&lt;p&gt;Every test now runs on seeded dice, takes at most one physics step per frame, and aims at&#10;its target. Nothing the player sees changed, so there&amp;rsquo;s no picture.&lt;/p&gt;&#10;&lt;p&gt;Every test scene extends &lt;code&gt;tests/harness_base.gd&lt;/code&gt;, and its &lt;code&gt;_ready()&lt;/code&gt; now runs this before&#10;the test:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_make_repeatable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;dice&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;seed&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;seed&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;seed: &amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nb"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;MonsterSize&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dice&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;WeaponRules&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dice&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;MonsterLoot&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dice&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;Afflictions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dice&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="c1"&gt;# Never more than one physics step to a frame. A frame that took a long time (a busy&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="c1"&gt;# machine) would otherwise be made up with up to eight steps at once, and the clock with&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="c1"&gt;# them: a wait of three frames could be anything from none to 0.4 s of play. This way a&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="c1"&gt;# slow machine runs the game slowly instead of in jumps. (Recording a film steps the game&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="c1"&gt;# at a fixed rate and is not affected.)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nc"&gt;Engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_physics_steps_per_frame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;seed()&lt;/code&gt; covers Godot&amp;rsquo;s global generator. Four rules roll on a &lt;code&gt;RandomNumberGenerator&lt;/code&gt; of&#10;their own, kept as a &lt;code&gt;static var rng&lt;/code&gt;, and each is seeded by name. Running a test with&#10;&lt;code&gt;-- --seed=N&lt;/code&gt; gives it other dice, which finds a check that only passes on luck.&lt;/p&gt;&#10;&lt;p&gt;The failing check was &amp;ldquo;the party&amp;rsquo;s shot lands&amp;rdquo;, and three faults were stacked under it.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The test read the floor height as &lt;code&gt;party.position.y&lt;/code&gt; after boot. The party wakes on a&#10;platform 0.8 m up, so every later placement left it standing in the air.&lt;/li&gt;&#10;&lt;li&gt;It called &lt;code&gt;queue_free()&lt;/code&gt; on one monster and spawned the next on the same spot in the same&#10;frame. A freed node stays solid until the frame ends, and the new monster was pushed onto&#10;its head, about 1.6 m up. The fix is to wait two frames.&lt;/li&gt;&#10;&lt;li&gt;It turned the party toward the monster and set &lt;code&gt;party.pitch = 0.0&lt;/code&gt;. Monster heights had&#10;been random since the day before (this type from 2.2 to 3.6 m), and a level shot from the&#10;air went over a short one. Both misses in 60 runs had a monster under 2.4 m.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Tests now aim with this helper, which tilts the view from the party&amp;rsquo;s eye height (1.6 m):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;aim_at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nf"&gt;face&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;party&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;global_position&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;Vector3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UP&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;party&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;eye_height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;party&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pitch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;atan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Vector2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The quest builder had a real race. Every time it opens, &lt;code&gt;_sweep()&lt;/code&gt; in&#10;&lt;code&gt;scripts/builder/builder_model.gd&lt;/code&gt; deletes any other process&amp;rsquo;s scratch folder that has no&#10;&lt;code&gt;stamp.txt&lt;/code&gt;. But &lt;code&gt;_write_scratch()&lt;/code&gt; makes the directories first and writes the stamp after.&#10;Open the builder twice at once and one copy deletes the other&amp;rsquo;s folder. Eight copies of the&#10;builder tests at once failed 11 of 24 runs. The tests now build their folder to one side&#10;with the stamp already in it, then move it into place with &lt;code&gt;DirAccess.rename_absolute()&lt;/code&gt;,&#10;so it never exists without a stamp. The builder itself still makes the folder first.&lt;/p&gt;&#10;&lt;p&gt;The agent on this ran the suite ten times in a row and three times with two copies at&#10;once: 608 scenario runs, 0 failures, by its own count. One failure it could never&#10;reproduce, so its cause is unknown.&lt;/p&gt;&#10;&lt;h2 id="putting-every-key-and-button-behind-one-input-table"&gt;Putting every key and button behind one input table&lt;/h2&gt;&#10;&lt;p&gt;The game opens on a main menu now, and Esc or Start pauses it. There are settings, key&#10;rebinding, and a controller layout for every screen.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-06-running-agents-overnight/menus-controller-stills.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-06-running-agents-overnight/menus-controller-stills.png" alt="Twelve stills: an opening screen reading VAT 07 NUTRIENT FEED FAILURE, a save menu with four slots, a settings page with volume sliders, the spell book and map with a small box naming the controller button being pressed, a pause menu titled PROCESS HELD, and a main menu titled BLOBBER." width="1280" height="2160" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;The box at the top right names each controller button the film pressed.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;Rebinding and on-screen hints both need one place that knows what every key does. That is&#10;the autoload &lt;code&gt;Controls&lt;/code&gt; (&lt;code&gt;scripts/input/controls.gd&lt;/code&gt;), built round a constant table,&#10;&lt;code&gt;ACTIONS&lt;/code&gt;, of 155 rows. A row is an id, its name on the rebinding screen, a context, the&#10;default keys and the default controller buttons:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;jump&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Jump&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WORLD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Space,X&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Y&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;interact&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Use / talk&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WORLD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;E&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;A&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;attack&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Attack / act&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WORLD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;F,mouse:1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;RT&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;next_spell&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Next readied spell&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WORLD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;C&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;X&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;next_caster&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Next caster&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WORLD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Shift+C&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A binding is a string: &lt;code&gt;key:Shift+C&lt;/code&gt;, &lt;code&gt;mouse:1&lt;/code&gt;, &lt;code&gt;pad:A&lt;/code&gt;, or &lt;code&gt;pad:LX-&lt;/code&gt; for a stick&#10;direction. &lt;code&gt;_register()&lt;/code&gt; turns each into an &lt;code&gt;InputEventKey&lt;/code&gt;, &lt;code&gt;InputEventJoypadButton&lt;/code&gt; or&#10;&lt;code&gt;InputEventJoypadMotion&lt;/code&gt; and writes it into Godot&amp;rsquo;s &lt;code&gt;InputMap&lt;/code&gt; under the action&amp;rsquo;s id, so&#10;&lt;code&gt;Input.is_action_pressed()&lt;/code&gt; still works underneath. A stick counts as one press when it&#10;crosses 0.5. Only bindings that differ from the defaults are saved, in the &lt;code&gt;bindings&lt;/code&gt;&#10;section of &lt;code&gt;user://settings.cfg&lt;/code&gt; (a &lt;code&gt;ConfigFile&lt;/code&gt;), never in a save.&lt;/p&gt;&#10;&lt;p&gt;The context says which actions are listened for at the same moment. The world and a screen&#10;never are, so R can be Rest in the world and Ready in the spell book. &lt;code&gt;rebind()&lt;/code&gt; takes a&#10;binding away from any action that would clash and returns the losers for the screen to&#10;name.&lt;/p&gt;&#10;&lt;p&gt;Hints are templates. &lt;code&gt;Controls.format(&amp;quot;[{interact}] Open&amp;quot;)&lt;/code&gt; gives &lt;code&gt;[E] Open&lt;/code&gt;, or&#10;&lt;code&gt;[A] Open&lt;/code&gt; once the last input came from a controller. &lt;code&gt;Controls.live(label, template)&lt;/code&gt;&#10;keeps a weak reference to the label and refills it on every rebind or change of device.&lt;/p&gt;&#10;&lt;p&gt;A test keeps it that way. &lt;code&gt;tests/controls.gd&lt;/code&gt; reads every game script outside&#10;&lt;code&gt;scripts/input/&lt;/code&gt; with a regular expression for &lt;code&gt;KEY_&lt;/code&gt;, &lt;code&gt;JOY_BUTTON_&lt;/code&gt;,&#10;&lt;code&gt;Input.is_action_pressed&lt;/code&gt; and the like, and fails on any match that isn&amp;rsquo;t one of five&#10;listed exceptions.&lt;/p&gt;&#10;&lt;p&gt;A controller has too few buttons for this game. The D-pad opens four screens, and holding&#10;LB shows a wheel of eight actions, each fired as if its key had been pressed.&lt;/p&gt;&#10;&lt;p&gt;Nobody has held a controller to it. The film and the tests build controller events and&#10;feed them to the engine, from &lt;code&gt;tests/pad_base.gd&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_pad_button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;JoyButton&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pressed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nc"&gt;InputEventJoypadButton&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;button_index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;button&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pressed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pressed&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pressure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pressed&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nc"&gt;Input&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_input_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="chaining-two-save-migrations-that-both-claimed-version-4"&gt;Chaining two save migrations that both claimed version 4&lt;/h2&gt;&#10;&lt;p&gt;Two branches each raised the save file from version 3 to 4: the one for learning spells&#10;one at a time, and the one for XP dropped on a party wipe. Landed, they are versions 4&#10;and 5.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-06-running-agents-overnight/spell-learning-stills.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-06-running-agents-overnight/spell-learning-stills.png" alt="Stills of the spell book with unlearned spells stamped NOT ON FILE, a chest holding procedure sheets, a vendor called the Registrar listing spells to buy, a spell named Recall raising a dead party member, and a dead monster standing back up as an ally." width="1280" height="2520" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;Spells are learned one at a time now. Unknown ones show as blanks.&lt;/figcaption&gt;&lt;/figure&gt;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-06-running-agents-overnight/wipe-xp-fight-log-stills.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-06-running-agents-overnight/wipe-xp-fight-log-stills.png" alt="Stills of a party wipe screen reading 2400 XP UNSPENT, RENDERED DOWN AND LEFT AT THE YARD, the party back at the vat, a second wipe that loses the first drop, a load menu with three autosaves, and a fight log listing each blow by fight." width="1280" height="2160" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;A wipe leaves your unspent XP where you fell. Wipe again first and it is gone.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;A save is &lt;code&gt;user://saves/&amp;lt;slot&amp;gt;.json&lt;/code&gt; with a &lt;code&gt;version&lt;/code&gt; and a dictionary of &lt;code&gt;sections&lt;/code&gt;.&#10;&lt;code&gt;SaveGame._read_file()&lt;/code&gt; refuses a file newer than &lt;code&gt;FILE_VERSION&lt;/code&gt; and brings an older one&#10;forward one version at a time:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;FILE_VERSION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;migrations&lt;/code&gt; maps the version a file was written with to a function that takes the whole&#10;decoded file and returns it as the next version would have written it. A migration never&#10;asks the running game anything. The 3 to 4 one carries a frozen table of the spells each&#10;school had at version 3, and gives a member all of them so nobody loses their spell book.&lt;/p&gt;&#10;&lt;p&gt;Both branches were cut from version 3, so both added an entry under key 3. The merge moved&#10;the second to key 4 and set &lt;code&gt;FILE_VERSION&lt;/code&gt; to 5. This is &lt;code&gt;scripts/save/save_migrations.gd&lt;/code&gt;&#10;after it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v1_items_get_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v2_one_list_and_lore_by_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v3_spells_known_one_by_one&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v4_keys_by_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The order was free here. One migration adds &lt;code&gt;known_spells&lt;/code&gt; to each member in the &lt;code&gt;party&lt;/code&gt;&#10;section. The other renames keys in the &lt;code&gt;doors&lt;/code&gt;, &lt;code&gt;chests&lt;/code&gt;, &lt;code&gt;traps&lt;/code&gt;, &lt;code&gt;keycards&lt;/code&gt; and&#10;&lt;code&gt;monsters&lt;/code&gt; sections, from an id and a position (&lt;code&gt;cell_door@-5.3,0.0,-38.5&lt;/code&gt;) to the id&#10;alone. A third branch made it version 6 the same morning.&lt;/p&gt;&#10;&lt;h2 id="why-did-the-run-sit-stopped-for-four-hours"&gt;Why did the run sit stopped for four hours?&lt;/h2&gt;&#10;&lt;p&gt;One main session briefs the agents and lands what they finish. Each agent works on its own&#10;branch in its own git worktree. The main session only acts when an agent reports to it or&#10;I type.&lt;/p&gt;&#10;&lt;p&gt;At 2:05 the main session hit its usage limit. Two agents finished at 2:05 and 2:15, and&#10;nothing was running to land them. The limit reset at 2:50, but nothing sends the session&#10;its next message. I typed &amp;ldquo;Continue&amp;rdquo; at 6:21, so finished work waited 4 hours 16 minutes.&lt;/p&gt;&#10;&lt;p&gt;One agent had started a shell loop at 23:49 that read a test output file every ten&#10;seconds, waiting for one result line. The line was never written to that file. The agent&#10;finished and reported without it, and the loop ran for about seven hours with no time&#10;limit. It only slept and read a file, so it changed nothing. I found it, not the session.&lt;/p&gt;&#10;&lt;p&gt;A watchdog for this has to run outside the session, so a limit doesn&amp;rsquo;t stop it too. It&#10;needs to know which branches are finished and unlanded, and to put a time limit on every&#10;wait. None of that existed that night.&lt;/p&gt;&#10;&lt;h2 id="also-done"&gt;Also done&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Items, chests, loot, recipes and prices left the code tables for JSON under &lt;code&gt;content/&lt;/code&gt;&#10;(&lt;code&gt;content/items/weapons.json&lt;/code&gt; and eight more, &lt;code&gt;stocks.json&lt;/code&gt;, &lt;code&gt;loot.json&lt;/code&gt;, &lt;code&gt;costs.json&lt;/code&gt;),&#10;read at start by &lt;code&gt;ItemDb&lt;/code&gt;. A recorded baseline of every item, chest, loot roll, recipe&#10;and cost was identical before and after. Another branch was adding items to the tables&#10;this one deleted, so seven files conflicted in the merge.&lt;/li&gt;&#10;&lt;li&gt;Classes and XP cost curves are content files too, with a class editor.&lt;/li&gt;&#10;&lt;li&gt;Spells no longer take damage from the caster&amp;rsquo;s weapon. Staves carry a spell bonus of&#10;their own.&lt;/li&gt;&#10;&lt;li&gt;A spear in both hands does 1.25 times its damage.&lt;/li&gt;&#10;&lt;li&gt;Monsters chasing the party are still chasing it after a load.&lt;/li&gt;&#10;&lt;li&gt;I looked into an art pipeline and put it on hold.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Next: how dungeons and an overworld get made.&lt;/p&gt;&#10;</description></item><item><title>Auditing a day of agent work and building tooling from it</title><link>https://birdoggydogs-builds.pages.dev/posts/2026-10-05-auditing-a-day-of-agent-work/</link><guid isPermaLink="true">https://birdoggydogs-builds.pages.dev/posts/2026-10-05-auditing-a-day-of-agent-work/</guid><pubDate>Mon, 05 Oct 2026 20:00:00 -0700</pubDate><category>blobber</category><category>tooling</category><category>agents</category><category>godot</category><description>&lt;p&gt;I measured where the first day of building Blobber went and turned the findings into&#10;tooling: one shell script that runs Godot, a base script that every test extends, a film&#10;recorder on top of Godot&amp;rsquo;s movie writer, and the files my agents read before they start.&#10;That evening eleven branches landed through it, built by three agents at a time.&lt;/p&gt;&#10;&lt;p&gt;Blobber is a first-person party RPG in Godot 4.7 that I started the evening before. I build&#10;it by directing AI coding agents: a main session I talk to, and background agents (workers)&#10;that each build one feature in their own git worktree.&lt;/p&gt;&#10;&lt;h2 id="where-did-the-first-days-effort-go"&gt;Where did the first day&amp;rsquo;s effort go?&lt;/h2&gt;&#10;&lt;p&gt;An agent read the transcripts of the main session and of the 15 workers run so far, and&#10;counted. These are its counts. I have not checked them.&lt;/p&gt;&#10;&lt;table&gt;&#10; &lt;thead&gt;&#10; &lt;tr&gt;&#10; &lt;th class="to-left"&gt;What it counted&lt;/th&gt;&#10; &lt;th class="to-right"&gt;Number&lt;/th&gt;&#10; &lt;/tr&gt;&#10; &lt;/thead&gt;&#10; &lt;tbody&gt;&#10; &lt;tr&gt;&#10; &lt;td class="to-left"&gt;Test scripts written from scratch, then deleted&lt;/td&gt;&#10; &lt;td class="to-right"&gt;61&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td class="to-left"&gt;Time spent running them&lt;/td&gt;&#10; &lt;td class="to-right"&gt;about 97 minutes&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td class="to-left"&gt;Of that, waiting 10.5 seconds for the game&amp;rsquo;s opening&lt;/td&gt;&#10; &lt;td class="to-right"&gt;about 23 minutes&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td class="to-left"&gt;Passing checks cited in reports, and kept&lt;/td&gt;&#10; &lt;td class="to-right"&gt;over 4,800, and 0&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td class="to-left"&gt;Test runs that hung for 300 to 400 seconds&lt;/td&gt;&#10; &lt;td class="to-right"&gt;4&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td class="to-left"&gt;Large shell heredocs that failed&lt;/td&gt;&#10; &lt;td class="to-right"&gt;15 of 17&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td class="to-left"&gt;Code read by workers before their first edit&lt;/td&gt;&#10; &lt;td class="to-right"&gt;2.0 MB, in 175 tool calls&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td class="to-left"&gt;Size of 14 briefs, and how much of it was the task&lt;/td&gt;&#10; &lt;td class="to-right"&gt;148 KB, and 62 KB&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;tr&gt;&#10; &lt;td class="to-left"&gt;Merge conflicts in 24 merges&lt;/td&gt;&#10; &lt;td class="to-right"&gt;4&lt;/td&gt;&#10; &lt;/tr&gt;&#10; &lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The biggest cost was the main session: one conversation of about 822K tokens, re-read on&#10;every turn. The fix is a fresh session after each landed feature, and that only works if&#10;everything the session knows is in files.&lt;/p&gt;&#10;&lt;h2 id="running-godot-through-one-script"&gt;Running Godot through one script&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;tools/gd.sh&lt;/code&gt; is now the only way Godot gets run, by me or by an agent. These are four of&#10;its five modes, from the script&amp;rsquo;s header (&lt;code&gt;movie&lt;/code&gt; is further down):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# tools/gd.sh import [-C dir] re-import (new class_name scripts, assets, after a merge)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# tools/gd.sh check [-C dir] import, then load the game for 90 frames&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# tools/gd.sh run [-C dir] &amp;lt;scene&amp;gt; [-- a b] run a scene headless (logic tests)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# tools/gd.sh shots [-C dir] &amp;lt;scene&amp;gt; [-- a b] run a scene in a window (screenshots only)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;-C dir&lt;/code&gt; picks the worktree, so the copy in the main checkout can run any worker&amp;rsquo;s branch.&lt;/p&gt;&#10;&lt;p&gt;Every mode goes through the same two functions in &lt;code&gt;tools/gd.sh&lt;/code&gt;. The first runs the console&#10;build of Godot under GNU &lt;code&gt;timeout&lt;/code&gt; and keeps the output and the exit code. The second&#10;decides whether the run failed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;godot&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nb"&gt;local&lt;/span&gt; raw&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nv"&gt;raw&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;timeout &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$LIMIT&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$GODOT&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; --path &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;1&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;%s\n&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; filter&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;report&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nb"&gt;local&lt;/span&gt; &lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="nv"&gt;failed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="o"&gt;[&lt;/span&gt; -n &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;%s\n&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; -eq &lt;span class="m"&gt;124&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;gd.sh: &lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt; TIMED OUT after &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;LIMIT&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s (a script error in a harness leaves Godot waiting; look above)&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nv"&gt;failed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; -ne &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;gd.sh: &lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt; exited with code &lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nv"&gt;failed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;fi&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;%s\n&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; grep -q &lt;span class="s2"&gt;&amp;#34;SCRIPT ERROR&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;gd.sh: &lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt; printed a SCRIPT ERROR&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nv"&gt;failed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;fi&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$failed&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A run fails three ways. &lt;code&gt;timeout&lt;/code&gt; returns 124 when the limit passes (150 seconds unless&#10;&lt;code&gt;GD_TIMEOUT&lt;/code&gt; says otherwise). The scene quits with a non-zero code. Or the text &lt;code&gt;SCRIPT ERROR&lt;/code&gt; appears anywhere in the output, even from code the test wasn&amp;rsquo;t about. The timeout is&#10;there because a test script that fails to parse never reaches its own quit call, and Godot&#10;then waits forever.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;import&lt;/code&gt; is &lt;code&gt;--headless --import&lt;/code&gt;, printing only lines that hold &lt;code&gt;ERROR&lt;/code&gt;, &lt;code&gt;WARNING&lt;/code&gt; or an&#10;&lt;code&gt;at:&lt;/code&gt; location. It has to run after a merge or after adding a script with a &lt;code&gt;class_name&lt;/code&gt;,&#10;because until then the new class doesn&amp;rsquo;t resolve. &lt;code&gt;check&lt;/code&gt; is the import and then&#10;&lt;code&gt;--headless --quit-after 90&lt;/code&gt;. &lt;code&gt;run&lt;/code&gt; is &lt;code&gt;--headless &amp;lt;scene&amp;gt; -- &amp;lt;args&amp;gt;&lt;/code&gt;. &lt;code&gt;shots&lt;/code&gt; is the same&#10;in a 1280x720 window, because a headless run draws nothing.&lt;/p&gt;&#10;&lt;p&gt;Godot prints the same harmless lines on every headless run, so &lt;code&gt;filter&lt;/code&gt; drops them with&#10;awk before anything is judged. &lt;code&gt;skip&lt;/code&gt; makes a dropped error take its indented &lt;code&gt;at:&lt;/code&gt; line&#10;along:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;filter&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;awk &lt;span class="s1"&gt;&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&#9;&#9;/^Godot Engine v/ || /^Vulkan / || /^OpenGL / || /^[[:space:]]*$/ { next }&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&#9;&#9;/^\[ *[0-9]+% \]/ || /^\[ DONE \]/ || /\[0m$/ &amp;amp;&amp;amp; /(reimport|scan|first_scan|update_scripts|loading_editor)/ { next }&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&#9;&#9;/ObjectDB instances were leaked at exit/ ||&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&#9;&#9;/resources still in use at exit/ ||&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&#9;&#9;/Parameter &amp;#34;material&amp;#34; is null/ ||&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&#9;&#9;/BUG: Unreferenced static string/ ||&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&#9;&#9;/Pages in use exist at exit in PagedAllocator/ ||&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&#9;&#9;/A Thread object is being destroyed without its completion/ ||&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&#9;&#9;/Please call wait_to_finish\(\)/ { skip = 1; next }&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&#9;&#9;skip &amp;amp;&amp;amp; /^[[:space:]]+at: / { next }&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&#9;&#9;{ skip = 0; print }&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&#9;&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="keeping-the-tests"&gt;Keeping the tests&lt;/h2&gt;&#10;&lt;p&gt;Tests now stay in &lt;code&gt;tests/&lt;/code&gt;. Each is a &lt;code&gt;.tscn&lt;/code&gt; holding one &lt;code&gt;Node&lt;/code&gt; with a script, and the&#10;script extends &lt;code&gt;tests/harness_base.gd&lt;/code&gt; and overrides one function. This is most of&#10;&lt;code&gt;tests/smoke.gd&lt;/code&gt;, the first kept test:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;scenario&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nf"&gt;check_eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;party&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;a party of four&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;party&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;groggy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;the party is not groggy once it has members&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;party&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_enabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;the party can move&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;party&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_alive&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt; is alive&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;action_damage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PartyMember&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MELEE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt; can hit&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;known_skills&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_empty&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt; knows some skills&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nf"&gt;check_eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;party&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inventory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;the party starts with its eight rations and nothing else&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;get_tree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_nodes_in_group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;monsters&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;the world has its monsters&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The base script&amp;rsquo;s &lt;code&gt;_ready()&lt;/code&gt; awaits &lt;code&gt;scenario()&lt;/code&gt; and then calls &lt;code&gt;finish()&lt;/code&gt;, which prints&#10;&lt;code&gt;checks: N passed, M failed&lt;/code&gt; and quits Godot with the number of failures as the exit code,&#10;capped at 100. &lt;code&gt;gd.sh&lt;/code&gt; turns a non-zero exit into a failed run, so nothing parses test&#10;output.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;boot()&lt;/code&gt; stands up the real game, the same scene the player gets:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with_intro&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nb"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LEVEL_SCRIPT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;skip_intro&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;with_intro&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;room&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SCENE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instantiate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nf"&gt;add_child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;party&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Party&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;with_intro&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;6.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;skip_intro&lt;/code&gt; is a &lt;code&gt;static var&lt;/code&gt; on the level&amp;rsquo;s script, so it can be set before the scene&#10;exists. When it is set, the level&amp;rsquo;s &lt;code&gt;_ready()&lt;/code&gt; skips the wake-up and calls a short function&#10;that powers the Meat Machine, marks it used, and builds the default party of four from the&#10;creation screen&amp;rsquo;s defaults. A test is ready after 3 frames. The old scripts waited 6.5&#10;seconds for the opening and 4 more after confirming the party. The session reported a&#10;whole test run at about 1.7 seconds.&lt;/p&gt;&#10;&lt;p&gt;Input goes in the way a player&amp;rsquo;s does. &lt;code&gt;key()&lt;/code&gt; builds an &lt;code&gt;InputEventKey&lt;/code&gt; press and release&#10;and hands them to &lt;code&gt;Input.parse_input_event()&lt;/code&gt;, so the game&amp;rsquo;s own handlers see them.&#10;&lt;code&gt;hold()&lt;/code&gt; wraps &lt;code&gt;Input.action_press()&lt;/code&gt; and &lt;code&gt;Input.action_release()&lt;/code&gt; round a wait. A test can&#10;also call the game directly, as in &lt;code&gt;monster.take_damage(n)&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Logic is always judged headless, because a windowed run receives my real keyboard and&#10;mouse.&lt;/p&gt;&#10;&lt;p&gt;The suite is a loop, one Godot process per scene (from &lt;code&gt;tests/README.md&lt;/code&gt;):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; scene in tests/*.tscn&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; tools/gd.sh run &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$scene&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;FAILED: &lt;/span&gt;&lt;span class="nv"&gt;$scene&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;There was 1 scene with 19 checks in the afternoon and 37 scenes by the end of the day.&lt;/p&gt;&#10;&lt;h2 id="filming-a-feature-with-godots-movie-writer"&gt;Filming a feature with Godot&amp;rsquo;s movie writer&lt;/h2&gt;&#10;&lt;p&gt;Every feature the player can see or hear now gets a 20 to 60 second captioned film, and I&#10;get the film before I&amp;rsquo;m asked whether to merge.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-05-auditing-a-day-of-agent-work/first-film-stills.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-05-auditing-a-day-of-agent-work/first-film-stills.png" alt="A grid of eight captioned stills from the first film: a green vat in a rusted room, a fight with a green practice monster in a yard, the inventory screen, and a town far off across a sandy field." width="1280" height="1440" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;The check sheet for the first film: 8 stills, evenly spaced, 2 across.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;I wanted to see the game in action while I&amp;rsquo;m away from the machine. Until then I only got&#10;screenshots.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;tools/gd.sh movie&lt;/code&gt; runs a scene with Godot&amp;rsquo;s movie writer turned on:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;godot --resolution 1280x720 --write-movie &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$avi&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; --fixed-fps &lt;span class="m"&gt;30&lt;/span&gt; --disable-vsync &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$scene&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; -- &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With &lt;code&gt;--fixed-fps 30&lt;/code&gt; the engine steps the game by exactly one thirtieth of a second per&#10;recorded frame, no matter how long the frame takes to draw. A &lt;code&gt;wait(6.0)&lt;/code&gt; in a scenario is&#10;6 seconds of film on any machine.&lt;/p&gt;&#10;&lt;p&gt;A film scenario is a test with captions, kept in &lt;code&gt;films/&amp;lt;feature&amp;gt;.gd&lt;/code&gt; so the film can be&#10;recorded again after a change. This is the first half of &lt;code&gt;films/tour.gd&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;scenario&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nf"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;The vat bay&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;360.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;move_forward&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;yield_monster&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nf"&gt;monsters_named&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Spoiled Yield&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;yield_monster&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;global_position&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;Vector3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UP&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nf"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;party&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;16.0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nf"&gt;face&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nf"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;The yard: every recovered member acts while attack is held&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;attack&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;caption()&lt;/code&gt; is a &lt;code&gt;Label&lt;/code&gt; on a &lt;code&gt;CanvasLayer&lt;/code&gt; at layer 100, in 24 px type with an 8 px black&#10;outline. &lt;code&gt;pan()&lt;/code&gt; is a &lt;code&gt;Tween&lt;/code&gt; on the party&amp;rsquo;s &lt;code&gt;rotation_degrees:y&lt;/code&gt;. &lt;code&gt;place()&lt;/code&gt; teleports the&#10;party and &lt;code&gt;face()&lt;/code&gt; turns it to a point in the world.&lt;/p&gt;&#10;&lt;p&gt;The movie writer leaves an &lt;code&gt;.avi&lt;/code&gt;. &lt;code&gt;tools/movie.py&lt;/code&gt; encodes it to an &lt;code&gt;.mp4&lt;/code&gt; that plays on a&#10;phone, then deletes the &lt;code&gt;.avi&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;imageio_ffmpeg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_ffmpeg_exe&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;-y&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;-loglevel&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;error&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;-i&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;-c:v&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;libx264&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;-preset&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;medium&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;-crf&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;27&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;-pix_fmt&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;yuv420p&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;-c:a&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aac&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;-b:a&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;96k&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;-movflags&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;+faststart&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;There was no ffmpeg on the machine. The Python package &lt;code&gt;imageio-ffmpeg&lt;/code&gt; carries one, and&#10;&lt;code&gt;get_ffmpeg_exe()&lt;/code&gt; returns its path. The 38.8 second tour came out at 6.4 MB.&lt;/p&gt;&#10;&lt;p&gt;An agent can&amp;rsquo;t watch a video, so &lt;code&gt;tools/frames.py&lt;/code&gt; makes the picture above. It reads the&#10;film&amp;rsquo;s length from ffmpeg, takes 8 frames at the middle of 8 equal slices, scales each to&#10;640 px wide and pastes them 2 across with Pillow. Whoever makes a film reads that sheet and&#10;checks that each caption&amp;rsquo;s shot shows what the caption says. The first take of the tour&#10;spent ten seconds facing a wall, and the sheet caught it.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;gd.sh&lt;/code&gt; first looked for &lt;code&gt;movie.py&lt;/code&gt; under &lt;code&gt;$dir&lt;/code&gt;, the worktree being filmed with &lt;code&gt;-C&lt;/code&gt;.&#10;Fifteen minutes later I had that changed to &lt;code&gt;$(dirname &amp;quot;$0&amp;quot;)&lt;/code&gt;, the script&amp;rsquo;s own folder, so&#10;a worktree cut before the encoder existed can still be filmed.&lt;/p&gt;&#10;&lt;p&gt;Recording opens a real window, so anything I type at the machine can leak into the film.&lt;/p&gt;&#10;&lt;h2 id="writing-down-what-an-agent-needs-before-it-starts"&gt;Writing down what an agent needs before it starts&lt;/h2&gt;&#10;&lt;p&gt;Every session and worker now starts from &lt;code&gt;CLAUDE.md&lt;/code&gt; (63 lines when written) and&#10;&lt;code&gt;docs/ARCHITECTURE.md&lt;/code&gt; (166 lines), so a brief is only the task.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; opens by pointing at four files: the architecture map, &lt;code&gt;WORKLIST.md&lt;/code&gt; (what is&#10;open), &lt;code&gt;docs/DESIGN.md&lt;/code&gt; (what I have decided) and &lt;code&gt;docs/HANDOFF.md&lt;/code&gt; (what is in flight,&#10;rewritten before a session ends). Then come the traps of the machine. The heredoc one reads&#10;&amp;ldquo;large heredocs fail here and &lt;code&gt;\\&lt;/code&gt; is silently collapsed to &lt;code&gt;\&lt;/code&gt;&amp;rdquo;, and the rule is to create&#10;files with the Write tool.&lt;/p&gt;&#10;&lt;p&gt;A worker doesn&amp;rsquo;t commit, merge, push, switch branch or spawn agents, and leaves its work&#10;uncommitted in its worktree. It decides small open questions itself and lists them. It&#10;keeps a running note outside the repository (done, left to do, verified), so that a worker&#10;cut off by a usage limit is resumed from the note and not started again. And it reports in&#10;a fixed shape (from &lt;code&gt;CLAUDE.md&lt;/code&gt;):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Report in at most about 6 KB, under exactly these headings: **Built** (what the player&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; gets, with numbers); **Decisions I made**; **Files** (added, changed); **Verified** (what&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; was run, counts passed and failed); **Film** (the .mp4&amp;#39;s full path and what it shows, in&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; order); **Not verified**; **Needs the user&amp;#39;s answer**;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; **Worklist** (items closed, changed or added).&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The architecture map has a section per system (which script owns it, what other code&#10;calls) and a table for the files every feature touches. By the audit&amp;rsquo;s count all 4 merge&#10;conflicts were two branches adding lines at the same spot in one of those. Two rows:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| `party.gd` `BINDINGS` | One line per new key action, kept in the dictionary&amp;#39;s order of appearance. New state: one commented `var` beside the related ones. New signal: beside the other signals. |&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| `ai/monster_profile.gd` | One static function per monster type, added after the last. |&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A brief now has four parts: where to work, my request word for word with what it means as&#10;a numbered list, the few files to read first, and which files the worker owns with what its&#10;test and its film must show.&lt;/p&gt;&#10;&lt;h2 id="answering-the-work-list-from-my-phone"&gt;Answering the work list from my phone&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;WORKLIST.md&lt;/code&gt; holds everything open in the game, one checkbox line per item, and the same&#10;items are on a private web page where I type answers under them.&lt;/p&gt;&#10;&lt;p&gt;The file has six sections, and the section gives the id its letter: Q a decision waiting&#10;on me, S a system not built, H a stub, N a placeholder number, R a rough edge, K&#10;housekeeping. Ids are never renumbered, because the page and the design record refer to an&#10;item by its id. This is one question before and after my answer:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- [ ] **Q4 Loading-zone markers.** The two zones (bay corridor, exit hall mouth) draw an amber floor patch and a &amp;#34;LOADING ZONE&amp;#34; label. Keep visible?&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- [x] **Q4 Loading-zone markers.** The two zones (bay corridor, exit hall mouth) draw an amber floor patch and a &amp;#34;LOADING ZONE&amp;#34; label. Keep visible? Decided: no visible markers. To build: S25.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- [ ] **S25 Remove loading-zone markers.** Decided (Q4), not built: take out the amber floor patch and the &amp;#34;LOADING ZONE&amp;#34; label.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A question says what the game does today, so I can answer it without opening the game. An&#10;answer ticks the Q, goes into &lt;code&gt;docs/DESIGN.md&lt;/code&gt;, and becomes a new S item if it needs&#10;building. The list had 91 items when it was written and 231 by the day&amp;rsquo;s last commit.&lt;/p&gt;&#10;&lt;p&gt;The page is a Claude artifact with a small database behind it: one row per item, with the&#10;same ids. The main session reads it at the start of a session and before every landing,&#10;looking for rows with an answer that aren&amp;rsquo;t marked done. It acts on each and marks it done&#10;with a note of what happened.&lt;/p&gt;&#10;&lt;p&gt;My first 27 answers settled 15 questions with no work and made 12 build items, which went&#10;to two workers. Each added new items to the list in its own worktree. Both worktrees were&#10;cut from the same commit, so both took the same next ids, and one set had to be renumbered&#10;before landing. The rule since then: workers don&amp;rsquo;t edit &lt;code&gt;WORKLIST.md&lt;/code&gt;, &lt;code&gt;docs/DESIGN.md&lt;/code&gt; or&#10;&lt;code&gt;tests/README.md&lt;/code&gt;. They report the changes and the main session applies them at landing.&lt;/p&gt;&#10;&lt;p&gt;After 38 more answers I set a standing rule for the evening: no more than three workers at&#10;a time, merge what passes the kept tests on the merged result, and start nothing that&#10;depends on work that hasn&amp;rsquo;t landed. The queue is a list in &lt;code&gt;docs/HANDOFF.md&lt;/code&gt;, each line a&#10;branch, its items, and what it waits for:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- `resistances`: S38, S39, S40. After `weapons` (same party and item files).&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- `save`: S4. After wave 1 (it must cover the event log, kill facts, new item fields). Put&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; serialisation in its own scripts so later workers add their state to it.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- `builder-2`: S41 NPC editor, S47 drafts and published. After `content-core`.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- `spells`: S9, spell book. After `resistances` (damage types) and `weapons`.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The three branches of the first wave each got a list of the files they own, so they could&#10;run at once. Eleven branches landed that evening.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-05-auditing-a-day-of-agent-work/save-film-stills.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-05-auditing-a-day-of-agent-work/save-film-stills.png" alt="A grid of eight captioned stills from the save and load film: a game in progress, a party member dead and a job failed, a quick load putting it all back, and the game started cold from the save." width="1280" height="1440" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;Save and load, one of the eleven: F5 and F9, three slots.&lt;/figcaption&gt;&lt;/figure&gt;&lt;h2 id="also-done"&gt;Also done&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;tools/land.sh &amp;lt;branch&amp;gt; &amp;lt;message-file&amp;gt;&lt;/code&gt;: commits a worker&amp;rsquo;s worktree, merges it with &lt;code&gt;git merge --no-ff&lt;/code&gt;, runs &lt;code&gt;tools/gd.sh check&lt;/code&gt; on the result, and fast-forwards every other clean worktree.&lt;/li&gt;&#10;&lt;li&gt;A quest builder inside Godot, with forms generated from the content schema: 16 kinds of condition and 28 kinds of effect.&lt;/li&gt;&#10;&lt;li&gt;Character creation with point buy: 25 points, no stat above 25 or below 3.&lt;/li&gt;&#10;&lt;li&gt;Three tiers of chests, a flying monster, 26 skills and XP from kills.&lt;/li&gt;&#10;&lt;li&gt;Resting, which costs one food per party member, and surgery on organs, paid for in XP.&lt;/li&gt;&#10;&lt;li&gt;A town, a sunken dungeon, a camp and a tower outdoors.&lt;/li&gt;&#10;&lt;li&gt;The calendar runs at ten times play speed, so a full day and night takes 2 hours 24 minutes of play.&lt;/li&gt;&#10;&lt;li&gt;From the evening run: save and load, 24 spells in three schools, a minimap and a map with the player&amp;rsquo;s own notes, twelve damage types.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>Building a first-person RPG prototype in Godot with agents</title><link>https://birdoggydogs-builds.pages.dev/posts/2026-10-04-building-an-rpg-prototype-in-godot/</link><guid isPermaLink="true">https://birdoggydogs-builds.pages.dev/posts/2026-10-04-building-an-rpg-prototype-in-godot/</guid><pubDate>Sun, 04 Oct 2026 20:00:00 -0700</pubDate><category>blobber</category><category>godot</category><category>stats</category><category>combat</category><category>agents</category><category>git</category><description>&lt;p&gt;I started Blobber on Sunday evening with no code. By the end of the night it had 17&#10;commits: stats that step up at breakpoints, one game clock that every cooldown counts on,&#10;shots swept as rays from the camera, and a dungeon built from a floor plan on a grid.&lt;/p&gt;&#10;&lt;p&gt;Blobber is a first-person party RPG in the style of Might and Magic VI, built in Godot 4.7.&#10;I build it by directing AI coding agents, and they write the GDScript. Everything below is&#10;the code as it stood at the end of that night.&lt;/p&gt;&#10;&lt;h2 id="making-stats-step-up-at-breakpoints"&gt;Making stats step up at breakpoints&lt;/h2&gt;&#10;&lt;p&gt;Every stat runs from 1 to 999 and turns into a bonus through one table. 10 is the baseline,&#10;each point below it is -1, and above it the bonus goes up by one at each breakpoint, to +50&#10;at 999.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-04-building-an-rpg-prototype-in-godot/stats-pane-breakpoints.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-04-building-an-rpg-prototype-in-godot/stats-pane-breakpoints.png" alt="The party inventory screen with a stats pane on the right for Chuck, a Shank. Muscle is 13 with a bonus of &amp;#43;3 and “next at 14”. Tendons, Fat, Grey Matter and Machine follow in the same form, with derived values such as health and evasion below." width="1280" height="720" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;The stats pane shows each stat&amp;#39;s bonus and the value where the bonus next goes up.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;I wanted diminishing returns, so that 80% of the gains come from 20% of the range. I also&#10;wanted the gains to come in steps, so that a player can say &amp;ldquo;i won&amp;rsquo;t get to x until the next&#10;break point&amp;rdquo;.&lt;/p&gt;&#10;&lt;p&gt;The table is a &lt;code&gt;PackedInt32Array&lt;/code&gt; of 51 entries in &lt;code&gt;scripts/stats.gd&lt;/code&gt;, built the first time&#10;anything asks for it. Entry &lt;code&gt;b&lt;/code&gt; is the lowest stat value that gives +b. The entries sit on&#10;a geometric curve from 10 to 999:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;breakpoint(b) = 10 + 989 * (R^(b/50) - 1) / (R - 1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;R sets how fast the gaps widen, and the 80/20 rule fixes it. Put b = 40 in, and&#10;(R^0.8 - 1) / (R - 1) has to equal 0.2. That has no tidy solution, so the script finds&#10;ln(R) by bisection between 0 and 40, halving the range 80 times, then fills the table:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;low&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="c1"&gt;# ln(R)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;high&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="mf"&gt;40.0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;low&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;high&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;fraction&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;PARETO_GAIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fraction&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PARETO_RANGE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;low&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;high&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;low&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;high&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;MAX_STAT&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;BASELINE&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;_breakpoints&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MAX_BONUS&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;MAX_BONUS&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;curve&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;BASELINE&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;MAX_BONUS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="c1"&gt;# Never more than +1 bonus per stat point.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;_breakpoints&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;maxi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BASELINE&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;roundi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;curve&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;R comes out near 3,105, so each gap is about 17% wider than the one before. The &lt;code&gt;maxi&lt;/code&gt; on the last line stops two breakpoints landing on one&#10;stat point, and it&amp;rsquo;s why the first 28 are one point apart, 11 to 38. After that the curve&#10;takes over: 43, 49, 56, 64, and on up to 208 for +40. 208 is 20% of the way from 10 to 999.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;Stats.next_breakpoint(value)&lt;/code&gt; returns the next entry, which the pane prints as &amp;ldquo;next at 14&amp;rdquo;.&lt;/p&gt;&#10;&lt;p&gt;The formulas in &lt;code&gt;scripts/party_member.gd&lt;/code&gt; work from bonuses, never from the stat. Each&#10;point of Tendons bonus is 4% more actions per second and 1.5% evasion. Each point of Fat&#10;bonus is 10 health and soaks 1.5% of every hit, up to 75%. Absorption was a flat amount at&#10;first, which made high-Fat characters nearly immune.&lt;/p&gt;&#10;&lt;p&gt;A character class is a &amp;ldquo;cut&amp;rdquo; of meat, and a cut is one dictionary of starting stats:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="s2"&gt;&amp;#34;name&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Brisket&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;blurb&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Dense and slow. Takes a long time to get through.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="s2"&gt;&amp;#34;muscle&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;tendons&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;fat&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;grey_matter&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;machine&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;caster&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-04-building-an-rpg-prototype-in-godot/meat-machine-party-builder.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-04-building-an-rpg-prototype-in-godot/meat-machine-party-builder.png" alt="The Meat Machine’s culture selection screen. Four rows, called yields, are named Chuck, Flank, Marrow and Gristle. Each has a cut picked from a list (Shank, Brisket, Sweetbread, Offal), a one-line description and its stats." width="1280" height="720" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;Picking cuts for the party. Brisket&amp;#39;s Tendons of 7 is a bonus of -3, so its 2.0 s melee cooldown shows as 2.24 s.&lt;/figcaption&gt;&lt;/figure&gt;&lt;h2 id="counting-every-cooldown-on-one-game-clock"&gt;Counting every cooldown on one game clock&lt;/h2&gt;&#10;&lt;p&gt;The game has one clock. Attack recovery, MP, monster respawns, trap cycles and the sun all&#10;count game seconds on it, and nothing keeps a timer of its own.&lt;/p&gt;&#10;&lt;p&gt;I wanted a day and night cycle with months and years, &amp;ldquo;and all of this should run off the&#10;clock&amp;rdquo;. The turn-based mode I plan can then move the same clock in jumps.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;GameClock&lt;/code&gt; is an autoload (&lt;code&gt;scripts/game_clock.gd&lt;/code&gt;) that holds one float, &lt;code&gt;total_seconds&lt;/code&gt;:&#10;game seconds since midnight on 1 January, Year 1. In &lt;code&gt;_process&lt;/code&gt; it adds&#10;&lt;code&gt;delta * time_scale&lt;/code&gt;. The normal &lt;code&gt;time_scale&lt;/code&gt; is 30, so a game minute passes every two real&#10;seconds. The calendar is integer division on that number, with 28-day months and a 336-day&#10;year.&lt;/p&gt;&#10;&lt;p&gt;Time only moves through &lt;code&gt;advance()&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;game_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;game_seconds&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="k"&gt;return&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nf"&gt;_whole_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;total_seconds&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;game_seconds&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nf"&gt;_whole_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;advanced&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;game_seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;SECONDS_PER_MINUTE&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;SECONDS_PER_MINUTE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;minute_changed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;SECONDS_PER_HOUR&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;SECONDS_PER_HOUR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;hour_changed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;SECONDS_PER_DAY&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;SECONDS_PER_DAY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;day_changed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;SECONDS_PER_MONTH&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;SECONDS_PER_MONTH&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;month_changed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;SECONDS_PER_YEAR&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;SECONDS_PER_YEAR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;year_changed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It compares before and after, so a big jump fires each signal once. Skipping a month emits&#10;&lt;code&gt;day_changed&lt;/code&gt; once, not 28 times.&lt;/p&gt;&#10;&lt;p&gt;Cooldowns are written in real seconds at the normal rate and converted once.&#10;&lt;code&gt;GameClock.real_seconds(2.0)&lt;/code&gt; is 60 game seconds. A party member&amp;rsquo;s &lt;code&gt;commit(action)&lt;/code&gt; stores&#10;that in &lt;code&gt;cooldown_remaining&lt;/code&gt;. The party listens to &lt;code&gt;advanced&lt;/code&gt; and calls each member&amp;rsquo;s&#10;&lt;code&gt;tick(game_seconds)&lt;/code&gt;, which subtracts.&lt;/p&gt;&#10;&lt;p&gt;Movement follows the clock too. &lt;code&gt;GameClock.rate()&lt;/code&gt; is &lt;code&gt;time_scale / 30&lt;/code&gt;, or 0 while the&#10;clock is stopped, and a monster multiplies its horizontal velocity by it before&#10;&lt;code&gt;move_and_slide()&lt;/code&gt;. A laser grid has no timer at all. Its place in its on/off cycle is&#10;&lt;code&gt;fposmod(GameClock.total_seconds / 30 + phase, on_time + off_time)&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The sky (&lt;code&gt;scripts/day_night_cycle.gd&lt;/code&gt;) reads &lt;code&gt;GameClock.day_fraction&lt;/code&gt; every frame. The&#10;sun&amp;rsquo;s angle is &lt;code&gt;(day_fraction - 0.25) * TAU&lt;/code&gt;, which puts sunrise at 6:00 and sunset at&#10;18:00. That angle turns a &lt;code&gt;DirectionalLight3D&lt;/code&gt; along an arc tilted 25 degrees. &lt;code&gt;smoothstep&lt;/code&gt;&#10;on the sine of the angle blends the &lt;code&gt;ProceduralSkyMaterial&lt;/code&gt; colors, the ambient light and&#10;the fog between day and night.&lt;/p&gt;&#10;&lt;h2 id="aiming-shots-with-the-camera-not-a-target-lock"&gt;Aiming shots with the camera, not a target lock&lt;/h2&gt;&#10;&lt;p&gt;Ranged attacks and spells fly to the point under the crosshair, and I aim them by turning&#10;and looking up or down.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-04-building-an-rpg-prototype-in-godot/yard-practice-monster.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-04-building-an-rpg-prototype-in-godot/yard-practice-monster.png" alt="A walled concrete yard under a brown sky. A green blocky monster stands beside a rusty post, with a health plate reading “Spoiled Yield 1000 / 1000”. A doorway behind leads into the dungeon. Debug text fills the top left corner." width="1280" height="720" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;The practice monster in the yard, with 1000 health and a small crosshair on the post beside it.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;The first version locked onto the target for me. I didn&amp;rsquo;t like it: &amp;ldquo;Shots should fire&#10;straight in front of you.&amp;rdquo; So the lock came out.&lt;/p&gt;&#10;&lt;p&gt;Every physics tick, &lt;code&gt;scripts/party.gd&lt;/code&gt; casts one ray from the camera along its forward axis&#10;for 20 m with &lt;code&gt;intersect_ray&lt;/code&gt;, leaving out the party&amp;rsquo;s own body. Where it hits is&#10;&lt;code&gt;aim_point&lt;/code&gt;, or the point 20 m out if it hits nothing. If the collider is a &lt;code&gt;Monster&lt;/code&gt;, that&#10;monster is &lt;code&gt;target&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Holding F calls &lt;code&gt;attack()&lt;/code&gt; on every tick. Each living member whose cooldown has run out&#10;picks for themselves: the spell if they&amp;rsquo;re a caster with 10 MP, otherwise melee if &lt;code&gt;target&lt;/code&gt;&#10;is within 4 m, otherwise a ranged shot. Melee calls &lt;code&gt;target.take_damage()&lt;/code&gt; directly. The&#10;other two spawn a &lt;code&gt;Projectile&lt;/code&gt;. The four members&amp;rsquo; shots start spread across 0.9 m of the&#10;party&amp;rsquo;s front and each is pointed at &lt;code&gt;aim_point&lt;/code&gt;, so they converge on the crosshair. They&#10;start up to 0.8 m ahead of the camera, because they filled the bottom of the screen on&#10;their first frame.&lt;/p&gt;&#10;&lt;p&gt;A &lt;code&gt;Projectile&lt;/code&gt; is a &lt;code&gt;Node3D&lt;/code&gt; with no collision shape. Before each move it sweeps a ray&#10;over the stretch it&amp;rsquo;s about to cover (&lt;code&gt;scripts/projectile.gd&lt;/code&gt;):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_physics_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;minf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_distance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;_travelled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;global_position&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nc"&gt;PhysicsRayQueryParameters3D&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;global_position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;passes_through&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;shooter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shooter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_rid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exclude&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nf"&gt;get_world_3d&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;direct_space_state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;intersect_ray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_empty&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nf"&gt;_impact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;position&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;collider&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="k"&gt;return&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;global_position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;_travelled&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_travelled&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;max_distance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="nf"&gt;queue_free&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Physics runs at 120 ticks a second and a party shot flies at 28 m/s, so each sweep is about&#10;23 cm. The ray covers the whole step, so a shot can&amp;rsquo;t skip through a thin wall between two&#10;ticks. Whatever it touches first ends it, and takes the damage if it has a &lt;code&gt;take_damage&lt;/code&gt;&#10;method.&lt;/p&gt;&#10;&lt;h2 id="building-the-dungeon-from-a-floor-plan-on-a-grid"&gt;Building the dungeon from a floor plan on a grid&lt;/h2&gt;&#10;&lt;p&gt;The dungeon is nine rooms beyond the yard, with two loops and two ramps, opening onto a&#10;200 m empty field. &lt;code&gt;scripts/dungeon.gd&lt;/code&gt; builds all of it from a list of rectangles when&#10;the scene starts.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-04-building-an-rpg-prototype-in-godot/dungeon-laser-grid.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-04-building-an-rpg-prototype-in-godot/dungeon-laser-grid.png" alt="A rusted green corridor with three red laser beams across it, one above another. Pressure plates lie on the floor beyond. Four party cards run along the bottom of the screen." width="1280" height="720" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;A laser grid in the gallery. Its beams are at 0.5, 1.1 and 1.7 m, on for 2.2 s and dark for 1.8 s.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;The first room was built one hand-placed box at a time. The dungeon is generated so that&#10;walls, floors and roofs meet edge to edge, with no gaps and no overlapping faces.&lt;/p&gt;&#10;&lt;p&gt;The plan is 14 room rectangles and 12 doorways, in meters, each with a ceiling height and&#10;materials:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 2. Gallery: long, narrow and tall. x -2..2, n 44..68.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nf"&gt;_room&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;44&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;68&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Mat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PAINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Mat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FLOOR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;ol&gt;&#10;&lt;li&gt;Rasterize. The plan is drawn onto a grid of 0.5 m cells, 92 by 153. A &lt;code&gt;PackedInt32Array&lt;/code&gt;&#10;records which rectangle owns each cell, or -1. The first rectangle to claim a cell keeps it.&lt;/li&gt;&#10;&lt;li&gt;Mark layers. An owned cell adds a floor slab below it and a roof slab above its ceiling.&#10;A doorway cell adds a hazard-striped band over the opening. An empty cell with an owned&#10;cell among its eight neighbors is wall, as tall as the tallest thing it touches. Each&#10;distinct (bottom, top, material) gets its own byte mask of the grid.&lt;/li&gt;&#10;&lt;li&gt;Merge. Each mask is covered greedily with as few boxes as possible:&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_emit_layer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PackedByteArray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;material&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Material&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;_columns&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&#9;&lt;span class="k"&gt;continue&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;_columns&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;_columns&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&#9;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;_rows&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;_row_is_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&#9;&lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&#9;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&#9;&#9;&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;_columns&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nc"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;CELL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;y0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;CELL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;center&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nc"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&#9;&lt;span class="n"&gt;GRID_X0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;CELL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&#9;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&#9;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GRID_N0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;CELL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&#9;&lt;span class="nf"&gt;_add_box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;center&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;material&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each box becomes a &lt;code&gt;StaticBody3D&lt;/code&gt; holding a &lt;code&gt;BoxMesh&lt;/code&gt; and a &lt;code&gt;BoxShape3D&lt;/code&gt; of the same&#10;size. The materials are &lt;code&gt;StandardMaterial3D&lt;/code&gt; with &lt;code&gt;uv1_triplanar&lt;/code&gt; and&#10;&lt;code&gt;uv1_world_triplanar&lt;/code&gt; on, so a texture lines up across every seam and no box needs UVs.&lt;/p&gt;&#10;&lt;p&gt;The party can&amp;rsquo;t step up a ledge, so the two 1.5 m height changes are ramps, 6 m and 8 m&#10;long. Each is a wedge built with &lt;code&gt;SurfaceTool&lt;/code&gt;, with a &lt;code&gt;ConvexPolygonShape3D&lt;/code&gt; of its six&#10;corners to walk on.&lt;/p&gt;&#10;&lt;p&gt;Eleven doors and fourteen traps sit on top, placed by world coordinates in&#10;&lt;code&gt;scripts/dungeon/dungeon_content.gd&lt;/code&gt;. A trap has no &lt;code&gt;Area3D&lt;/code&gt;. Every physics tick it turns&#10;the party&amp;rsquo;s position into its own local space with &lt;code&gt;to_local()&lt;/code&gt; and compares numbers.&lt;/p&gt;&#10;&lt;h2 id="splitting-the-work-across-git-worktrees"&gt;Splitting the work across git worktrees&lt;/h2&gt;&#10;&lt;p&gt;For the first 99 minutes there was no version control. Then the project became a local git&#10;repository with no remote, with &lt;code&gt;main&lt;/code&gt; in one folder and each branch checked out in a folder&#10;of its own beside it.&lt;/p&gt;&#10;&lt;figure class="shot"&gt;&#10; &lt;a href="https://birdoggydogs-builds.pages.dev/posts/2026-10-04-building-an-rpg-prototype-in-godot/five-monster-lineup.png"&gt;&#10; &lt;img src="https://birdoggydogs-builds.pages.dev/posts/2026-10-04-building-an-rpg-prototype-in-godot/five-monster-lineup.png" alt="Five blocky monsters in a row on an empty field, each a different color and size, with name plates: Gristle Spitter 200, Rind Stalker 280, Bile Seer 180, Marrow Mender 150 and Slab Warden 480." width="1280" height="720" loading="lazy" decoding="async"&gt;&#10; &lt;/a&gt;&lt;figcaption&gt;Five monster types built in one worktree while doors and traps were built in another. There is only one monster model, so they differ by tint and height.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;I wanted to work on the map while features were being built. A &lt;code&gt;git worktree&lt;/code&gt; folder is a&#10;whole Godot project that opens separately. Godot&amp;rsquo;s import cache, &lt;code&gt;.godot/&lt;/code&gt;, is in&#10;&lt;code&gt;.gitignore&lt;/code&gt;, so each folder imports the assets once and keeps its own cache. A&#10;&lt;code&gt;.gitattributes&lt;/code&gt; line, &lt;code&gt;* text=auto eol=lf&lt;/code&gt;, keeps line endings the way Godot writes them.&lt;/p&gt;&#10;&lt;p&gt;The bigger pieces each went to a background agent in its own worktree. An agent leaves its&#10;changes uncommitted. Nothing is merged into &lt;code&gt;main&lt;/code&gt; until I say &amp;ldquo;merge&amp;rdquo;, and then the clean&#10;worktrees are fast-forwarded to the new &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The first room, the opening&amp;rsquo;s story, the dialog and the interface wiring were all in one&#10;script, &lt;code&gt;scripts/vat_bay.gd&lt;/code&gt;, so two branches would collide there. The rule was that each&#10;piece goes in a new script and touches existing files as little as it can. The dungeon&amp;rsquo;s&#10;whole footprint in &lt;code&gt;vat_bay.gd&lt;/code&gt; was a doorway cut in the yard&amp;rsquo;s north wall and one line,&#10;&lt;code&gt;add_child(Dungeon.new())&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Monster behaviour got the same shape. &lt;code&gt;Monster&lt;/code&gt; is a &lt;code&gt;CharacterBody3D&lt;/code&gt; that supplies verbs&#10;(&lt;code&gt;move_toward_point&lt;/code&gt;, &lt;code&gt;face&lt;/code&gt;, &lt;code&gt;can_see&lt;/code&gt;, &lt;code&gt;try_melee&lt;/code&gt;, &lt;code&gt;try_ranged&lt;/code&gt;), and a behaviour is a&#10;small script in &lt;code&gt;scripts/ai/&lt;/code&gt; that only decides which verbs to call:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript" data-lang="gdscript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;fight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;monster&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Monster&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;party&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Party&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;monster&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;distance_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;party&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;monster&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;melee_range&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;monster&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;move_toward_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;party&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;global_position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;monster&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="n"&gt;monster&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;face&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;party&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;global_position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;monster&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;melee_range&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;monster&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;try_melee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;party&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;_sees&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;monster&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ranged_range&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;&lt;span class="n"&gt;monster&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;try_ranged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;party&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s the whole hybrid type (&lt;code&gt;scripts/ai/hybrid_ai.gd&lt;/code&gt;). The five types share &lt;code&gt;ChaseAI&lt;/code&gt;, a&#10;state machine with three states: idle, chase and return. A monster notices the party within&#10;18 m in plain sight and gives up beyond 32 m, or after 8 s without sight. There&amp;rsquo;s no&#10;pathfinding, so a monster that hasn&amp;rsquo;t walked home in 45 s is put there.&lt;/p&gt;&#10;&lt;p&gt;The night had one merge conflict, where two branches each added a line at the same spot in&#10;&lt;code&gt;scripts/party.gd&lt;/code&gt;. Both lines were kept. It also had one duplicate. My request for the&#10;monster agent reached the doors-and-traps agent too while that one was being resumed, and&#10;it started an agent of its own for the same job in a new worktree. The duplicate was&#10;stopped, but its worktree is still there.&lt;/p&gt;&#10;&lt;p&gt;Nothing was played by hand that night. Every check was a scripted run with simulated key&#10;presses and screenshots.&lt;/p&gt;&#10;&lt;h2 id="also-done"&gt;Also done&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;A setting. The brick test room became Culture Bay 7, a worn-down sci-fi meat plant where a party of four is grown at the Meat Machine.&lt;/li&gt;&#10;&lt;li&gt;An NPC I can talk to with E. A chat bubble by his head lists numbered options, grey until I&amp;rsquo;m within 3.5 m.&lt;/li&gt;&#10;&lt;li&gt;One shared party inventory on the I key. Everyone starts with a stock heart, lungs and liver that change nothing and are flagged &lt;code&gt;requires_surgery&lt;/code&gt;, so &lt;code&gt;unequip()&lt;/code&gt; refuses them.&lt;/li&gt;&#10;&lt;li&gt;Sound: 135 free clips from Kenney, plus ambience loops made by a Python script.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item></channel></rss>