The last post was about the invisible part — the abstract simulation that keeps a town breathing even when the player isn’t there. This post is about what happens when the player is there, and one of those simulated NPCs needs a body.
We use Unreal’s Mass Representation system to handle that handoff. When an NPC is far away, it’s just data — a position, a destination, a small bundle of fragments riding the ECS train. When the player gets close, Mass spawns an Actor to represent that entity. When the player leaves, the Actor goes away and the data keeps simulating.
That sounds simple. It is not.
The first version of our NPC pipeline used placed Actors — characters dropped into the level by hand or spawned from a subsystem at startup. They were full Actors all the time, AIControllers and Behavior Trees and all, regardless of whether the player could see them. That worked at small scale. At village-scale it absolutely did not.
So this week the job was tearing that apart and reseating everything on Mass:
┌────────────────────────────┐
│ Mass Entity (data only) │
│ FAR FROM PLAYER │
└──────────────┬─────────────┘
│ player gets close
▼
┌────────────────────────────┐
│ Spawn Actor (the puppet) │
│ - copy mesh + state │
│ - wire movement comp │
│ - parent to entity │
└──────────────┬─────────────┘
│ player leaves
▼
┌────────────────────────────┐
│ Despawn Actor │
│ Entity keeps simulating │
└────────────────────────────┘
The interesting bit — the part that ate most of the week — is the wiring step. Mass doesn’t just spawn an Actor and walk away. Several fragments inside the entity need to be told which component on the spawned Actor they own: the movement component, the capsule, the scene root, the agent radius. If any of those wires are missing or stale, the Actor and the entity will gracefully disagree about where the character is — the entity off doing its errand, the puppet rooted to the spawn spot.
We had a version that did exactly that. The entity would walk to the coffee shop. The Actor would just stand there looking dumb.
The fix was a custom UMassRepresentationActorManagement subclass that takes over the post-spawn step. It manually wires the wrapper fragments to the puppet’s components, sets the movement mode and a couple of physics flags the engine doesn’t set automatically when there’s no PlayerController in the loop, and — importantly — does not re-run construction scripts on the spawned Actor. Re-running construction was the silent killer: it would reset the movement component back to defaults, and then the entity and the puppet would drift.
There were also some quality-of-life problems to clean up along the way. The State Tree tasks driving NPC behavior were too eager to fail when their data wasn’t ready yet. An NPC waking up at 6am before SmartObjects had finished loading would emit a hard error and stop for the day. The new behavior is: park in Running, log at verbose, try again next tick. The number of NPCs in the village that are now patient with each other has gone up dramatically.
On the side, we ended up writing some asset cleanup tooling. A year of importing marketplace packs from multiple vendors means we’ve accumulated duplicate .uasset files scattered across the project — the same prop shipped in three different bundles, the same texture re-saved under a slightly different name. The new audit script hashes every asset under /Game/, groups by content, and produces a CSV of duplicates sorted by reclaimable disk. The first dry run found more than I want to admit. That’s a job for a future post.
The takeaway is the same as last time: abstraction has a cost. Mass keeps the town breathing on a handful of frames per second. Mass Representation makes those breathing characters become actual breathing characters when you walk up to them. The seam between the two is where most of the bugs live, and most of the week was spent down there with a soldering iron.
Worth it though. When it works, you don’t notice. Which is the whole point.
Discover more from Secondhand Carrot
Subscribe to get the latest posts sent to your email.
