Omer's playground

In pursuit of knowledge, driven by curiosity.


cat 2026-10-10-sigilreach-medieval-world-with-agents.mdx

Sigilreach: nine days, a thousand commits and a medieval world that lives without me

2026-10-10 · 19 min read

#gamedev#unity#urp#sigilreach#claude code#ai coding agents#TDD#optimisation#testing

table of contents

I have wanted to build a game for a long time. Not a weekend jam, a real one. Bellwright, Manor Lords and Kingdom Come scratched the itch, but every time I played them I ended up thinking about the same thing: the villagers are props. They stand where the designer put them. The village is there because someone placed it.

So on the 2nd of October I opened Unity and wrote a vision doc with one line at the top:

A medieval frontier built by its people: every villager wants something, the map changes because of what they do, and it goes on with or without you.

That’s Sigilreach. Nine days later the repository has 1,064 commits, about 45,000 lines of pure C# game rules, 2,500 domain tests, a 6 by 6 kilometre generated land, a castle, a smithy, marriages, murders, outlaw hunts and villagers who think with a local language model.

I didn’t write most of that code by hand. A small crew of Claude sessions did, with me as the owner, the reviewer and the person who plays the build at night and comes back with a list. This post is about what that looked like, what broke, and what I would do the same way again.

Wear your helmets.


What the game is

Single-player, realistic, no stylisation, no magic. You wake up as a stranger near a lord’s stone castle with nothing in your pockets. Everyone else is a simulated person with traits, needs, a dream (to be rich, to lead the town, a home of their own, to answer to no one), memories and opinions of each other.

The pillars that shaped every technical decision:

  • NPCs make the world. Hamlets get founded because someone wanted to get away from a leader they hate. Villages split, feud, steal, raid and die.
  • Same rules for everyone. The player is an Actor. So is every villager. Gathering, crafting, building and fighting are the same code paths for both. If a villager can’t do it, the feature isn’t done.
  • No static villages. Nobody lays out a village. Streets are worn by walking, homes line them, buildings go up piece by piece with scaffolding and hauled materials.
  • Progress is material. No XP trees. Tools gate resources, stations gate recipes, people gate the top tiers.

That second pillar turned out to be the most important architecture decision in the whole project, and I’ll come back to it.


The stack

  • Unity 6.3 LTS with the Universal Render Pipeline, Deferred+ renderer on PC.
  • A pure C# domain (Sigilreach.Domain) with noEngineReferences. Every rule lives there: needs, combat, construction, crafting, society, the land itself. No UnityEngine anywhere.
  • Runtime is thin MonoBehaviour glue: bodies, input, UI, terrain streaming.
  • Editor generators build every scene, prefab and icon from code. Nobody hand-edits a scene. If something looks wrong you change the generator and rebuild.
  • Content as CSV with stable ids. Items, recipes, nodes, building pieces, blueprints, creatures, lessons. Code asks for components and tags, never for a specific id.
  • A local LLM for the villagers’ minds: LLM for Unity (llama.cpp) with Qwen3-4B in 4-bit GGUF, about 2.5 GB, shipped inside every build and on by default.

Why the domain is engine-free

This is the decision everything else rests on. Because the rules don’t know Unity exists, they compile with plain dotnet as netstandard2.1 with C# 9, the same API level and language version Unity uses. Which means:

  • tests run in seconds without the editor;
  • they run in the cloud, on agents that will never see a Unity window;
  • they run while I’m in Play mode or the editor is busy rebuilding a 6 km world;
  • a whole village can be simulated headless for 30 in-game days and print a chronicle of who married whom, who stole, who froze to death and why.

That last point is how you debug emergent behaviour. You don’t. You read the chronicle.

Why URP and not HDRP

People ask this every time I show a screenshot, so here it is. The decision is in the vision doc, dated day one:

URP over HDRP because it has wider asset-store support and is where Unity’s renderer investment is going; the realistic look comes from assets and lighting.

In practice that played out like this:

  • The asset store is a URP world. Every pack I bought ships URP materials or a URP package for Unity 6.3: NatureManufacture’s Meadow and Forest, The Tales Factory’s characters, the castle kit, GameAssetFactory’s farm buildings, MicroSplat’s terrain shader. One character pack I passed on was HDRP-first, and that was one of the reasons.
  • The realistic look comes from assets and light, not the pipeline. MicroSplat for the terrain with anti-tiling, NatureManufacture’s water, a proper grade with ACES and bloom, volumetric clouds, a time-of-day light that changes colour through the day.
  • What we gave up, honestly: Unity’s Physically Based Sky and volumetric clouds are HDRP-only. URP 6.3 has no screen-space reflections, and planar reflections are too expensive on a 6 km land, so water reflects the sky through environment reflection. For clouds we vendored an MIT-licensed URP volumetric clouds and sky project instead of paying for one.

There are two more reasons that I believe were in play but that the project never wrote down, so take them as my reading:

  • The world is huge and the frame budget is shared. A 6 km streamed land, hundreds of simulated people, a language model on the same GPU. URP’s lighter per-frame cost leaves room for the simulation and the mind.
  • The main dev machine is a Mac. URP is the comfortable path on Apple silicon, and the builds I play are macOS builds.

The asset hunt

There is no art team. There is me, the Unity Asset Store and a budget I kept trying to stay under. The repo has an ASSETS.md with every pack, what it was bought for, what it cost and what was rejected and why. Total so far is about $609 for everything installed, with about $838 estimated to a vertical slice, down from the first estimate of $1,100 to $1,250.

The buying rules we settled on:

  1. Buy for the current milestone. Exception: a real sale on something we’re sure to need.
  2. Test-import before relying on a pack. Under our URP lighting, check Unity 6.3 compatibility, polygon counts, LODs and material count.
  3. One visual family per category. A subset of one pack beats a mix of three styles.
  4. Import only the sub-packs you use. Bundles unlock access; each pack downloads separately and some are many gigabytes.

The rejections taught me more than the purchases:

  • A modular house pack at $99.99 turned out to be about a million triangles per hut. I bought GameAssetFactory’s Medieval Farm Modular for $39.99 instead: 3 m grid, sane triangle counts, real thatch and timber framing. That one decision is why villagers can build houses piece by piece today.
  • A castle pack had 100K-triangle walls and gothic Roman ruins. Another had 2M-triangle tents. Another was a French château. The castle we use was €22.77 on a 55% sale instead of a $120 alternative.
  • A character pack at €202 had fixed variants at 59 to 93K triangles each and no soldiers. The Tales Factory’s modular characters map straight onto our clothing slots, so a crafted tunic shows up as a tunic.
  • A grain crops pack got rejected for being mobile quality.
  • Deer and domestic animal packs at €184 and €165 got dropped when PROTOFACTOR’s full animal pack showed up at €69 on a 50% sale with both.

And the gotchas nobody warns you about:

  • One character pack’s URP sub-package re-assigns a material’s GUID and leaves it on the Standard shader. Pink head. You fix it by hand once and write it down.
  • The castle pack’s particle materials use the built-in particle shader, magenta in URP. Its bonfire now runs on our own URP particle materials made from its textures.
  • The castle pack also had a custom grunge shader Unity 6.3’s URP can’t compile. Moved to URP Lit.
  • The women’s skirts are open tubes of single-sided cloth. When a stride shears the skinned mesh at a distance, the inside turns to the camera, isn’t drawn, and you get a woman with no legs below the knee.

Two rules came out of all this that I’m happy with:

  • Store packs never go into git. They’re gitignored, and ASSETS.md has the exact import steps per pack. A fresh clone plus the packs plus one menu click rebuilds the whole game.
  • There is no fallback art. Early on we had a “Greybox” art set so the game ran without packs. It doubled every generator and every test. On day six I deleted it entirely. Nobody should run this game without its art.

On day four I also changed my mind on AI-generated art. It’s allowed now, judged on quality like anything else, and disclosed on Steam at release. The first piece is the UI kit.


A world nobody laid out

The land is generated from one fixed seed: 6 by 6 kilometres, the same land every game, populated differently every time.

The first version laid rivers first and carved the land around them. Every valley floor sat at 0.5 m and the water was one flat sheet, so nothing could flow downhill and the whole 6 km read as a plain with 40 m of relief. We turned it around:

  • Heights first on an 8 m grid: mountain ranges along the edges, an inland massif, warped hill country, lowland basins.
  • Erosion on the grid: hydraulic droplets cut gullies and leave soft floors, then thermal erosion settles slopes into scree.
  • Rivers from flow: depressions fill into lakes, flow gathers downhill, the biggest channels become rivers, fords sit where the channel is wide and shallow.

All of that lives in the domain, so the simulation reads the same heights the terrain shows. Villagers pick where to settle and where to cross a river from the same Land model the terrain builder uses. No hand-finishing with terrain tools, ever, because that would break “same seed, same land”.

Water had to work as a rule too. Up to waist depth you wade at falling pace. Deeper, you swim at 0.45 of walking speed, no tools, no fighting, no carrying a log, and stamina drains. Run out and you’re carried to the nearest bank, losing health. From the farthest point of the biggest lake that’s under 80 health, so nobody drowns from full health. Villagers follow the same rules and path round by fords, because swimming costs 40 on the navmesh.


Optimisation, or: why the game froze at midnight

Builds that took forever

When the land went to 6 km, a clean rebuild of all scenes went to about 24 minutes. Most of that wasn’t C# compiling. It was our own generators redoing everything every time.

So every expensive output now keeps a fingerprint, a set of SHA-256 digests of its inputs: the land’s digest, the domain’s world sources, the generator’s own source, the store-pack assets it reads with their dependencies, and the Unity version. If nothing changed, it’s reused, and the console says so:

[Sigilreach] Cache: Oakhollow terrain tiles reused
[Sigilreach] Cache: icons rebuilt (changed: model)

A UI or rules change now needs no terrain rebuild at all.

The terrain tiles were about 936 MB sitting in Resources, which meant every player build repacked them. They moved to plain AssetBundles: one per 512 m tile plus a shared bundle, LZ4 compressed so a tile opens without decompressing the whole thing, built once and only copied into a player build. A release build verified at 198 bundles, 974 MB. Tiles stream around the player within 1.6 km (2.4 km on Ultra) and load in the background. And one lesson for free: building AssetBundles while a player build runs crashes Unity. Ask me how I know.

The day’s turn

I played a release build and the game froze for ten seconds or more every time the in-game day changed. Classic. A headless probe on the big land, 302 people, ticking 60 times a second through the hour before midnight, the turn and the hour after, found it in minutes:

before after
The tick at day change 30 to 42 ms 7 to 12 ms
First autosave capture of a session 3.2 to 3.4 s 30 to 45 ms
Later captures 70 to 95 ms 27 to 33 ms
JSON and the 22 to 29 MB file 250 to 490 ms on the game thread another thread

The first capture was asking every one of 62,000 resource sources whether it was a felled tree, and working out the land’s trees around each one to answer. Now only trees are asked. Regrowing every stump and fading worn paths moved out of the turn into a round spread over the next in-game hour. The JSON and the write moved off the game thread.

And then a test guards it: the turning tick may cost at most 3 times the 99th-percentile tick of the hour before it. Measured at 1.4. If someone puts a land-wide loop back into the day’s turn, the test fails before I ever play it.

Detail where it matters

Only villagers within 250 m of you run in full detail and ask their mind. Distant people run a cheaper simulation that has to end up in much the same place, and there’s a test that compares the two over whole worlds. The language model gets one request at a time by priority: your conversation first, then villagers within 30 m, then everyone else. Anything older than an in-game hour is dropped. If the model hasn’t started a spoken reply in 1.5 s, the rules’ answer is said first. About 2.8 s per decision on a macOS build.

The mind also only chooses among goals the simulation offers, in a JSON schema, and can lift a goal’s score by at most 0.25. Urgent needs still win. A 4B model is a fine source of “living, random-ish choices” and a terrible source of truth, the same lesson I learned with Home Assistant. The rules decide what’s possible.


Testing: fast without losing safety

This is the part I care about most. I’ve written before about TDD with AI and this project is that idea at full volume. Rules are test-first: the failing test, then the minimal code. Agents don’t get to skip it.

By day six the suite was hurting. An agent’s change took 16+ minutes of tests and a merge pass over 30. My instruction was simple:

I do not want to reduce the safety of tests, but I want to increase speed and efficiency.

Rules for that change: no test deleted to save time, the same assertions, and every card measured before and after. What came out:

Suite Before After
Domain rules 2 min 43 s 24 to 26 s
Long whole-world runs 14 min 57 s 2 min 24 s
PlayMode 16+ min, most tests loading the full 6 km world about 5 min in small scenes

Three things did it:

  • Release builds and parallel fixtures. Release alone took the domain tests from 157 s to 43 s. Parallel took it to about 22 s.
  • Small test scenes. Arena for combat, Yard for crafting, Hamlet for building and saves, Castle, Workshop, Commons. Each is about 192 m of the real art, loads in seconds, and is also a place I can open and try one thing.
  • Tiers. fast for every agent after every change. world for the long simulations, queued one at a time per machine. merge for the lead once per batch. full before every build I play.

Parallel tests found real bugs

Running fixtures in parallel immediately broke two tests in about half the runs. The cause: shared caches built with ??=. Two fixtures racing each got their own copy of the content, and a stick from one copy isn’t a stick to a simulation built from the other. They became Lazy<T>.

Then an audit found a nastier one. Blueprint.Footprint was built the same way, and some code compared footprints by reference. Five of six lordly worlds founded in parallel laid out their keep, bailey or hall differently from the same seed run alone. Same seed, different world, only when the race landed. Now there’s a rule (sims share no mutable state) and a test that releases threads together on a barrier and checks they all get one footprint. It failed 17 of 17 runs before the fix.

A simulation whose result depends on what else is running in the process isn’t deterministic. It just hasn’t been caught yet.

Golden seeds lie

World tests used to assert on one seed. Then a harmless change shifted the random numbers and seed 3 went from nine hamlets to four. Was that a regression or a seed turning? You can’t tell from one seed. So the world tests now assert a share of a block of seeds (five of eight worlds must pass, for example) run in parallel inside the test. A sweep before and after a change tells drift from a fall.

The NUnit trap

Unity ships NUnit 3.5. The fast tier runs on 3.14, because .NET 10 can’t pin anything older. So a test can pass in the cloud and fail to compile in the editor. The one that bites is Does.Contain on a collection. It’s in AGENTS.md in bold now.


Working with a crew of agents

None of the above happens without process, because several agents work on the same code at the same time. What we landed on:

  • A lead session on the Mac that integrates. Agents push branches, never main. The lead merges a batch, runs fast, then the affected world tests, the editor compile, a rebuild if a generator changed, the visual checks, and only then pushes. If a batch fails, git bisect --first-parent finds the branch.
  • A Windows desktop for editor work. It has the Unity editor and the packs, so PlayMode tests, rebuilds and experiments run there, one at a time.
  • Cloud sessions for rules. No editor, but the domain compiles and runs anywhere with .NET, so they write rules and tests and mark their PRs “PlayMode not run”.
  • Docs change in the same commit as the code. SYSTEMS.md, TESTING.md and the feature cards are what the next agent reads. A change that leaves a doc saying something false is not done. The docs are around 6,000 lines now and they’re the project’s memory.
  • Small files, one concern each. Several agents in one big file is a merge conflict factory. New goals get new files.
  • No runtime singletons. No static Instance. Systems are handed to what uses them, wired by the scene generator. This one came from a bug: the pause, sleep and the death skip each saved and restored Time.timeScale, and one restoring after another left the game at 8x speed. Now one owner holds the speed and everyone else takes and releases holds.

An audit day

Today was an audit day. A code audit produced a findings page, and the desktop took three branches. The interesting one: raised floors in the castle’s buildings were answering at ground height, half a metre low. The brief suspected Unity’s navmesh height mesh. The desktop wrote a probe that sampled every deck cell over four new games, ran it with the height mesh on and off, and found the height mesh wasn’t the cause. Bodies were being placed before the buildings were in the mesh on a load. Main had 70 of 156 deck cells wrong. The branch with the load wait had 0 of 104.

Same branch: creatures were being baked into the navmesh as solid capsules. A new test showed a 0.70 m hole around a deer without the fix. A reviewer then pointed out felled trunks had no test, so the next PR added one: a 0.83 m hole without the fix.

The other one I liked: a castle walk test was flaky “about 1 run in 4” on the Mac. The desktop probed 40 loads, half with the height mesh on and half off, then ran the real test 10 times on the branch and 10 on main. It never failed. So it changed nothing and wrote down the numbers and the suspect. No fix without a failure to test it against. That’s the right call, and it’s not one I expected an agent to make on its own.

Things agents are still bad at

Honest list:

  • Looking at the game. Agents don’t judge visuals. I do, in the editor, at day, golden hour and night. Every visual card ends with “owner checks” written as pass and fail, and a lot of them are still pending.
  • Knowing when “it works” isn’t “it’s right”. A world can pass every test and still look wrong when you walk through it. Only playing catches that.
  • Animations. I had to make it a rule: an animation finishes before the next action starts, for everyone. Lying down, getting up, kneeling into work. Without it everything looks glitchy. It’s data on the generated animator now (Settle and Settled tags), not a timer per case.

What I’d do the same way again

  1. Engine-free rules from day one. It’s the reason tests take a minute, cloud agents are useful, and villagers can do everything the player does.
  2. Generators, not hand-made scenes. Painful to set up, but every scene is reproducible, reviewable as code and cacheable.
  3. Measure before optimising. Every speed-up in this post started with a probe that printed numbers, and ended with a test that keeps them.
  4. Docs as the shared brain. With many sessions and none of them remembering yesterday, the docs are the only continuity there is.
  5. URP. I haven’t missed HDRP once. I’ve missed good assets many times.

What’s next

The villagers’ AI gets a regression net next: a set of measures over many seeds that says whether a change made the world better or worse, before I play it. Then bows, sieges, the castle redesign, footsteps that make a sound, and a lot of owner checks I owe the crew.

Nine days in, the world already does things I didn’t plan. A home-dreamer builds a lean-to and a kind neighbour helps. A greedy hoarder gets caught pilfering and resented. Men sent on an outlaw hunt sided with the outlaws because family and friends of the band were among the hunters, and now there’s a test for that.

That’s the whole idea. I just have to keep up with it.

Till the next one. Stay hydrated.


cd ..