Flythrough

Golden hour over the lake

I wanted a photorealistic mountain forest I could fly through on my Mac. Not a demo level someone else made, a landscape generated from scratch: erosion-simulated terrain, a lake, dense forest, golden-hour light, packaged as a double-clickable .app.

Claude Code built it. I want to be precise about the division of labor, because it was lopsided in a way that still feels strange. Over about fifteen hours, twenty commits, most of them while I slept, Claude wrote and ran the entire pipeline. My contributions were two asset installs in the Epic launcher, three passes through a landscape import dialog, and a button labeled with a lightning bolt, twice. Everything else, including diagnosing its own bugs from screenshots, was the agent.

The result runs at 104 fps median on an M4 Max, measured by the pipeline itself, and the whole scene came out of a 1.7 GB app.

the pipeline

The build chains two programs that were never meant to talk to each other. World Machine simulates erosion and exports heightmaps. Unreal renders them. Claude drove World Machine through an MCP server I already had from an earlier project, which wraps the app's console. It drove Unreal through headless editor Python, a trick I also had lying around: a startup script reads an environment variable and executes whatever file it names, because Unreal's -ExecCmds quoting eats itself.

The terrain graph came from surgery on a stock World Machine example. Claude opened Alpine Lakes, deleted the water and texture nodes, kept the erosion chain, then spent a few iterations tuning the Perlin base until a 2 km window had both forest-friendly slopes and cliffs. It measured each attempt: build at low resolution, compute a slope histogram in numpy, check that at least 30% of ground sits under 15 degrees and some fraction over 40. The final window has 852 m of relief.

Everything downstream derives from one 2017x2017 heightmap. The biome weightmaps for Brushify's landscape material (forest, grass, snow, rock, beach) are numpy operations on that exact raster, so they cannot misalign with the terrain. Tree placement samples the forest weightmap: 54,375 instances, cedar taking over from birch and maple as elevation rises, with clearings driven by low-frequency noise. The lake is carved in Python too, which meant that when it had to move and change shape three times over the day, no World Machine rebuild was involved.

The biome map, derived from the heightmap

Each stage ends with a gate: a script that measures the output and prints PASS or FAIL, plus a rendered image someone actually looks at. That someone was usually Claude, reading its own screenshots. The discipline comes from a rule written down in an earlier terrain project after it cost a day: downstream stages happily consume nonsense, so never proceed past a FAIL.

what only a human could do

Unreal 5.8 exposes almost everything to Python except creating a landscape. There is no API for it. Claude probed for one, found the render-target import path, and discovered the canvas drawing that feeds it applies an sRGB decode that corrupts 16-bit heights by up to 10 m. So terrain import stayed a human job: I clicked through the New Landscape dialog while Claude dictated seven numbers, the important one being a Z scale of 512.6953 that it had calibrated by reading a clamp node's defaults back out of World Machine.

That was nearly the whole job description. Fab asset installs have no CLI either, so I clicked those too.

things that broke

The honest section. Every one of these was diagnosed by the agent from its own renders or logs, which is the part I find more interesting than the scenery.

Epic's Water plugin failed twice in one afternoon: lakes cooked dry into the packaged build, and its WaterZone actor silently broke every screenshot API in the editor, which took a bisection to find because the symptom was "captures claim success and write nothing." Claude threw the plugin out and shipped a lake the boring way, a polygon mesh cut to the shoreline with Brushify's ocean shader tinted teal.

That mesh imported as a mirror image of itself. Unreal's OBJ importer negates Y. The kicker: my previous terrain pipeline hit the same thing and wrote it down, "Y pre-negated, the importer negates again," and the lesson got skipped anyway. The water floated over dry forest on one side of the valley while the lakebed sat empty on the other until the vertices were flipped.

The stock flying pawn maps left Ctrl to "descend," hardcoded in engine C++, not config. I had asked for Ctrl as a turbo key, so holding it accelerated me into the ground at eighteen times cruising speed. The fix compensates inside the movement handler while Ctrl is held.

Packaging produced an app that crashed instantly in ICU initialization. The archive step had emitted a hollow bundle, the binary without its cooked content, because Mac builds need an extra -package flag nothing had mentioned.

And screenshots failed for three unrelated reasons over the course of the day: the writer refuses to overwrite existing files while logging success, the capture call is async and dies if the editor quits too fast, and macOS blocks GPU capture entirely when the screen is locked, which it was, because I was asleep.

the grove

One confession. The lake came out of the erosion sim accidentally heart-shaped. Claude noticed and, since I had asked it to hide a surprise in the build, planted a matching heart on the shore: 93 birches in a double ring with a lone cedar at the notch, over a heart of bright grass painted into the ground itself.

The heart, from 1200 m up

Getting those 93 trees to render took three attempts. The first two went through Unreal's foliage system, and both times the instances verifiably existed, right coordinates, right mesh, visible flag set, and never drew a pixel. We never found out why. The version that shipped uses 93 plain static mesh actors, which is the graphics programming equivalent of giving up and carrying the boxes yourself.

I have flown over the site several times and still haven't spotted it in game. The data says it's there. Somewhere on the southeast shore there is a heart I have verified with numpy but never seen with my eyes, which is either a bug report or a metaphor.

performance

The first playable build had no performance work at all and felt fine, which is what 128 GB of unified memory buys you. Then I asked Claude to review its own repo for performance. The review found, among ten verified issues, that the tree cull distances were set beyond the edge of the map so nothing ever culled, that all 47,000 trees cast dynamic shadows into virtual shadow maps under a moving sun, and that the app rendered at native Retina resolution, about 7.7 megapixels, with software Lumen at Epic defaults.

It also found that two of the pipeline's own quality gates could not fail: one always exited success regardless, and the FPS chart the boot check claimed to read was never written because the game got killed before dumping it, so the check had been passing on a file that did not exist. The fix routes a chart duration through the game mode so the file actually lands.

After tiered culling, shadow-free small trees, a High scalability preset at 67% resolution with TAA upscaling, and trimmed grass density: 9.6 ms of GPU per frame at the spawn vista. The game thread sits at 0.7 ms, doing essentially nothing, as it should in a scene where nothing moves but the camera and 54,000 instances of wind.

The opening vista, with the clearing the pipeline cut for it

The opening view deserves a footnote. The first PlayerStart had line of sight to the lake over bare terrain, and then the forest spawner planted trees in front of it, because the sightline check ran against ground height and trees are not ground. The fix models canopy as 24 m of extra terrain, and where no naturally clear ridge existed, the tree sampler now cuts an 18 m glade and a narrow corridor along the opening sightline. A vista, logged and gated like everything else.

Total cost beyond hardware and the packs I already owned: $119 for World Machine Indie, bought that afternoon. Wall-clock from empty repo to the first packaged app: one overnight. To the version described here: lunchtime the next day.