Skip to main content
monsters·January 20, 2025·5 min read

Monsters and NPCs in a weekend: reusing the generation pipeline

Characters were one creature. Monsters and NPCs were the other two. The pipeline composed: the only kind of reuse that pays.

J
Jean P.Founder

The character generator wasn't really about characters.

In my head it was about creatures. Player characters are one kind of creature in a campaign. Monsters are the antagonist creatures. NPCs are everything else: the shopkeeper, the quest-giver, the talking raven, the bard at the tavern who'll probably die in act two. Three roles, one underlying idea: a thing in the world with a name, an appearance, a stat block of some shape, and a reason to exist.

I wanted all three.

The data didn't want to unify

The first instinct, when you've just built one entity type and you're about to build two more, is to make them share a table. One creatures table with a kind column. Polymorphism in the database. The DRY reflex.

I tried to imagine that schema and it didn't compress.

Characters have classes, levels, proficiency bonuses, hit dice, spell slots, equipment, and a backstory section that's longer than the rest of the sheet combined. Monsters have challenge rating, legendary actions, lair actions, damage resistances, and a tactical block that explains how the thing fights. NPCs have an occupation, a relationship to the party, a disposition, a voice, and most of the time, no stats at all. Forcing all three into one table meant either a wide table with a lot of nulls or a base-plus-JSON-blob design that's painful to query and worse to validate.

Three tables. Three tRPC routers. Three schema files. The duplication is the cost, and the cost was smaller than the alternative.

What actually composed

The data didn't want to share, but the verbs did.

The generation pipeline didn't care what was being generated. The task graph (text generation, then portrait, then full body, then 3D model) only needed to know that something was being generated, that the something had an ID, and that the something would have a status the UI could poll. The same task code runs for a half-elf bard, a frost giant, and a tavernkeeper named Hildebrand. The only thing that changes is the prompt.

The dialogs composed too. ModelGenerationDialog and AvatarGenerationDialog didn't know whether they were generating a character or a monster: they took an entity ID and a kind, hit the right tRPC router, and watched the same status field. The progress bar, the preview, the toast notifications, the retry button: all of that lived above the entity type and didn't change when a new one showed up.

The pattern is the one I should've expected and didn't fully see until I'd shipped it twice: duplicate the data, share the verbs. The data is where the entities differ: that's the whole reason they're separate entities. The verbs are where they don't: generating, rendering, exporting, sharing. Squeeze the reuse out of the verbs and let the data spread out.

Monster Day

The actual implementation went fast. Faster than I expected, which is what made me trust the pattern.

There was a stretch where the monster work happened in roughly a day. Schema, router, generation prompt, sheet UI, preview, sharing, model generation hookup. The schema was the only piece that needed real thought: the rest was wiring familiar parts to a new entity. The generation prompt took a few iterations to land on something that produced monster stat blocks rather than character backstories with combat stats stapled on, but that was a prompt-engineering problem, not an architecture problem.

NPCs followed within days. NPCs are the simplest of the three because most of the time they don't have stats at all: they just need a name, a description, an appearance, and an optional combat block for the ones that fight. The schema was small. The generation prompt was the simplest of the three. The sheet UI mostly inherited from monsters minus the parts that didn't apply.

By the time NPCs shipped, the codebase had three entity types, three tables, three routers, and one generation pipeline serving all of them. The pipeline didn't grow. The dialogs didn't grow. The trigger tasks didn't grow. The thing that grew was the surface area of what the product could generate, and that's the kind of growth I wanted.

Multi-format export

The other piece that landed in this stretch was multi-format export. GLB, OBJ, and STL, all hitting the same export path, all working for all three entity types.

This was the cleanest possible test of whether the pipeline really did compose. The export code didn't have a switch (entityType) anywhere: it took a generated 3D model and a target format, and produced a file. If a future entity type showed up (terrain, props, vehicles) it would land in the same export path with no changes.

That's the actual win of getting the architecture right the first time. Not "I wrote less code," but "the next thing I add doesn't make the existing thing more complicated."

What the stretch shipped

A character generator. A monster generator. An NPC generator. One generation pipeline serving all three. Multi-format export across the board. Three sheet UIs that looked like they belonged to the same product without pretending to be the same component.

The product was getting closer to something I could describe in a sentence. Not there yet. But every entity type that landed made the next one easier, and that's the part of building a platform that compounds.

SHARE
~ 5 min read · 889 words
Discord

Discuss this post

Join the D3 Designs Discord to share thoughts and follow along.

Join Discord