Heroes Required: The Machine That Writes the World
How the authoring tool generates seasons, and how the weekly engine keeps them alive · devlog gamedev csharp ai
The previous post covered how the rules engine works — deterministic outcomes, the canon firewall, the 52-week test. This post is about what happens before any of that: how content gets into the game, and how it keeps changing after it's there.
There are two separate problems here, and they needed two separate systems.
Problem one: Creating a new story season is a lot of work. 40 named NPCs, each with personality profiles, cultural alignments, and narrative roles. 60 hireable vassals spread across five specialist classes. 10 story chapters. A zone map with locations that connect meaningfully. Writing all of this by hand is a wall we'd never get over.
Problem two: Once content is in the game, it can't just sit there. An NPC who says the same thing in week 1 as week 12 isn't a character — they're furniture. The world has to actually move.
The same AI that creates the content updates it. But the two pipelines are completely different, because the risks are completely different.
World Forge: Authoring Tool for New Seasons
World Forge is the developer-facing content pipeline. It's not a visual editor — it's a set of API endpoints we call when we want to build something new. The workflow is four steps.
Step 1 — Import the Lore
Before generating anything, we feed the engine the source material: backstory documents, cultural profiles, historical events, world rules. Anything that should inform what gets created.
POST /api/worldforge/lore/import
{ "name": "The Dragon Accord", "rawContent": "..." }
The import service splits the document into chunks, runs each through OpenAI's text-embedding-3-small, and persists the vectors to the database. That's the RAG library — when we generate a season, the prompt assembler searches this library and pulls the most relevant chunks into context. The AI writes NPCs that are actually aware of the world's history, because it has the history in front of it.
Step 2 — Generate the Season
One request, a few minutes, a lot of output:
POST /api/worldforge/season/generate
{
"theme": "The Dragon Accord Reconsidered",
"toneGuide": "Hitchhiker's Guide absurdity. Zork precision. Dirk Gently causality. Absurd is allowed; random is FORBIDDEN.",
"requiredTruths": ["TRUTH_GREAT_FORGETTING", "TRUTH_TIAMAT_SCROLL"],
"loreSourceNames": ["The Dragon Accord", "Magic Rules"]
}
SeasonGenerator runs two passes. First, it generates the season skeleton: 40 NPCs, 10 story chapters, world truths consistent with the required canon. Then, with those truths available as context, it runs five parallel passes to generate 12 vassals per class — Sages, Scouts, Craftsmen, Defenders, Traders — each class knowing about the world the skeleton just defined.
The result is a draft. Nothing is live yet.
Step 3 — Generate the Zone Map
Locations are a separate pass that runs after the NPC roster exists, because placement depends on knowing who needs to be placed:
POST /api/worldforge/season/{id}/locations
The AI designs 8 locations (City, Dungeon, Camp, DragonLair, etc.) and places the NPC roster into them. It also seeds initial dialogue — 5 opening lines per NPC, generated at launch so the world isn't silent on day one. Up to 5 concurrent dialogue requests run in parallel; individual failures don't abort the batch.
Step 4 — Human Review
Everything generated goes into Draft state and gets an ApprovalRecord. We read through the output, make notes, and choose:
PUT /api/worldforge/approvals/{id}/approve { "notes": "NPC names approved, tone consistent" }
PUT /api/worldforge/approvals/{id}/revise { "notes": "Brunna's dialogue is too cheerful, revise" }
PUT /api/worldforge/approvals/{id}/reject
The revision path is where name changes and tone corrections happen. We flag the specific issue, the draft goes back into the queue with the notes attached as context, and the generator tries again. Nothing reaches SeasonState.Live until a human has explicitly approved it. This is enforced in code, not in discipline:
// Only the approved season becomes Live — the exporter reads nothing else
foreach (var s in current) s.CurrentState = SeasonState.Archived;
var season = await db.Set<Season>().FindAsync([approval.ContentId], ct);
if (season is not null) season.CurrentState = SeasonState.Live;
Drafts are invisible to the game. Approval is the gate.
The Weekly Content Job: Keeping Things Moving
Once a season is live, the content engine takes over. Every week, a background job runs the full mutation cycle in six steps.
Step 1 — Freeze World State
A snapshot of every NPC, relationship, subplot, and usage count is taken at the start of the cycle. The mutation job works against this frozen state so mid-cycle changes don't interfere.
Step 2 — Select Mutation Targets
This is the part that replaces randomness with intent. MutationSelector scores every NPC using a composite formula:
var score = (tension - usage + 1) // social heat minus player attention
* (pressure.Total + 1) // narrative pressure from context
* storyRelevance // how tied this NPC is to active plot nodes
* instability; // Rewrite-flagged NPCs mutate more easily
Mutation rates by type:
| Target | Weekly rate | Logic |
|---|---|---|
| NPCs | 10–15% | Weighted by narrative pressure score |
| Minor subplots | 20–40% | Prioritises stale, Major/Rewrite-flagged nodes |
| Relationships | Tension-driven | Selected when Rivalry + Resentment − Trust > 50 |
| Dialogue & rumours | 50%+ | Highest rotation — the thing players read most |
An NPC who's been interacted with heavily gets a usage penalty — the engine backs off and lets other characters breathe. An NPC who's been dormant accumulates pressure and is more likely to be selected. This produces natural narrative rhythm rather than random churn.
Step 3 — Generate AI Mutations
Each selected NPC gets a mutation request: current state JSON, allowed mutation level (Minor, Major, or Rewrite), and the last few interaction history notes. The AI returns an updated goal, behavior profile, and new dialogue lines.
Every result goes through IContentValidator before it's accepted:
var validation = await contentValidator.ValidateAsync(result, ct);
if (!validation.Passed)
{
logger.LogWarning("NPC mutation for {Name} failed validation: {Count} violation(s).",
req.TargetName, validation.Violations.Count);
continue; // mutation is silently dropped, not applied
}
Failed validations are logged and discarded. The world gets the NPCs whose mutations passed; the ones that didn't are simply unchanged that week. No broken state, no partial updates.
Step 4 — Compile Behavior Trees
Approved NPC mutations include behavior tree definitions. BehaviorCompiler converts these into the JSON format Unity's BehaviorRunner ticks against — selectors, sequences, conditions, actions — with fresh content for this week's state.
Step 5 — Generate Transition Narrative
Before publishing, the job generates a "what changed this week" summary:
var summaryPoints = allNpcResults
.Select(r => $"{r.NpcName}'s goal changed to '{r.NewGoal}'.")
.ToList();
var transitionNarrative = await aiContent.GenerateTransitionNarrativeAsync(
new TransitionNarrativeRequest { Season = season, Week = week, SummaryPoints = summaryPoints }, ct);
This is available to us as a quality check and could eventually drive in-game news boards or loading screen copy.
Step 6 — Publish the Package
Everything bundles into five JSON files written to the filesystem:
content-packages/
└── Season01_Week03/
├── manifest.json ← version, season, week, required client version
├── npcs.json ← updated goals, moods, quest keys per NPC
├── behaviors.json ← compiled behavior tree JSON per NPC
├── dialogue.json ← new dialogue lines per NPC
└── relationships.json ← relationship score mutations
Unity polls GET /api/content/version at session start. If the version number has changed, it downloads whichever files are new. If the backend is unreachable, it falls back to the local cache from the previous session. If there's no cache, it falls back to a bundled sample baked into the build.
Three fallback levels, because players shouldn't see a blank Notice Board just because the backend is having a slow day.
What This Buys Us
The two pipelines solve different problems the same way: the AI does the heavy lifting, humans control what reaches the game.
For season creation: a theme, a tone guide, and some lore imports is enough to generate a playable season skeleton in a few minutes. We review it, fix anything that's off, and approve. What would have taken weeks of manual writing takes an afternoon of review.
For weekly updates: the mutation engine decides what needs to change based on narrative pressure and usage patterns, the AI decides how it changes, the validator decides if the change is allowed, and the package lands in Unity without anyone touching a file manually.
The important property isn't that it's fast. It's that every layer can reject what the layer above it generated. The validator rejects bad AI output. The approval workflow rejects bad season drafts. The canon firewall rejects mutations that touch immutable truths regardless of what anyone approved.
Every strange event requires forgotten logic. That's the tone guide we give the AI. It's also the design principle for the whole engine.
Next post: the Unity side — how BehaviorRunner ticks those behavior trees, how WorldSyncService keeps the export fresh, and what the player actually sees when all of this resolves into an NPC on screen.
Log in or sign up for Devpost to join the conversation.