Rebuilding a City That Runs Itself in UE5: A Field Guide from the Driving Core through Crowd AI to LLM-Driven Gameplay

This is an extension to the open-world series. The previous installment — “How to Build an Open-World Game: A City That Can Tell Stories” dissected what a mature commercial open-world RPG is *doing* — how it tells its story, how it makes a city look alive. This one takes a different angle: suppose we wanted to reimplement this “city that runs itself” on Unreal Engine 5. Which mechanisms must be reproduced, which can directly reuse the engine’s built-in systems, and which must be implemented from scratch?

The whole piece is grounded in a source-level reading of the original engine’s runtime, together with the contracts and already-shipped code of an in-progress UE5.8 rebuild project. We keep the series’ anonymization: we discuss only engineering, never naming the original. Throughout, “the source runtime” refers to the original C++ runtime under analysis, and “the rebuild project” refers to the UE5-side reimplementation.

A note on scope: this piece is closer to a development plan / rebuild blueprint — grounded in source-level analysis, it maps out *what* to reproduce and *how* to land it on UE5. Concrete implementation results and a runnable demo will be added later as the project progresses.

Introduction: The real difficulty of migration is not “translation,” it is “not flattening the graded state into a bool”

Acceptance ≠ completion: the staged-state spine and five “false-completion” signals
Fig 1 · Acceptance ≠ completion: the staged-state spine and five “false-completion” signals

Porting a proprietary engine’s city simulation to UE5 intuitively feels like a translation job: find the corresponding UE subsystems, convert the data formats, wire up the interfaces one-to-one. But once you actually read into the source runtime’s code, you discover a trap buried along this path — one that recurs constantly and is fully capable of wrecking the entire migration: mistaking “acceptance” for “completion.”

Almost every outward-facing API in the source runtime is asynchronous and staged:

  • Adding a person to the city: the call returns success, but that only means the request entered a queue — the person does not exist at all this frame;
  • A pathfinding query returning isReady = true does not mean a path was found — it may simultaneously carry three status bits: “start point not yet streamed in,” “node pool exhausted,” and “only half a path found”;
  • A workspot issuing a “play” command and returning true only means the command was enqueued — not a single frame of animation has been sampled;
  • Even when the traffic system’s global pathfinding fails, what it returns is a non-null empty path — the pointer is not null, but there is nothing inside it.

These are not bugs; they are the inevitable shape of an asynchronous engine under a fixed frame budget. A city can have thousands of agents and hundreds of vehicles on screen at once; no single system can carry “request → load → initialize → attach → register → playable” through to completion in one frame. So every system slices its work into stages, and each stage promises only “where my own layer has gotten to,” never making guarantees on behalf of downstream.

The first discipline of migration, therefore, is not “find the corresponding UE system,” but: preserve this staged/graded state on the UE5 side exactly as-is, and never flatten it into a single boolean value just to make an interface look clean. In the rebuild project this is written down as a non-negotiable contract — every runtime API must return structured status rather than a bare bool:

// The rebuild project's shared status vocabulary (shipped)
UENUM(BlueprintType)
enum class ECyberRuntimeStatus : uint8
{
    Unknown,        // No trustworthy status
    Requested,      // Request submitted, but no completion boundary crossed
    Queued,         // Waiting on some tick/job/frame queue
    Loading,        // Resource/data loading in progress
    Loaded,         // Raw asset loaded, but not necessarily initialized or playable
    Initialized,    // Runtime object's local initialization complete
    Attached,       // Attached to its owning world/scene
    Registered,     // Discoverable via registry/ID
    Ready,          // This module's readiness boundary reached
    GameplayReady,  // Cross-module gameplay consumers can safely depend on it
    Completed,      // The operation within the declared scope is complete
    Failed, Cancelled, Aborted, Defaulted, Skipped, Assumed
};

This enum is not decoration. It is the spine of the entire article: every system below — population, NPC behavior, smart objects, pathfinding, vehicles — is staged in the source runtime, and on the UE5 side must be carried by this same staged/graded state, or something like it. We will see the same move over and over: find a callback that looks like “completion,” prove that it actually only completes one layer, and then give it a dedicated status bit on the UE5 side.

The article first covers the driving core (what makes the whole city run), then dissects system by system along the chain “foundation (entities) → crowds → NPCs → residency → pathfinding → vehicles and traffic → cross-system readiness gates,” and finally lands on two more forward-looking matters — driving the city with an LLM, and migration trade-offs. Each system chapter has a fixed three-part structure:

  • ① The original design — how this mechanism actually runs in the source runtime (based on source-level reading);
  • ② Strengths relative to stock UE5.8 — for the same task, what UE5.8 provides out of the box, where the source runtime’s design is strong, and where UE5.8 is in fact better (this part pulls no punches — where UE5.8 is better, we say so);
  • ③ Porting to UE5.8 — exactly how to land it, which built-in system to reuse, what to build yourself, and where the traps are.

The reason “② strengths comparison” gets its own section is that the core of every migration decision is precisely this question: is this mechanism worth copying wholesale, or is UE5.8’s native approach already better? The answer differs system by system and must be weighed one by one. While reading, keep two threads in mind: what actually drives this city’s operation? (answered in Chapter 1) and what happens if you swap that “driver” for an LLM? (answered in Chapter 9).


1. The City’s Driving Core: What Makes This City Run

The driving core: phase scheduling + data-driven config + fact bus + budget
Fig 2 · The driving core: phase scheduling + data-driven config + fact bus + budget

We said “every system is staged and runs on signals,” but we have not yet answered a more fundamental question: what drives these systems? A city with thousands of agents, hundreds of vehicles, and dozens of quests on screen does not run each on its own independent thread; they all hang off a unified driving substrate. This substrate has four parts: frame-phase scheduling, data-driven config, an event bus, and a budget system. Understanding it is the only way to understand where the whole city’s “operating logic” comes from — and the only way to understand why every later system takes the shape of “staged, signal-driven.”

① The original design

Frame-phase scheduling. The source runtime is not “every object ticks itself.” Instead, it slices a frame into an ordered set of phases, and each system registers a specific job into a specific phase. A task scheduler orchestrates everything with priority-tiered channels, dependency counters, and wait lists: jobs are chained together by “fire only when the dependency count hits zero,” and cross-object update constraints (e.g., population must refresh before physics, interpolation must run after physics) are guaranteed by phase ordering rather than by each object deciding for itself. The core discipline is “dispatch and return; rely on signals for completion” — a job is dispatched and returns immediately; when it finishes it notifies downstream via a counter/callback, and nobody blocks waiting on anybody.

Concretely, for the systems above, *which phase they hang off* is carefully choreographed: population dispatches its pool-update job in the animation-update phase and waits for it to finish in the post-physics phase; vehicles split into pre-physics and post-physics phases; pathfinding runs in the camera-update phase; traffic dispatches a string of parallel jobs at the head of the frame; AI assigns LODs in the phase before bucket updates. This choreography guarantees the correct ordering of data dependencies — which is exactly why every system above is asynchronous and staged: they all live inside this phase scheduling; a frame’s budget is sliced and allotted by phase, and no one has the right to “own a whole frame and run start-to-finish.”

Data-driven config. The city’s “content” — every NPC’s attributes, every vehicle’s parameters, every quest’s structure, every interaction’s behavior — is not hard-coded but lives in a million-entry flat config store with hot reload support. Runtime systems fetch records from the store by ID, and records can be changed without recompiling. This is the vehicle for “content leaving the code and entering editable data” — a designer changing a value or adding an NPC type need not involve a programmer. Its discipline was mentioned earlier: graded success (the CRC only guarantees byte integrity, not semantic validity), Val<T> may bind to a default value, and a hot-reload notification does not equal consumers having rebuilt. Remember this store — it is the single most important interface for the LLM-driven approach later: the content an LLM generates ultimately lands as records written into this store.

Event / fact bus. Systems do not call one another directly; they broadcast state changes through the fact store (FactsDB) and a generator event bus. When a fact is written, it synchronously fires all listeners’ callbacks; an NPC’s spawn/death/stub-creation is broadcast to subscribers (quests, the anti-respawn system). The fact store is the city’s shared blackboard — the quest system reads it to evaluate conditions and writes it to advance the story; AI reads it to perceive the world; an interaction changing a device’s state writes it. This publish-subscribe arrangement is the key to the city’s systems “influencing each other” without “coupling to each other”: no one owns anyone; everyone reads and writes the same shared facts.

Budget system. Each frame’s workload is finite. Smart object resolution has a sub-millisecond budget, population attach has a count budget, traffic global pathfinding has a 2 ms budget, navmesh tile insertion has a 0.5 ms budget. Budgeting + carry-over + segmented time-slicing is the root reason this substrate degrades gracefully rather than crashing within fixed frame time — when the budget runs out, the remaining work queues to the next frame instead of stalling this one.

Put these four together and you have the “operating logic that drives the city”: phase scheduling decides who runs when, data-driven config decides what content runs, the event bus lets them influence one another, and the budget system makes everything converge within fixed frame time. Each system is just one mount point on this substrate. This is also where the title’s question gets answered — what makes the city “run itself”? Not any one clever system, but this substrate: it acts like a clock, slicing each frame into cells and handing them out to the systems, each of which reads data, writes facts, and spends budget within its cell.

② Strengths relative to stock UE5.8

UE5.8’s counterparts are scattered, and by default more “every man for himself”:

  • tick: UE has tick groups (PrePhysics/DuringPhysics/PostPhysics) and tick dependencies (AddTickPrerequisite); Mass has processor execution order/phases. This provides the skeleton for phase choreography. But by default UE is “each actor/component ticks itself,” and approximating the source runtime’s “unified phase scheduler + dependency counts + per-frame budget slicing” requires Mass’s processor graph plus a custom scheduler.
  • data: UE has DataTable, DataRegistry, GameplayTags. But “million-entry flat values + hot reload + graded success (found / defaulted / missing / schema-mismatch)” must be built on top of DataRegistry — which is exactly the part the rebuild project’s CyberDataRuntime has shipped.
  • events: UE has delegates, GameplayMessageSubsystem, and messages routed by GameplayTag. This approaches a fact bus, but the boundary of “synchronous callback vs. next-frame delivery,” as well as fact persistence (save games), must be defined yourself.
  • budget: UE has no unified “frame budget allocator.” Each system’s time-slicing must be written yourself.

In a sentence: UE5.8 gives you the parts for phases/data/events, but the whole — “a unified driving substrate” where one scheduler slices a frame across phases by budget, content is fully data-driven and hot-reloadable, and systems influence each other only through a fact bus — is an architecture you must assemble yourself.

③ Porting to UE5.8

The driving substrate is the foundation beneath the foundation; in the rebuild project it corresponds to a few shared plugins:

  • Phase scheduling: Express cross-system phases via Mass’s processor graph (UMassProcessor::ExecutionOrder); non-Mass systems use UTickableWorldSubsystem plus explicit tick dependencies. You can additionally add a UCyberFrameScheduler to centrally manage “which system runs in which phase and draws how much budget.”
  • Data: CyberDataRuntime (shipped) provides, on top of DataRegistry, a structured result ECyberDataStatus (Found / Defaulted / Missing / TypeMismatch / SchemaMismatch / ReloadPending) — this is precisely the UE-ification of the source runtime’s “graded success.”
  • Events: Use UGameplayMessageSubsystem or a custom UCyberFactsSubsystem as the fact bus, and for each API explicitly choose “synchronous callback or next-frame delivery” (the source runtime has synchronous re-entrancy; the UE rebuild must pick one explicitly); facts must be persistable into save games.
  • Budget: Every time-sliced system (population resolution, smart object resolution, traffic pathfinding) draws its budget from a shared FCyberFrameBudget rather than estimating its own.
// Rebuild project: a frame's budget is drawn from a shared allocator (design)
struct FCyberFrameBudget
{
    double SmartObjectResolveSeconds = 0.0004;  // 0.4ms
    double TrafficPathfindSeconds    = 0.002;   // 2ms
    double NavTileInsertSeconds      = 0.0005;  // 0.5ms
    double LlmDecisionSeconds        = 0.0;     // see Chapter 9: the LLM draws budget here too
    double Acquire(double& Pool, double Want);  // draw budget + carry-over
};

Why this chapter matters: it is the answer to “why all the later systems can be written this way” — staged, signal-driven, budgeted, all because they live inside this driving substrate. It is also the integration point for the LLM-driven approach in Chapter 9: an LLM does not drive the city out of thin air; what it drives is exactly this substrate — writing content into the database, writing facts onto the fact bus, being invoked in some phase under a budget. Understand this chapter and Chapter 9 becomes merely “hang one more producer on it.”


2. Foundation: Entity Lifecycle Is Never a Single Spawned Bool

Entity lifecycle: seven independent status fields + the unattached pool
Fig 3 · Entity lifecycle: seven independent status fields + the unattached pool

Before moving population and AI, you must first take apart “an entity coming into existence from nothing,” because every later system depends on it.

① The original design

In the source runtime there is no binary question of “is it spawned or not.” An entity’s birth spans at least seven non-equivalent stages: request submitted, entered queue, spawn token reports success, object constructed, components initialized, attached into the world, registered into a queryable table, and only then “gameplay systems can safely depend on it.”

The key point is that spawn-token-ready is not gameplay-ready. After the population system’s spawn token reports IsSpawned() true, the entity is merely “constructed” — it may not yet be attached to the world, the render proxy may not be built, physics may not be in place, AI may not have taken over, and the registry may not yet find it. Any downstream system that uses this entity the moment the token is ready gets a half-finished product.

More subtly, the source runtime allows an entity to spawn first and attach later: a spawned-but-not-yet-attached entity first enters an “unattached pool,” and is attached only when the budget allows. So “the object exists” and “the object is in the world” are two independent states. This “unattached pool” itself has an independent budget — it prevents spawning too many entities in one frame and exhausting the memory budget; even when spawn tokens have all succeeded, attach still has to queue. So an entity may sit for several frames in an intermediate state of “constructed, initialized, but still in the unattached pool waiting for attach budget” — fully invisible, non-interactable, and not found in the registry.

The cost of this grading is that the caller must be explicit about which layer it depends on. Downstream systems in the source runtime do not vaguely ask “is this entity ready”; they ask “has it reached gameplay-ready” or some explicit subset. This seems tedious, but it is exactly what lets timing defects such as “a quest dispatching dialogue to an NPC that is not yet ready” be caught at compile time / contract level rather than crashing randomly at runtime.

② Strengths relative to stock UE5.8

The entity lifecycle UE5.8 provides out of the box is coarse-grained: AActor has a handful of booleans like BeginPlay, HasActorBegunPlay(), IsActorInitialized(), and World Partition’s readiness is cell-level “is loading complete.” This is sufficient for “placing a few level props,” but for “a city with thousands of agents on screen, each possibly stuck in some intermediate state” it is far too coarse — it cannot distinguish “constructed but not yet attached,” “attached but not yet in the registry,” and “registered but AI not yet in control.”

The strength of the source runtime’s design lies precisely here: it cleanly splits “the object exists” and “the object can be depended upon” into multiple independently observable states, and adds a budgeted “unattached pool” layer. Downstream can therefore precisely wait for “playable” rather than “spawned,” eliminating timing defects like “dispatching a quest to an NPC that is not yet ready” at the source — which UE5.8’s coarse-grained booleans cannot do.

But to be fair: UE5.8’s Actor/Component + Subsystem model is itself perfectly adequate as the carrier — which is exactly why this layer could land directly in the rebuild project and serve as the template for the whole article. The source runtime’s strength is not some “mechanism” UE5.8 lacks, but the discipline of “grading state and never flattening it into a single boolean value.” The mechanism is borrowed; the discipline is self-imposed.

③ Porting to UE5.8

This layer has already shipped in the rebuild project and can serve directly as the template for all later systems. It uses no advanced systems whatsoever — just the plainest Actor/Component + WorldSubsystem — which is precisely the first principle of UE5 adaptation: use standard features before reaching for advanced systems.

The lifecycle is modeled as a set of parallel, independent status fields, not as a single progress value within one enum:

// Rebuild project, shipped: entity lifecycle, one independent status bit per stage
USTRUCT(BlueprintType)
struct FCyberEntityLifecycleState
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadOnly) FCyberEntityId EntityId;
    UPROPERTY(BlueprintReadOnly) ECyberRuntimeStatus Spawn          = ECyberRuntimeStatus::Unknown;
    UPROPERTY(BlueprintReadOnly) ECyberRuntimeStatus Construction   = ECyberRuntimeStatus::Unknown;
    UPROPERTY(BlueprintReadOnly) ECyberRuntimeStatus Initialization = ECyberRuntimeStatus::Unknown;
    UPROPERTY(BlueprintReadOnly) ECyberRuntimeStatus Attachment     = ECyberRuntimeStatus::Unknown;
    UPROPERTY(BlueprintReadOnly) ECyberRuntimeStatus Registry       = ECyberRuntimeStatus::Unknown;
    UPROPERTY(BlueprintReadOnly) ECyberRuntimeStatus Gameplay       = ECyberRuntimeStatus::Unknown;
    UPROPERTY(BlueprintReadOnly) ECyberRuntimeStatus Persistence    = ECyberRuntimeStatus::Unknown;
    UPROPERTY(BlueprintReadOnly) TArray<FString> Warnings;
};

Note there is no field called bSpawned. Spawn, construction, initialization, attachment, registry, gameplay, and persistence are seven independently observable states. When the quest or interaction system needs to depend on an entity, the contract mandates that they may only wait on Gameplay == GameplayReady or an explicitly declared subset, and must never treat success at the Spawn stage as gameplay readiness.

The registry itself is a UWorldSubsystem that provides structured queries — returning not a raw pointer but a result struct with bFound:

UCLASS()
class UCyberEntityRuntimeSubsystem : public UWorldSubsystem
{
    GENERATED_BODY()
public:
    UFUNCTION(BlueprintCallable)
    bool RegisterInstance(const FCyberEntityInstance& Instance);

    UFUNCTION(BlueprintPure)
    FCyberEntityQueryResult GetInstance(FName InstanceId) const;   // bFound + Instance

    UFUNCTION(BlueprintPure)
    TArray<FCyberEntityInstance> GetInstancesForZone(FName ZoneId) const;
private:
    UPROPERTY(Transient) TMap<FName, FCyberEntityInstance> Instances;
};

Why this chapter matters: it establishes the writing paradigm for every system thereafter — staged status, structured results, subsystem registry. The later rebuilds of population/AI/vehicles are, in essence, this paradigm applied to their respective domains.


3. Crowds: Cost Equals Distance, and the Three-Tier Degradation Is Three Representations on One Pipeline

Three-tier crowd cost model and bidirectional handoff
Fig 4 · Three-tier crowd cost model and bidirectional handoff

This is the core of cost control for the whole city, and also the part where the UE5.8 side can reuse the most engine capability.

① The original design

The source runtime’s population system is not an API that “spawns NPCs”; it is an entire orchestration layer. It coordinates among editor-authored community data, lightweight stubs, the runtime spawn service, traffic lanes, distant rendering, transient respawns, and save/load. Its core is stub-first:

A stub is a lightweight placeholder that exists before the entity does. It holds the record ID, world transform, appearance, generator ID, and cached component persistence state — but has no render entity. The system attaches a real engine entity only when the stub enters the “spawn shape” (a certain radius around the player); after an entity is recycled, the stub remains in place. The population system’s entry point for adding an entity even hard-asserts that “the entity must already have a stub.”

On top of the stub, the same “passerby” pays a three-tier cost by distance:

  • Full NPC: nearby, interactive, full behavior and animation, no component stripping, normal spawn-event broadcast. The most expensive.
  • Lightweight crowd stub: mid-distance. On spawn it applies a component filter by LOD, stripping out several categories of components (status effects, squad, visual perception, dismemberment, etc.), simplifies collision to a query shape, and stays silent without broadcasting (a performance optimization).
  • Distant crowd dot: far away. No stub or entity is created at all — instead there are thousands of pure-data “dots” whose positions are interpolated along traffic-lane splines, sent to the renderer in one batch for GPU instancing. The fact that a dot’s render data is valid does not mean there is an interactable person there.

The three tiers are a continuously degradable, bidirectional conversion: as a distant dot approaches, it issues a spawn request and is promoted to a stub; when a stub goes out of range and is deleted, it falls back to a distant dot; once a stub attaches an entity it becomes a full NPC, and when the entity is disposed it falls back to a bare stub. They are not three independent objects but three representations on the same lane, kept in mutual handoff via spawn requests and IDs.

This system’s engineering discipline shows up at two boundaries. First, “acceptance” and “landing” are strictly separated across frames and locks: every outward API (add entity, remove entity, update slot) only pushes a request onto a locked queue; the actual registration, spawning, attach, and despawn all converge into a single write-lock-holding job that executes serially. Second, visibility is an async dual channel: the system simultaneously maintains two independent visibility query results, “in view” and “fast-incoming view” — an entity in view is never robbed of budget by despawn.

Stepping back, the whole spawn pipeline is staged like this: external call to add entity → (accepted into the locked queue) → registration landed inside the single-threaded tick → bucketed and sorted by importance computed from distance/priority/visibility → attach or spawn attempted by importance → spawn token retrieved and pushed onto a queue → token completion polled → attach + broadcast. Both ends are async boundaries; only the middle stretch is the real “landing.”

Walkthrough: An NPC from request to readiness goes through six stages

Laying this pipeline open makes it clearer “why no step can be treated as completion.” In the source runtime these six stages are distributed across different thread contexts and different frames:

  • Stage A — Acceptance (any thread, locked enqueue). The external call “add entity” first hard-asserts that this entity already has a stub, then parses the record (person or vehicle), computes the template hash, picks an appearance, sets priority, and finally only pushes a registration request onto a spin-lock-guarded request set. This step lands nothing; the success returned only means “the queue accepted it.”
  • Stage B — Registration landed (single-threaded tick, write lock held). A unified pool-update job swaps the requests out, actually builds a registration record, caches the stub pointer and world coordinates; those that belong in the active set go to the active set, and “always spawn” ones go directly onto an immediate-spawn list. Only from here is there a “system-managed” entity record — but still no engine entity.
  • Stage C — Scoring and bucketing. Iterate the active set; for each candidate run the “in-view / fast-incoming view / at point-of-interest” determination, compute a score via an importance formula (distance × priority weight × type weight × height factor, plus in-view/POI bonuses), distribute into four arrays “in-view / about-to-enter-view / out-of-view / out-of-range,” each sorted in descending importance.
  • Stage D — Actual landing. For each bucket, try in importance order: teleport an already-attached one if possible, attach an unattached one if possible, otherwise actually spawn. Only at spawn is the spawn context constructed (template path, position from the traffic slot or the stub, skip attach, initially hidden/dissolved, component filter by LOD, spawn priority by priority), the spawn service is called to return a spawn token pushed onto the spawn queue, and the callback merely sets a “needs updating just-spawned” atomic flag. Spawning may also normally return failure due to spawn policy, zero-waste policy, cancellation, queue throttling, or insufficient unattached budget.
  • Stage E — Token-completion polling. That atomic flag triggers an “update just-spawned entities” step which polls the spawn queue: if a token reports spawned, the entity is extracted; those needing immediate attach go to scheduled attach, otherwise into the “unattached pool” (consuming unattached budget); if a token reports failure, the spawn-failure path is taken.
  • Stage F — Attach + broadcast. Scheduled attach sets the entity into the registration record, attaches it into the world (optionally with a fade-in), calls back the crowd system “population system spawned an entity,” and only non-crowd LODs broadcast the spawn event (crowds stay silent, a performance optimization), write blackboard markers, update budget.

Across these six gates, the “success” the outside world can see appears at Stage A, while “this NPC truly exists in the world and can be referenced by quests” comes only after Stage F. In between lie two async boundaries (the request queue, the spawn token) and at least one frame crossing. Any downstream system that uses this entity at any point in Stages A–E gets a half-finished product.

Despawn is the exact mirror image: a “can it be deleted immediately” gate decides — an unattached one can be deleted at once, a fading-out one cannot, otherwise it depends on whether it is still inside the full frustum (visible means it cannot be deleted at once). An entity to be deleted is first marked fading-out, removed from the active set, delivered a “dissolve-hide” event, and pushed onto the fade queue; only after the fade time elapses is it truly disposed, its budget returned, and (only for non-crowds) the despawn event broadcast. An entity in view is never removed immediately.

Where stubs come from, and who consumes them

Stubs do not appear out of nowhere. They have two production lines: one is the community system — an editor-authored “community” describes which people should be in a region and on what schedule they appear; when the region streams in, the community’s “entry generator” first reserves a workspot (a scarce resource — a failed reservation must recycle the ID, retry on a timer, with a retry cap), and only after obtaining the workspot’s world transform does it build the stub. The other is the crowd system — it first requests a traffic slot on a segment of a traffic lane, then builds the stub on the slot, with pedestrians preferring to land on workspots.

Once a stub is built, it is consumed in reverse by multiple systems:

  • The vehicle system adds and removes “vehicle stubs” through the population system, and after a vehicle stub attaches, hands the traffic-slot provider to the vehicle object and populates the vehicle with passengers;
  • The AI’s drive/move actions obtain a handle to the crowd system through the community system, performing traffic join/leave/complete;
  • The quest respawn/prevention system carries its own set of stubs and a pre-generated ID pool, subscribing to the generator’s “spawn/death/stub-creation” event bus to do quest-related dynamic spawning;
  • Scene streaming drives the community’s “region inflow/outflow,” deciding when to start building stubs and when to recycle.

There is also a technical-debt marker worth remembering: to ensure a story-critical NPC is never recycled, the source code implements “permanently extract a stub” as “ordinary extraction + a permanent counter” — because the quest still needs to access the extracted stub’s ID, so it cannot truly be extracted and forgotten. This kind of “suppress respawn with a counter” hack shows that stub state recovery (save/unload/re-activate) boundaries are extremely error-prone; the UE rebuild should replace it with a cleaner “owned / extracted” state machine rather than copy the counter hack.

Why this section matters: it shows the population system is the city’s hub, with a whole roster of downstream systems hanging off its event bus and stub primary key. The UE rebuild must preserve this hub status — the population’s stub ID should be the shared entity primary key across AI, vehicles, and quests, not each system rolling its own.

② Strengths relative to stock UE5.8

UE5.8 actually gives quite a lot here: MassEntity (in the MassGameplay plugin) is a mature data-oriented ECS, and MassCrowd provides a crowd-representation framework that switches between ISM / static actor / dynamic actor by distance — this is precisely the foundation the official city demo uses to hold up “a moving city.” So the hardest skeleton, “three-tier LOD representation,” UE5.8 does give you.

But the source runtime’s design still has three strengths UE5.8 does not provide out of the box and that must be filled in:

  • The stub’s lifecycle is independent of the entity’s. MassEntity defaults to “entity is data,” with no concept of “the entity is destroyed but the placeholder remains.” The source runtime’s stub-first design lets entities attach/dispose repeatedly while the stub stays put — so background communities don’t have to re-find an AI workspot every time, and the budget (attached / unattached) targets entities rather than stubs. This is the root of “saving” at large population scale.
  • Request and landing separated across frames and locks. All outward APIs only enqueue onto a locked queue; the real spawn/attach/despawn converge into a single write-lock-holding job executed serially. UE5.8’s Mass is a parallel-processor model and needs you to establish this “request queue → single-threaded drain” boundary yourself, or async spawn-completion callbacks will race with state modifications.
  • Visibility dual channel + in-view never robbed of budget. The source runtime’s “in view” and “fast-incoming view” are two independent results, and an in-view entity is never robbed of budget by despawn. Mass’s LOD is by distance only; this priority rule must be written yourself.

In a sentence: UE5.8 provides the “structural skeleton” (three-tier representation); the source runtime provides the “core semantics” (stub-first independent lifecycle, single-threaded drain, visibility priority).

③ Porting to UE5.8: prefer replicating the source runtime — a custom data-oriented population runtime

There is a substrate-level choice to make first: is the population data substrate a custom data-oriented runtime built the way the source runtime does it, or UE’s MassEntity?

This project’s orientation is to prefer replicating the source runtime — its population system is already a proven, complete data-oriented design, so rebuilding it 1:1 buys the highest semantic fidelity and the least effort spent “fighting the engine’s assumptions.” Concretely on UE5, that means reproducing it in plain C++: an SOA array of stubs, a budgeted UWorldSubsystem doing “request queue → single-threaded drain,” BVH/Octree spatial queries, UInstancedStaticMeshComponent for distant dots, and on-demand AActor attach for full NPCs — you own the storage and scheduling, and skip Mass’s ECS.

Mass is the optional other path (an upgrade, not the default): MassEntity (in MassGameplay), MassAI, and MassCrowd are all ready-made, and MassCrowd even ships a by-distance ISM/actor representation framework. Its value is saving the ECS plumbing and interoperating with the MassCrowd / MassTraffic / ZoneGraph ecosystem; the cost is that you must fit the source-runtime semantics below into Mass (which may not support them natively), and its entity handle is not a stable persistent ID. One connected effect: if population goes custom, you typically also forgo MassCrowd, and Chapter 7’s traffic already has to be self-built — the two together mean the city-simulation layer is almost entirely custom. This is a deliberate jump straight to tier ④/⑤ (self-build) in the “standard-first → upgrade” path, worth it insofar as you value fidelity and control.

Whichever substrate, the semantics below must be carried over verbatim — they are the source runtime’s real value. First pin down the tiers and the stub (“staged/graded state” and “stub-first”):

// Rebuild project: crowd tiers and stub (design)
UENUM(BlueprintType)
enum class ECyberCrowdTier : uint8
{
    DistantDot,   // pure data dot, ISM rendering, no actor (a fragment under Mass)
    Stub,         // lightweight stub, has ID/transform/persistent state, no render entity
    LightActor,   // actor with stripped-down components attached
    FullNpc       // full interactive NPC
};

USTRUCT()
struct FCyberPopulationStub   // exists before the entity; can attach/dispose repeatedly while itself stays put
{
    GENERATED_BODY()
    FCyberEntityId            EntityId;
    FCyberEntityDefinitionRef Definition;
    FTransform                Transform;
    ECyberCrowdTier           Tier = ECyberCrowdTier::Stub;
    TWeakObjectPtr<AActor>    AttachedActor;   // non-null only in LightActor/FullNpc stages
    ECyberRuntimeStatus       Attach = ECyberRuntimeStatus::Unknown;
};

The four boundaries below hold for either substrate and must be carried over, or the source runtime’s design intent is lost:

  1. You cannot use AActor as a stub. The UE developer’s instinct is “one NPC, one Actor,” but the source runtime’s budget (e.g., a hard cap on the number of simultaneously attached entities) targets the entity, not the stub. The stub must be an independent lightweight container (an entry in an SOA array if custom, a fragment under Mass); AActor/UInstancedStaticMeshComponent are created on demand only when entering the spawn shape and destroyed after fading out. A region can have thousands of stubs but only hundreds of actors at once.
  1. “Acceptance” and “landing” separated across frames and locks. This corresponds to a budgeted UWorldSubsystem: outward APIs only Enqueue requests onto a thread-safe queue, and the real spawn/attach/despawn are all drained single-threaded in Tick. Otherwise async spawn-completion callbacks will race with state modifications (the same boundary applies under Mass).
  1. Three-state handoff must be bidirectional. The dot↔stub↔npc conversion must be bidirectional and preserve the ID. If custom, it is just a method on the population subsystem (under Mass it corresponds to a representation processor, but “the stub exists independently of the entity” must be filled in yourself — Mass defaults to “entity is data,” with no concept of “the entity is destroyed but the placeholder remains”):
// Rebuild project: three-state handoff (a method on the custom subsystem). The stub is always present; actors come and go by tier.
void UCyberPopulationRuntimeSubsystem::UpdateTier(FCyberPopulationStub& Stub, float DistToCenter)
{
    const ECyberCrowdTier Desired = ChooseTier(DistToCenter);   // distance → target tier
    if (Desired == Stub.Tier) return;

    switch (Desired)
    {
    case ECyberCrowdTier::DistantDot:
        ReleaseActor(Stub);                 // destroy actor, keep stub + ISM instance
        break;
    case ECyberCrowdTier::Stub:
        ReleaseActor(Stub);                 // data only, even the ISM can be dropped
        break;
    case ECyberCrowdTier::LightActor:
        Stub.AttachedActor = AcquireActor(Stub, /*component filter=*/ELOD::Light);
        Stub.Attach = ECyberRuntimeStatus::Attached;
        break;
    case ECyberCrowdTier::FullNpc:
        Stub.AttachedActor = AcquireActor(Stub, ELOD::Full);     // no component filtering
        break;
    }
    Stub.Tier = Desired;   // EntityId never changes — this is the key to handoff not losing identity
}

The key is that EntityId stays constant across all conversions: a distant dot promoted to a full NPC and then demoted back to a stub is, to the quest system, always the same person. If custom, a stable FCyberEntityId (with FGuid) as the cross-tier primary key is all you need; under Mass, beware — Mass’s FMassEntityHandle changes after an entity is destroyed and rebuilt, so it cannot serve as a persistent identity.

  1. Visibility dual channel + async occlusion. Use async occlusion/visibility queries (to avoid per-frame synchronous rays), and “in view” vs. “fast-incoming view” must be two independent results. If custom, this is your own async query on the population subsystem (under Mass it corresponds to FMassVisualizationLODProcessor); either way, the priority rule “an in-view entity is never robbed of budget by despawn” must be written into your own budget-allocation logic.

The trade-off of this chapter: the population “soul” — stub-first independent lifecycle, single-threaded drain of the request queue, budgeted resolution, visibility dual-channel priority — must be written yourself whether custom or Mass. The difference is only the substrate: replicating the source runtime = 1:1 fidelity + full control, at the cost of writing the whole ECS plumbing (SOA storage, chunked iteration, parallel scheduling) yourself and forgoing the MassCrowd/MassTraffic ecosystem; using Mass = saving that plumbing + gaining the ecosystem, at the cost of bending to it and still building the “soul” anyway. This project takes the former — replicating the source runtime — and folds traffic into the same custom, data-oriented city-simulation layer.


4. The NPC’s Brain: Two Behavior Models, a 15-Slot Will, and a Synchronously Re-entrant Completion Chain

Command queue: fixed 16 / overflow at 15 / one sentinel slot
Fig 6 · Command queue: fixed 16 / overflow at 15 / one sentinel slot
NPC synchronous re-entrant completion chain: one call stack unwinds a subtree
Fig 5 · NPC synchronous re-entrant completion chain: one call stack unwinds a subtree

Once people move, you have to let them “think.” This chapter has powerful ready-made systems in UE5 (AIController + BehaviorTree/StateTree), but several core semantics of the source runtime collide head-on with UE’s default assumptions — copying them wholesale produces subtle timing bugs.

① The original design

In the source runtime there are two independent behavior-execution frameworks at once, with clearly separated namespaces.

The first is a traditional behavior tree: bind a compiled tree, activate the root node, recursively tick top-down each frame, and deactivate once the root no longer returns “in progress.” A classic BT, serving legacy assets.

The second is the workhorse new behavior framework, architecturally entirely different from the traditional BT — it is a hybrid of compile-time memory layout + event-driven + callback completion:

  • Behaviors are compiled into globally shared read-only objects: deduplicated and cached by resource hash, shared process-wide as a single copy, released only when the reference count hits zero. The node tree and an “instance data layout” (an offset table fixed at compile time) are compiled once at construction. Each NPC holds only one instance buffer; the nodes themselves are stateless. This is equivalent to one BT asset shared by many instances, except that even the memory layout of the instance data is offsets fixed at compile time, not object fields.
  • The update is not a recursive traversal of the whole tree but a traversal of an “update queue” — only nodes that actively register as updatable get ticked.
  • The most critical feature: completion propagation is synchronously re-entrant, not enqueued. When a node completes, it synchronously: if it has a parent, immediately calls back the parent’s “child completed” (the parent reacts within the same call stack); if it is the root, synchronously calls the instance’s “executed,” which synchronously calls “deactivate.” So “child completes → parent callback → possibly triggering sibling activation or whole-tree deactivation” all happens within a single synchronous call stack, never passing through any queue. Precisely because of this, the update loop must continually increment an “update ID” — because along the callback path a node may already have been re-evaluated, and you must prevent re-ticking a node that has gone inactive.

Beyond behaviors, the NPC interacts with the world through three supporting structures:

Command queue — a fixed-length state machine. Its capacity is 16, but it judges overflow at slot 15, directly cancelling the new command. Why 15, not 16? Because there is a “restore all” operation that pushes the stashed commands back into the queue all at once, leaving one empty slot to prevent array overrun at that moment. This “reserve a sentinel slot” is a classic technique for hand-written fixed-length containers. Each state transition of a command synchronously delivers a state event to the entity and synchronously calls back all listeners.

Signal system — a fixed 4 listener slots. The instant a signal flips from “clean” to “dirty,” it synchronously notifies all listeners, but only on that flip (edge-triggered); subsequent repeated dirtying is not re-broadcast. A command is “what I want you to do,” a signal is “what happened in the world” — the two are paired.

Data-driven action system — compiles conditions/actions from the config store, caches them, instantiates per entity (reusing from a free-list pool), and lazily deactivates by TTL during tick. This is the concentrated expression of the design intent “no heap allocation, predictable memory.” Its loop has four parts: fetch a record from the config store, compile it into an executable condition/action object and cache it (with double-checking to guard against concurrent duplicate compilation), instantiate one per entity that uses it from the free-list pool, and update the “last access time” on each evaluation; then a step registered in the early-frame phase lazily reclaims instances that have not been accessed beyond their TTL, returning them to the pool. It also does something clever: it merges content-equivalent (same-hash) records so multiple IDs share one compiled product. This “compile → cache → pooled instance → TTL lazy reclaim” form has no direct counterpart in UE, but its intent — avoiding repeated per-frame data parsing and heap-allocation jitter — can be expressed in Mass with fragments + shared fragments.

It is worth inserting a comparison here, because it clarifies that “pseudo-async” is not because this engine cannot write async: pathfinding is pseudo-async (the kernel is synchronous), but the AI driving’s collision avoidance is genuinely async. The source runtime places six collision probes around each AI vehicle’s body (front, front-left, front-right, rear, rear-left, rear-right), each holding an async physics-query token, following the standard “submit-poll-consume”: when the token is empty it submits one async sphere sweep, the next frame checks “is it processed,” and if so reads the hit result and clears/consumes the token; only when all six are empty is the next round allowed to submit. In the same codebase, pathfinding chose pseudo-async (because synchronous pathfinding is fast enough and it simplifies the call sites), while collision avoidance chose genuine async (because physics queries are expensive and must be amortized across frames) — this shows that “pseudo-async” is a deliberate engineering choice, not a shortcut. Migration should likewise judge item by item by cost, not paint with one brush.

Finally, the source runtime gives AI LOD-graded tick: four LOD buckets, with tick periods defaulting to once every 1 / 4 / 8 / 16 frames; after sorting by visibility, distance, and whether in combat, the buckets are filled, overflow demotes to a lower LOD, and an NPC forced by a quest or cutscene ignores bucketing and goes directly to the highest frequency. It also has low-framerate degradation — when the frame rate drops below a threshold it notifies subscribers to further reduce AI cost, with hysteresis to avoid jitter at the threshold edge. Worth noting: the source code has a comment explicitly admitting that these parameters, though called “tick rate,” are actually tick period (how many frames between ticks), the naming being a misnomer — this kind of “name doesn’t match semantics” historical baggage is the easiest thing to trip over in migration; understanding it by its name will invert frequency and period.

Walkthrough: how a “child node failure” chain-deactivates a whole subtree within a single call stack

The most counter-UE-intuitive thing about the new behavior framework is its synchronous re-entrancy. Imagine an NPC executing “walk to cover and open fire,” and the open-fire child node determines the target is already dead and returns failure. In the source runtime, everything that follows does not cross a frame and is not enqueued — it all happens within this one call stack:

  1. The fire node calls “complete (failure),” synchronously triggering the parent node’s (a sequence node) “child completed” callback;
  2. The sequence node sees the child failed, completes itself “(failure),” synchronously triggering its own parent;
  3. Propagating up to the root node, the root’s “executed” is called synchronously;
  4. “Executed” synchronously calls “deactivate,” shutting down the entire behavior instance and cancelling all registered callbacks;
  5. Meanwhile, a higher layer may have an event handler for “switch to flee on behavior failure” registered; it is synchronously activated during deactivation, so a new behavior instance starts immediately.

This whole chain, from “target is dead” to “begin fleeing,” happens in the same frame, the same call stack, before the original tick has even unwound. Precisely because of this, the update loop must increment an “update ID” on each entry/exit and use it to judge “the node I’m ticking — has it already been re-evaluated or deactivated by the callback chain just now?” — otherwise it would re-tick a node that has already died.

The benefit of this mechanism is zero-latency reaction: the NPC’s response to world changes has no one-frame lag. The cost is that any callback code must assume it is being triggered mid-state, with state possibly in an intermediate condition. UE’s BehaviorTree, by using deferred completion, trades this complexity for “one frame slower but easier to reason about” — which is exactly the trade-off to weigh head-on during migration.

② Strengths relative to stock UE5.8

This chapter’s “ready-made-ness” in UE5.8 cuts both ways and must be discussed separately.

What UE5.8 gives: AIController + BehaviorTree (a mature behavior tree), StateTree (a data-driven state-machine framework with compact instance data). Worth emphasizing: StateTree’s design philosophy is highly consistent with — even cleaner than — the source runtime’s “globally shared read-only compiled product + one compact instance buffer per NPC” (StateTree’s instance data does not depend on a per-NPC UObject tree). On this point UE5.8 is no worse; rebuilding the NPC brain should prefer StateTree and need not reproduce the source runtime’s hand-written instance-data-layout.

Where the source runtime genuinely beats stock UE5.8 is in three places:

  • Synchronously re-entrant completion chain = zero-latency reaction. The source runtime’s “child fails → parent callback → whole-tree deactivation → sibling activation” all runs within one call stack, the same frame, with no one-frame lag for world changes. UE5.8’s BehaviorTree uses FinishLatentTask to defer completion to the next tick — but this must be viewed honestly from both sides: deferred completion buys “one frame slower but easier to reason about,” which is a safer engineering trade-off for most behaviors. So this “strength” comes at a cost; it is not pure gain.
  • Command queue and signal system: UE5.8 simply has none. “NPC command queue” and “4-slot signal + edge trigger” are gameplay-layer; the engine does not provide them. The source runtime implemented predictable memory with fixed-length, heap-allocation-free containers plus a sentinel — a solid, real advantage.
  • AI LOD bucketed tick. The source runtime’s 1/4/8/16-frame bucketing + overflow demotion + low-framerate hysteresis; UE5.8’s AIController ticks every frame by default, and large crowds must build variable-frequency tick themselves (or borrow Mass’s LOD batching).

③ Porting to UE5.8: based on BehaviorTree/StateTree, but rebuild four semantics

UE5 ships AIController + BehaviorTree, plus StateTree (in the engine, a more data-driven framework better suited to expressing state machines). The source runtime’s two behavior models map nicely onto UE’s BT (traditional tree) and StateTree (data-driven, compact, event-friendly). But four source-runtime semantics conflict with UE’s defaults:

  1. Completion propagation: UE is deferred, the source runtime is synchronously re-entrant. UE’s BehaviorTree uses FinishLatentTask to defer task completion to the next tick, and StateTree’s transitions are declarative too. Directly copying the source runtime’s “child fails and immediately triggers sibling/whole-tree deactivation within the same stack” will change timing. If some logic depends on “the instantaneous side effect of completion” (e.g., a completion callback immediately changes a shared blackboard value that the next node reads the same frame), UE’s deferred completion makes it lag a frame. The rebuild must either accept this one-frame lag and audit all dependents, or build a synchronous completion channel and reproduce the “update ID”-style “re-evaluated along the callback path” guard.
  1. Shared read-only + per-NPC instance buffer: use StateTree, not a per-actor UObject tree. The source runtime’s “one compiled product + one compact instance blob per NPC” is exactly StateTree’s design philosophy (the StateTree asset is shared, instance data is a compact property blob). This has far better memory and cache characteristics than “one UObject behavior tree per NPC.” Rebuilding the NPC brain should prefer StateTree, mapping the source runtime’s instance-data-layout onto StateTree’s instance data.
  1. Command queue and signal system: the engine has neither, build them yourself, and the constants are hard contracts. UE has no such thing as an “NPC command queue” — this is gameplay-layer. In the rebuild carry it on a component, but capacity 16, overflow threshold 15, reserve a sentinel slot, 4 signal slots, edge-triggered notification are not arbitrary numbers — they carry the intent of “no heap allocation, predictable memory.” When rewriting with a dynamically growing TArray, the overflow protection and edge-trigger semantics must be preserved as-is:
// Rebuild project: NPC command queue (design), preserving fixed-length + sentinel semantics
UCLASS()
class UCyberNpcCommandQueue : public UActorComponent
{
    GENERATED_BODY()
public:
    static constexpr int32 MaxQueueSize = 16;        // hard contract
    // overflow threshold = MaxQueueSize - 1, reserve one slot for the "restore all" operation
    bool Enqueue(const FCyberNpcCommand& Cmd);       // when full, set Cancelled; do not block
    void SetState(FCyberNpcCommandHandle H, ECyberCmdState New);  // synchronously fire event + synchronous callback
private:
    TArray<FCyberNpcCommand> Queue;      // logical cap MaxQueueSize
    TArray<FCyberNpcCommand> Stashed;
};
  1. AI LOD: use Mass LOD or build bucketed tick yourself. UE’s AIController ticks every frame by default, with no “1/4/8/16-frame bucketing + overflow demotion + low-framerate hysteresis.” Large-crowd AI must build variable tick frequency — either put the NPC AI into Mass (Mass naturally batches by LOD) or add a bucketed scheduler to AIController. The special case of being force-pulled to the highest frequency by a quest/cutscene must also be kept:
// Rebuild project: AI LOD bucketed scheduling (design), preserving "period" rather than "frequency" semantics
UENUM()
enum class ECyberAiLod : uint8 { L0_EveryFrame, L1_Every4, L2_Every8, L3_Every16 };

USTRUCT()
struct FCyberAiLodConfig
{
    GENERATED_BODY()
    // Note: this is the tick period (once every N frames), not frequency. The source code itself flagged it a misnomer.
    int32 TickPeriodFrames[4] = { 1, 4, 8, 16 };
    int32 BucketCapacity[4]   = { -1, 16, 16, -1 };   // -1 = no cap; overflow demotes to a lower LOD
};

// Re-sort once per second: compute a sort key from visibility/distance/in-combat, fill buckets descending, demote on overflow;
// an NPC force-pulled by quest/cutscene ignores bucketing and goes straight to L0. On low framerate, demote one further tier using a hysteresis threshold.

If this scheduling goes straight into Mass, the hand-written bucketing can be dropped — Mass’s LOD processor naturally batches entities graded by distance. But the three priority rules “overflow demotes to a lower LOD,” “force-pulled to the highest frequency by a quest,” and “low-framerate hysteresis demotion” still have to be written into a custom LOD processor; the engine’s default LOD is by distance only and does not know “this NPC is acting in a quest and must run at full frame.”

The lock-holding callback trap must be called out separately. The source runtime makes heavy use of “synchronous cross-system callbacks while holding a lock” — the movement-strategy component’s entire update holds a lock and within that lock queries the warning-zone manager, which in turn, within its own callback lock, synchronously calls back into the movement-strategy component. The command queue’s state transition also fires events synchronously while holding a lock. UE typically assumes the game thread is single-threaded and callbacks execute immediately — if the rebuild puts this logic into multi-threaded Mass processors, this split-lock/double-buffer structure must either be preserved or wholly rewritten into a lock-free dataflow; you must never subjectively assume “it’s all on the game thread anyway.”

Let me make this pit fully explicit, because it is the most insidious class in migration. The movement-strategy component’s update function holds a lock for its entire body, and within that lock it queries the warning-zone manager (the logic that keeps NPCs from leaving the warning zone); meanwhile, the warning-zone manager, within its own callback lock, synchronously calls back into the lambda the movement-strategy component registered (to invalidate the cached restricted target). So there is a two-lock cross-system interaction — get the order wrong and you have a classic deadlock; call back at the wrong moment and you read half-updated state. This “A holds a lock and calls B, B holds its own lock and calls back A” structure was carefully designed in the original’s multi-threaded bucket-update model, but is extremely dangerous when moved to UE: if an engineer defaults to “UE is all on the game thread, callbacks immediate, no locks needed” and flattens this logic directly, they either get lucky running single-threaded (losing the parallelism benefit) or randomly deadlock once it goes into Mass’s parallel processors. The correct approach is: before migrating, draw out the lock-dependency graph of these cross-system callbacks and decide whether to preserve the split-lock structure wholesale or rewrite it into a “collect–barrier–apply” lock-free dataflow — but this decision must be made explicitly, not by default.


5. Residency Behavior: Needs, Reservations, Workspots, and a String of Possibly-Missing Animations

Workspot's eight completion signals and the three-step missing-animation fallback
Fig 7 · Workspot’s eight completion signals and the three-step missing-animation fallback

Walking and deciding alone are not enough. For a city to feel “alive,” there must be people seated, people at work, people leaning against cover. This chapter has a dedicated SmartObjects plugin in UE5, but several of the source runtime’s semantics need correcting.

① The original design

Two systems perform together: smart objects (chairs, vending machines, cover — points in the world that “you can do something to”) and workspots (an “occupation” flow bound to an animation).

There is a frequently-misreported detail about smart objects that needs correcting: its occupancy is not an “exclusive reservation” but a stackable reference-count lock. This lock protects “don’t unload/destroy this object,” not “I exclusively occupy this workstation” — multiple users can stack this lock simultaneously. The real workstation exclusivity lives at a higher layer (the workspot/cover occupancy logic). These are two layers and must not be conflated in the rebuild.

A smart object’s “resolution” (turning a registered placeholder into “you can actually use it now”) is budgeted, spatially localized, and hysteretic:

  • The total budget per frame is tiny (sub-millisecond); it stops when exhausted, and unused budget carries over for no more than half a frame;
  • The player and key AIs serve as “resolution centers,” sorted by distance to the camera; object positions live in a BVH;
  • A resolve-inner-ring, unload-outer-ring hysteresis design prevents boundary jitter; each center resolves at most a very few per frame, and there is also a cap on how many are un-resolved per frame;
  • Objects far from all centers are never resolved, occupying only a table entry plus a BVH leaf, with zero runtime cost;
  • Destruction is never immediate but marked + callbacks cut + pushed onto a destruction queue; and the lock takes precedence over everything — a locked object, even when marked for destruction, is forcibly put back to keep it alive.

Cover is a different trade-off: the query for available firing stances is cached + synchronous line traces. Each cover, for each exposure type, may fire a synchronous ray that blocks the main thread — which is the entire reason the cache exists. The cache key quantizes the ray’s origin to a 0.1-meter grid then XORs the target ID; the validity period interpolates between 0.5 and 2 seconds by distance to the threat (closer means more frequent refresh); for a “detached/uninitialized threat” it returns empty directly. A hit is an O(1) hash lookup with zero physics; a miss is a string of synchronous rays — an order of magnitude difference in cost.

Cover occupancy, unlike the smart object’s reference-count lock, is a strict 1:1 hard mutual exclusion: maintained by two reverse hash tables (NPC→cover, cover→NPC), with four gates at registration (cover valid, registered, this NPC occupies no other cover, this cover not occupied by anyone else). This confirms the earlier point that “the keep-alive lock and the exclusive reservation are two layers” — in the same city, “don’t unload this chair” uses a stackable reference count, while “only I can use this cover” uses a 1:1 mutual-exclusion table; the two mechanisms each do their own job. If the rebuild conflates them into one, either cover gets contended by multiple people and clips, or a chair gets locked forever by an NPC that never arrives. One concurrency detail is also worth noting: registration’s check uses a read lock and the write upgrades to a write lock, leaving a check-then-act window in between — if the UE rebuild uses SmartObjects’ Claim mechanism, confirm its occupancy is atomic and do not inherit this window too.

The workspot takes “local completion is not business completion” to its extreme. Configuring a workspot is merely configuration; issuing a command is merely enqueuing; the actual playback, animation readiness, displacement, item manipulation, and completion all happen afterward. Its animation advancement especially illustrates “degrading gracefully”:

  • When picking the next animation, if that animation is missing, it does not error: if the missing one is idle, it gives a several-second dwell (pretending it played, to avoid a spin storm); if a regular animation is missing, it gives zero duration (jump immediately to the next, without stalling);
  • If record advancement repeatedly returns zero, a “skip at most N records” fallback triggers; beyond that, it degrades into a looping idle and returns a throttle duration to prevent an in-frame infinite loop.

In other words, a misconfigured workspot does not crash the game; it makes that NPC spin in place repeatedly — which is precisely the engineering source of those action glitches you see in open worlds.

And “completion” itself is broken by the source runtime into a string of signals, each representing only one layer. Laying it out as a table is the most important thing to memorize in this chapter — because “workspot” has nearly the most layers of “completion semantics” of any system in the city:

SignalWhen it firesWhich layer only it represents
Configure-workspot returnsOn callOnly “instance registered / command enqueued”; not a frame of animation played
Issue-command returns trueOn callOnly “instance exists and command enqueued”; not executed
Workspot startedAfter instance is added to the graphAttached and started playing (entered play state)
Animation started/endedSingle recordThe animation start/end of one record, not the whole workspot
Play-synced-animation returns trueMaster/slave alignmentMatched the entry, jump command issued — not playing, not aligned, not complete
CompleteEntered exit stateThe logical notification that the workspot finished naturally, but resources/items are not yet unloaded
StopExternal forced teardownThis is what unloads the graph, animset, items, and clears LOD
Move-controller’s “is complete”Smart-object sideThe final verdict of “did the action actually finish playing”

Understand this table and you understand why “an NPC sitting down to rest” is, in engineering terms, “one reservation + a string of commands + a possibly-missing animation + eight completion signals each governing one layer.” If the rebuild merges any two layers into one bool, you get glitches like “the NPC is teleported while the animation is still playing” or “the workspot’s logic completed but the prop is still in the NPC’s hand.”

② Strengths relative to stock UE5.8

What UE5.8 gives: the SmartObjects plugin (Claim/Release exclusive slot + instance sets activated on demand), GameplayBehaviorSmartObjects, StateTree, Motion Warping (displacement alignment). The parts “interaction point + exclusive occupancy + state machine + displacement alignment” are all present in UE5.8.

Where the source runtime beats stock UE5.8 is its scheduling discipline, which is exactly what the SmartObjects plugin does not handle:

  • Two clearly distinguished occupancy semantics. The source runtime’s reference-count keep-alive lock (prevents unload, stackable) and exclusive reservation (1:1 mutual exclusion, used for cover) are two different layers. UE5.8’s SmartObjects only gives the exclusive Claim layer; the “keep-alive lock” layer must be implemented yourself with a subsystem pin-count. Conflating them produces “a chair locked forever by an NPC that never arrives” or “unloaded despite being in use.”
  • Budgeted resolution + spatial localization + hysteresis. Sub-millisecond budget segmentation, a BVH inner-ring resolve / outer-ring unload hysteresis band, “far away = never resolved = zero cost” — this is key to controlling CPU cost in a large world. SmartObjects supports “activate on demand,” but this scheduling must be implemented yourself.
  • Quantized-grid cache for synchronous rays. Cover LOS uses a 0.1 m voxel + target ID as key, with a distance-adaptive validity period. UE5.8 has no such cache; copying synchronous traces wholesale would noticeably slow the frame rate.
  • The three-tier degradation chain for missing animations. A misconfigured workspot does not crash, it merely makes the NPC spin in place. UE5.8’s missing Montage has no such fallback and needs to be built yourself.

In short: UE5.8 gives the interaction point and occupancy mechanism; the source runtime gives the scheduling discipline that keeps these mechanisms from running away on cost in a large world.

③ Porting to UE5.8: based on the SmartObjects plugin + StateTree + Motion Warping

UE5’s SmartObjects plugin (plus GameplayBehaviorSmartObjects) and StateTree are in the engine and map nicely. But five boundaries need care:

  1. “Available/issued” ≠ “complete” must run through the entire design. In the source runtime, the boolean return values of “add need,” “issue command,” “play synced animation” express only “match succeeded / command enqueued”; completion always goes through an independent callback. In UE, the SmartObject’s Claim, a StateTree task’s entry, and a Montage’s play request must all preserve this layering — do not treat “request succeeded” as “action complete.”
  1. The keep-alive lock (refcount/pin) and the exclusive reservation are two layers; implement them separately. The source runtime’s reference-count lock only prevents unload and stacks across multiple users; workstation exclusivity lives one layer up. In UE this maps to: use a world subsystem’s TMap<FGuid,int32> PinCount for streaming keep-alive, while the SmartObjects plugin’s Claim/Release (exclusive slot) is the other layer. Mixing them produces “a chair locked forever by an NPC that never arrives” or “unloaded despite no one using it” bugs.
  1. Resolution must be budgeted + spatially localized + hysteretic. This is key to controlling CPU/memory cost in a large open world. In UE this maps to a budgeted UTickableWorldSubsystem + Mass/Octree spatial queries. You must never spawn all SmartObjects as actors at once — the SmartObjects plugin itself supports “instance sets + activate on demand,” but the scheduling “far away never resolved, sub-millisecond budget segmentation, inner/outer-ring hysteresis” must be written into the resolution processor yourself:
// Rebuild project: budgeted smart object resolution (design)
void UCyberSmartObjectResolver::Tick(float Dt)
{
    double Budget = ResolveBudgetSeconds;          // sub-millisecond; unused carries over for ≤ half a frame
    Budget += FMath::Min(CarriedBudget, Dt * 0.5);
    // Resolution centers = player + key AIs, sorted by squared distance to camera
    for (const FResolveCenter& C : SortedResolveCenters)
    {
        int32 ResolvedThisCenter = 0;
        // BVH query: "to-be-resolved" objects within the inner-ring radius
        for (FSmartObjectHandle H : Octree.QueryInner(C.Location, InnerRadius))
        {
            if (Budget <= 0 || ResolvedThisCenter >= MaxResolvePerCenter) break;
            const double T0 = FPlatformTime::Seconds();
            Resolve(H);                              // placeholder → actual registration
            Budget -= (FPlatformTime::Seconds() - T0);
            ++ResolvedThisCenter;
        }
    }
    // Un-resolve already-resolved objects beyond the outer-ring radius (InnerRadius < OuterRadius forms the hysteresis band)
    UnresolveBeyond(OuterRadius, MaxUnresolvePerFrame);
    // Objects far from all centers: never enter the loop above → zero cost
}

The core of this snippet is three numeric relationships: inner-ring radius < outer-ring radius (the hysteresis band, preventing boundary jitter), per-center per-frame resolve cap (preventing spikes), and per-frame un-resolve cap (preventing unload spikes). The SmartObjects plugin gives “instances + Claim/Release,” but this scheduling is not within its responsibilities.

  1. Synchronous rays must have a quantized-grid cache fallback. Cover LOS uses main-thread synchronous rays, with key = “origin quantized to a 0.1-meter voxel + target ID” and a distance-adaptive validity period. If the UE rewrite of cover exposure points uses synchronous line traces, this quantized cache must be copied over, or several extra synchronous traces per NPC per frame will noticeably slow the frame rate; a detached/unattached threat must short-circuit to empty. Better still, move such queries onto async traces or Mass’s batched traces.
  1. The degradation chain and separating “completion/cleanup” are baseline robustness. The three-tier degradation (missing regular animation → jump immediately / missing idle → dwell for a few seconds / skip-cap exceeded → looping fallback idle with throttling) guards against pathological data infinite loops; “logical completion notification” and “resource unload cleanup” must be two separate paths. In UE, StateTree/Montage’s completion delegate and instance teardown should likewise be split, with a hard-coded idle fallback set for a missing Montage. Use Motion Warping in place of the source runtime’s “workspot displacement request” for displacement alignment.

Finally, an often-underestimated source of complexity: the workspot’s item/prop side effects are themselves async. When an NPC performs a task it must hold items (carrying a plate, gripping a tool), and this splits into two kinds — real items go through the inventory system’s give/take, while global props go through an async spawn: first asynchronously load the prop’s bound resource, then dispatch a job to spawn the prop entity, with the spawn token’s callback filling in the entity handle. While the prop is not yet spawned, fetching the prop returns an invalid index — yet another instance of “request succeeded ≠ resource ready.” Moreover these item actions are deferred + deduplicated (for the same entity and same item, only the last action is kept), so order-sensitive inventory sequences get flattened. In the UE rebuild, props in the workspot’s hands should be expressed via async spawn + handle callback, never assuming “the prop is in hand when execution begins”; on unload you must also distinguish “unbind (the prop returns to its place)” from “destroy” — in the source runtime, taking a prop off defaults to merely unbinding without destroying the entity, and copying this wrong produces “props vanishing into thin air” or “prop leaks.”

The trade-off of this chapter: SmartObjects + StateTree give the skeleton “interaction point + state machine,” but what is truly valuable in the source runtime is that set of scheduling disciplines (budgeted resolution, quantized cache, degradation chain, two-layer lock, completion-semantics layering) — none of which the engine provides, and which constitute the bulk of the rebuild work.


6. Pathfinding: A Synchronous Kernel Beneath an Asynchronous Cloak, and UE Happens to Share the Source

Pathfinding: pseudo-async shell / sync kernel + status-bit criteria
Fig 8 · Pathfinding: pseudo-async shell / sync kernel + status-bit criteria

Both NPCs and vehicles must pathfind. There is good news in this chapter: UE’s navigation foundation is Recast/Detour, the same source as the source runtime — the reference value is extremely high. But the layer of “pseudo-async + multi-graph stitching” the source runtime wraps over Detour, UE does not provide.

① The original design

Looking only at the script-layer interface, it is easy to conclude pathfinding is asynchronous: initiate a path to get a token, then each frame query “is it complete,” and once complete, retrieve it. But reading down into the implementation you hit a comment that bluntly admits: async pathfinding is in fact not implemented, and that “update query” function directly calls synchronous pathfinding in the kernel. The whole “async” is simulated by an upper multi-frame compositing layer using token polling.

This pseudo-async layer is actually worth borrowing — it turns “is it truly async” into an implementation detail and exposes only an async-shaped contract to the outside. If you ever did want to switch to genuine multi-frame async, callers would not change a single line.

But the real trap in this layer is that the graded state must not be flattened into a single boolean value. The underlying pathfinding returns a set of bitmask statuses: start point not streamed in, end point not streamed in, nearest walkable polygon invalid, result is a partial path, node pool exhausted, straightening failed… and they appear in combination. The distinction between two of these bits is crucial:

  • “Partial path” AND “node pool exhausted” = not finished searching, worth retrying;
  • “Partial path” BUT “node pool not exhausted” = the entire reachable graph was searched and still did not arrive, retrying is pointless, this is genuinely no path.

If the caller decides success by looking only at a single isReady, it will let the NPC walk along an incomplete path and eventually hit an obstacle. The multi-frame compositing layer relies precisely on the combination of these two bits to decide “wait for streaming and retry” or “judge no path.” It also has a “wait for streaming” strategy: when start/end points are not streamed in, it re-sends the same query every few seconds, while delivering the existing partial result immediately to the user (because waiting for streaming may take indefinitely long).

Above pathfinding there is another layer of multi-graph stitching. In the source runtime, walking/indoors uses one navmesh, while lanes/sidewalks use a separate traffic-lane graph; a query builder is responsible for stitching “navmesh → traffic → navmesh”: deciding navmesh vs. traffic by the target point’s distance to the nearest lane, reverse-looking-up reachable lanes, picking a path with a “path goodness” approximated by straight-line distance estimation, handling off-mesh connections for elevators and entrance markers. This stitching layer is riddled with author-annotated FIXMEs and HACKs (elevator off-mesh is force-filtered out so it doesn’t work, geometry is used to guess which segment is the elevator, and a misspelled tag from the content is added to the whitelist to accommodate the typo) — this is the part of the entire migration with the most work and the most bugs.

The traffic system here also has a counterintuitive detail: when global pathfinding fails, it stores an empty placeholder path rather than a null pointer — so “the result pointer is non-null” does not at all mean “a path was found”; the upper layer must judge failure by “is the path empty.” The author even flagged a TODO in the source to delete this dummy path.

Walkthrough: how a path crossing navmesh and lanes “fakes async”

Lay the multi-frame compositing layer open and it becomes clear exactly where the “pseudo-async” is fake. This layer maintains a set of in-progress queries using three domain-partitioned locks (active queries, cancelled queries, results), each query being a small state machine:

  • Initiation: the caller submits a path; the first segment is emitted immediately (possibly navmesh, possibly lane), an incrementing token is allocated and returned. The synchronous kernel has already computed the first segment this frame, but the caller receives only a token.
  • Per-frame advancement: each frame the compositing layer clears cancelled queries and advances active queries one by one — reading the previous segment’s result and deciding whether to switch to the next segment, switch to the next fallback plan, or retry. A “navmesh → lane → navmesh” path is stitched together across multiple segments and frames.
  • Interpretation: here is the essence. A lane segment returning null means “still computing,” while a returned path that is “empty” is failure (that dummy path); a navmesh segment “not ready” means “still computing.” If it carries “start/end not streamed in” and that segment allows waiting for streaming, it re-sends the same segment every few seconds while immediately marking the existing partial result available and handing it to the caller — because waiting for streaming may take indefinitely long and cannot be sat out. And “partial result but node pool not exhausted” is judged “genuinely no path” and fails directly.
  • Retrieval: the caller asks “is it done” each frame; it returns only when marked available, and only one segment at a time — a multi-segment path requires multiple retrievals.

So the “async” is entirely simulated by this layer with token polling + multi-segment stitching + wait-for-streaming retries; every actual computation beneath is synchronously blocking. The elegance of this design is that it hides “is it truly async right now” entirely in the implementation, exposing only an async-shaped contract forever.

The traffic system that underpins the vehicle flow is itself an independent, heavily job-ified machine at the head of the frame, worth a separate look because it is the part most in need of self-building on the UE side. At the head of each frame it dispatches a string of parallel jobs: process pending lane registrations, update lanes (clear invalid slots, light changes, reclaim nodes pending deletion), advance green-wave lights, update intersections, detect dead ends, update collision, process global pathfinding requests, clear timed-out spawn requests, and maintain the evaluator and spawn-query caches. Three mechanisms in this job string especially illustrate the engineering difficulty of “large-world traffic”:

First, lane registration is deferred and cancellable. Lanes stream in and out with regions, but registration and de-registration do not take effect immediately — they first enter a queue, and if a node appears in both the registration and de-registration queues simultaneously they cancel each other out (common during streaming jitter). The actual deletion is further deferred until “all references are zero and there are no occupied slots,” to avoid deleting a lane a vehicle is currently occupying. Registration also extracts and injects the node’s static collision from a persistent pool into the runtime collision graph, returning it on de-registration — collision data follows the lane’s lifecycle.

Second, dead-end detection is event-driven + head-of-frame batch processing. A collision update (e.g., a stalled vehicle blocking the road) triggers gathering the affected lanes into a to-check set; the head-of-frame dead-end-detection job checks them one by one, marks dead ends on hit, and propagates upstream (one road blocked makes the roads leading to it dead ends too), finally broadcasting a “lane blocked” event in one batch so spawning and pathfinding avoid it. This “collision → to-check → batch process → propagate → broadcast” chain is the source of city traffic naturally rerouting around obstacles.

Third, global pathfinding is stateful and sliceable for resumed computation. Traffic global pathfinding is not computed in one shot but has a 2-millisecond frame budget: do a stretch on startup, do another stretch on continuation, and if it times out, continue next frame; the current progress is held by an “in-progress request.” This forms an interesting contrast with the navmesh layer’s “pseudo-async” — traffic pathfinding is genuinely sliced across frames, because the global pathfinding on the lane graph really is expensive enough that it must be spread out.

None of these three has a ready-made implementation in UE: ZoneGraph gives the graph structure of lanes and intersections, but lane occupancy, deferred-registration cancellation, dead-end propagation, and global-pathfinding slicing all have to be written yourself. This is why traffic is the only system in the whole article flagged as “needs self-building/migration.”

And on top of it sits a “query builder” responsible for translating high-level intent into multiple segments + multiple fallback plans: pure navmesh (no partial allowed) → navmesh-lane-navmesh → start over from the furthest streamed point → pure lane → (entrance connection) → and only last, the plan that allows partial results + a straight-line fallback. Tier by tier it degrades, compromising more as it goes. This fallback chain is the engineering source of “the navigation arrow can always point you in a direction, even if only a partial path” in open worlds.

② Strengths relative to stock UE5.8 (including where UE5.8 is stronger)

This chapter must say it the other way around: in many places UE5.8 is better than the source runtime, and copying the source runtime would be a regression. No dodging — point by point:

  • Genuine async: UE5.8 wins. The source runtime’s “async” is simulated by an upper layer with token polling; the kernel FindPathSync is synchronously blocking (the source flags its own todo). UE5.8’s UNavigationSystemV1::FindPathAsync is genuinely asynchronous pathfinding. Do not migrate the source runtime’s pseudo-async here.
  • Failure semantics: UE5.8 wins. The source runtime’s traffic pathfinding uses a “non-null empty path” to signal failure, an anti-pattern (the author’s own TODO wants it gone). UE5.8’s FNavPathSharedPtr plus IsValid() / bPartial is far cleaner. Use UE5.8’s semantics directly.
  • Underlying algorithm: a tie (same source). UE5.8’s ARecastNavMesh is Recast/Detour underneath, the same source as the source runtime, with incremental tile streaming and off-mesh links ready-made.

So what is left of the source runtime worth borrowing? Only two things, but they are key:

  • The subdivision criterion for partial. The source runtime distinguishes “partial + node pool exhausted = not finished searching, worth retrying” from “partial + not exhausted = genuinely no path.” UE5.8’s bPartial has no such “worth retrying” signal — this semantic must be filled in yourself, or you cannot tell retry from give-up.
  • Multi-graph stitching strategy + wait-for-streaming strategy. The “navmesh → traffic graph” stitching, the “mark only when the whole sector is ready” gate, and “deliver partial immediately while waiting for streaming + re-send every few seconds” — UE5.8 does not provide these, and ZoneGraph does not either; they are the gameplay strategy layer.

So the honest conclusion of this chapter is: adopt UE5.8 underneath (stronger), adopt UE5.8’s failure semantics (cleaner), and only borrow the two semantics “partial subdivision” and “multi-graph stitching strategy” from the source runtime.

③ Porting to UE5.8: based on Recast/Detour, build the graded result and stitching layer yourself

UE’s ARecastNavMesh is Recast/Detour underneath, with tile streaming, incremental add/remove tile, FindPathAsync (genuine async), and off-mesh links all ready-made. This is the chapter with the highest engine-readiness in the article. But four things must be filled in yourself:

  1. Preserve partial’s subdivision semantics. UE’s FPathFindingResult has bPartial, but no “node pool exhausted” “worth retrying” signal. The source runtime’s “partial + not exhausted = genuinely no path; partial + exhausted = not finished searching” criterion is not provided by UE — you must add a “interrupted by node budget?” marker on the query filter or path metadata yourself, or you cannot distinguish “should retry” from “should give up.”
  1. Streaming gate + readiness query must be self-built. UE’s navigation tile streaming (with World Partition’s Navigation Data Chunk) does incremental loading, but does not expose “is this navigation ready” for the upper layer to gate before pathfinding. The source runtime’s “mark ready only when the whole region’s tiles all succeed” and “the listener is notified before the data is actually inserted (so traffic can register first)” semantics have no equivalent in UE — you must build a “ready sectors” set yourself using World Partition’s streaming events and expose a query.
  1. The navmesh↔traffic stitching layer is entirely self-built — this is the hard bone of the rebuild. In UE, navmesh = Recast, traffic lanes = ZoneGraph (ready-made in the engine, providing lanes/intersections). But the stitching between the two graphs — entrance connection, clipping to lanes, subgraph connectivity matching, pedestrian-path goodness approximation — UE does not provide at all. This is exactly where the source runtime’s FIXMEs/HACKs are densest. Recommendation for the rebuild: express elevator/stair transitions directly with UE’s smart link + custom area, and do not reproduce that broken off-mesh HACK; the goodness approximation can copy the cheap “estimated/straight-line distance” version first, but leave an improvement point for “recompute with real path length.”
  1. Use UE’s clean failure convention; do not migrate the dummy path. The source runtime’s “non-null empty path” for failure is an anti-pattern (the author wants it gone too). UE’s FNavPathSharedPtr plus IsValid()/bPartial expresses it more cleanly — use UE’s semantics directly. But strategies like “deliver the existing partial immediately while waiting for streaming” and “re-send every few seconds” are gameplay-layer, with no native UE equivalent, and must be written yourself — they can hang on the traffic path-request processor.
  1. The off-mesh connections’ runtime state machine must be self-built. The source runtime maintains several tables of “off-mesh connection ID ↔ polygon reference,” enable/disable/override bits per agent size, a gameplay-state count of “is it available to the player,” and replays already-set state when a tile streams in. UE’s off-mesh links (UNavLinkComponent/smart links) have enable/disable, but no tag-based filtering, no “replay already-set state on stream-in,” no gameplay-state bits like “is it available to the player” — these must be rebuilt on the area flags of a custom query filter. There is a clear lesson here: in the source runtime the elevator’s off-mesh is itself broken (force-filtered out; the author flagged a FIXME), and there is one spot where, to accommodate a misspelled tag in the content, the typo is added to the whitelist as a hack. The UE redo should express elevator/stair transitions directly with smart link + custom area, and neither reproduce that dead off-mesh path nor inherit those hacks — migration is a rare chance to “pay off historical technical debt in one go.”

The trade-off of this chapter: the underlying algorithm is provided nearly natively by UE (same source), but the four things “graded-result subdivision,” “streaming-readiness gate,” “multi-graph stitching,” and “wait-for-streaming strategy” are gameplay strategy layer and must be self-built. The stitching layer especially must reserve ample schedule.


7. Vehicles and Traffic: Parallelism Within the Physics Phase, and Tire Contacts That Are “Synthesized”

Vehicles: player = physics / AI = kinematic split
Fig 9 · Vehicles: player = physics / AI = kinematic split

Vehicles are a different set of logic, but the discipline runs in the same vein. This chapter must be viewed in two halves: vehicle physics has a ready-made in UE (Chaos), while vehicle flow / traffic scheduling is the only part in the whole article with no unified built-in solution, requiring building on ZoneGraph / MassTraffic or from scratch.

① The original design

First, correct a point prone to misunderstanding: the source runtime’s vehicles do not use a PhysX/Chaos-style vehicle SDK, but a custom rigid body + raycast suspension model — the underlying physics engine is only responsible for collision queries and contact callbacks; the vehicle’s motion is integrated by itself.

The vehicle object has a clear hierarchy, but several counterintuitive structural facts mean that copying it on intuition during migration will build the wrong inheritance tree:

  • Beneath the generic vehicle base class, only cars and motorcycles are “wheeled vehicles” — they share a drivetrain (brakes/gearbox/engine).
  • Tanks and aircraft inherit the generic base class directly and are not wheeled. Tanks have no drivetrain but do have heavy weapons and FX parameters for wings/thrusters; aircraft are even more special — they inject no suspension at all (the suspension pointer is null), and motion runs on a separate aerial-control system.
  • Precisely because aircraft have no suspension, the generic base class must null-check on every suspension access and fall back to the chassis component. The suspension is injected as an abstract base-class pointer; the base class is entirely agnostic to whether it is car suspension, motorcycle suspension, or tank suspension — the subclass injects it at construction.

The design intent of this hierarchy is “the generic base class holds optional pointers to all subsystems; subclasses inject only what they need.” Its mapping to UE should not be a deep inheritance tree (car is-a wheeled is-a vehicle is-a…) but rather composition: a vehicle actor mounts several optional components (drivetrain, suspension, aerial control, weapons, autopilot, destruction), deciding which to mount by type. Deep inheritance would clash with Chaos’s AWheeledVehiclePawn hierarchy in UE, whereas composition lets “aircraft = no suspension + aerial control” and “tank = no drivetrain + heavy weapons” be expressed naturally.

Its tick is bucketed by physics phase, which is the root of being able to run a whole city’s traffic at once:

  • Pre-physics bucket: process the garage, drain the deferred-registration queue, gather the vehicles to tick this frame, then do one parallel pre-move update over all vehicles, then speed limiting, fixed stepping, and finally interpolation.
  • Fixed stepping is the core: a fixed 60 Hz (1/60 s per step), with an accumulator budgeting how many steps to take this frame; first update the autopilot of streamed-out vehicles (writing the target transform directly onto the stub), then run a parallel-for over the vehicle array, each vehicle walking the budgeted number of steps in the inner loop. Each step internally is “pre-solve job → suspension pre-solve → custom solver Solve → custom integrator IntegrateMotion → post-solve.”
  • Post-physics bucket: do the post-move update in parallel, and flush callbacks uniformly at the end.

Determinism is held up by two “update flags”: one makes tick and config-store hot reload mutually exclusive (no reload during tick), the other makes the registration container and reads mutually exclusive. Registration itself goes through a lock-free queue, flushed uniformly into an ordered array in the pre-physics bucket; the capacity has a static upper bound, and overflow is fatal.

Within the vehicle’s “perception” hides a mechanism that especially illustrates the point: smooth contact synthesis. The contact between tire and ground is not the physics ray’s hit taken directly each frame, but is passed through a critically-damped smoothing — maintaining a “contact confidence” between 0 and 1. On a hit, confidence moves toward 1; on a miss, it decays toward 0; and as long as confidence is still above a small threshold, even if the ray hits nothing this frame, it synthesizes a contact point at the wheel’s fully-drooped position, forcing the normal to point toward the vehicle’s top. This confidence is multiplied directly into the wheel load, participating in all wheel-force calculations. Its purpose is: during brief airtime or over road seams, do not let the suspension force snap to zero instantly, avoiding jitter and bouncing. The cost is — the ground the tire “hears” is not necessarily the ground it actually “stepped on” this frame: downstream audio and FX may see this synthesized contact rather than the real hit. Most of the time this makes the feel smoother, but it is a classic case of “presentation and physics not sharing a source.” A related detail: AI vehicles use rays, player vehicles use sweeps — because the player vehicle needs more precise contact feel, while the AI vehicle needs cheaper. The suspension query itself also makes a sidewall correction for motorcycles (a tilted motorcycle wheel “appears” to lift when swept with a semi-cylinder, so the wheel must be pressed back by the correction amount), and a “limit compression event” clamp for curbs (a once-per-frame sweep at 60 Hz that wants to support driving up a curb must distinguish a curb impact from a jump landing and clamp pathological compression spikes). These are all places where “feel” hides in physics details; underestimate them in the rebuild and you lose the texture.

Collision contact is another place where the semantics must be copied. Under a shared lock, the system iterates physics contacts, mapping physics actors back to chassis components. When two vehicles collide, for a vehicle in traffic it triggers a “traffic impact” reaction, then forwards the contact to the object layer. When the object layer processes the contact, there is a key operation: normalize impulse by mass — divide the physics impulse by the vehicle’s physics mass, to prevent those kinematically-driven heavy-mass vehicles (an autopilot vehicle’s mass may be set very large) from flinging other vehicles away on a single collision. The traffic NPC’s impact reaction is a “stun” state: above a threshold computed from the per-vehicle record, the stun duration is interpolated by force, a stun event is queued to make the vehicle stop its current action, while below the upper threshold and not player-driven it triggers a delayed honk. These quantized thresholds and reactions are the source of “city traffic looking real after being hit.”

Another key boundary for vehicles is that autopilot (AI driving) is kinematic, not physical. Player driving runs physics (rigid body + suspension + solver), while AI driving has no forces, torques, or wheel simulation whatsoever: entering autopilot explicitly turns off physics, then “sets” the pose directly each step. When AI drives along a spline, it is essentially just integrating an “arc-length scalar + speed” and converting it to a transform. The velocity written into the rigid body is merely decorative, fed to audio and FX, and does not participate in integration.

Autopilot is delegated by a facade to one of three strategies; understanding these three strategies is to understand the motion source of every AI vehicle in a city:

  • Traffic slot: used by crowd traffic. The vehicle is snapped to the position and heading given by the traffic slot, flowing along the lane.
  • Spline slot: the workhorse for scripted and quest driving. It is simply “a point moving along a speed-limited spline,” integrating a one-dimensional arc length with an independent 60 Hz fixed-substep accumulator, with target speed from the speed limit at the current position on the spline; heading is computed from two sample points front and rear (wheelbase-aware). It also has a “keep distance” mode for convoys / rubber-band following: project the companion vehicle’s position, damp at close range, and probe a certain distance ahead to decelerate early for turns. On activation it discretizes the whole path into a string of collision discs registered to the traffic system, so other vehicles yield.
  • Follow object: the simplest, pinning a vehicle at a local-coordinate offset from another object and chasing it with exponential smoothing. Used for escorts/wingmen.

And when the target transform is actually “set” onto the vehicle, there are two kinds of host: a full vehicle goes a path of “compute target transform → optionally interpolate player↔AI control authority → conform to the road surface → turn off physics → force-set the transform,” while a streamed-out, stub-only vehicle goes another path that writes the same mathematical result onto the stub. The same motion math, two hosts — this is the structure the later UE rebuild should most copy.

The physics switch itself is not a bool but a reason bitmask: traffic, debug, explosion, autopilot, asleep, “stream-init asleep,” “surroundings streamed out,” quest cutscene, debug gizmo, “waiting for streaming”… a dozen or so reasons; setting any one turns off physics, and physics returns only when all are cleared. A vehicle is born “stream-init asleep.” The significance of this design is that the reasons for turning off physics can stack — a vehicle may have physics off both because it is “in traffic” and because a “quest took over,” and both reasons must be cleared before it recovers. Expressing this with a single bool loses the stacking and produces bugs like “the quest ended but the vehicle won’t move (because it’s still in traffic state).”

// Rebuild project: physics-disable reason bitmask (design), not a single bool
UENUM(meta=(Bitflags))
enum class ECyberVehiclePhysicsDisableReason : uint32
{
    None             = 0,
    Traffic          = 1 << 0,
    Autopilot        = 1 << 1,
    Asleep           = 1 << 2,
    StreamInitAsleep = 1 << 3,   // born asleep
    StuffStreamedOut = 1 << 4,
    QuestOrScene     = 1 << 5,
    Debug            = 1 << 6,
    // ...
};
// IsPhysicsEnabled() == (DisabledReasons == 0)
// Entering autopilot: Set(Autopilot); quest takeover: Set(QuestOrScene);
// physics re-runs only after all relevant reasons are Cleared.

Persistence is also layered: the system level stores very little; the real per-vehicle state (autopilot progress, wheel visual pose, destruction grid, radio, quest-forced transform) is pushed down to each vehicle’s persistence component, serialized by the reflection framework. The wheel’s persistent state uses a “previous frame” sentinel value (a tiny negative number) to judge “is there a valid previous frame,” so the damping calculation and a mid-flight save both work. This detail is key: the per-frame wheel runtime state (contact material, visual displacement, spring compression, anti-roll-bar displacement, damping force) is written back to persistence in real time, so the “previous frame in persistence” serves both the damper’s differential computation and “the player saves the instant the vehicle is airborne, and after loading the vehicle’s pose is exactly identical.”

Walkthrough: within one frame, how a city’s vehicles walk the fixed step in parallel

The vehicle tick is the most parallel place in the whole system; laying it open reveals how “determinism” and “parallelism” are obtained at the same time:

  1. At the head of the pre-physics bucket, first grab an exclusive “no hot reload” flag — the config store cannot reload for the entire tick, guaranteeing every vehicle sees consistent parameters.
  2. Serially do a few preparation tasks: process the garage (summon/recycle), flush requests from the lock-free registration queue into the ordered vehicle array, gather the vehicles to tick this frame.
  3. In parallel over all vehicles, do one “pre-move” update (parallel-for).
  4. Compute the fixed-step budget: accumulate this frame’s dt into an accumulator and, by the fixed step size (1/60 s), compute how many whole steps to take this frame — possibly 0 steps (frame too fast), possibly 2–3 steps (frame too slow).
  5. First update the streamed-out vehicles’ autopilot: these vehicles have no full entity, only a stub; write the autopilot-computed target transform directly onto the stub — this is the kinematic path, not touching physics.
  6. In parallel over all vehicles, do the fixed step (parallel-for): each vehicle walks the budgeted number of steps in the inner loop, each step being “pre-solve → suspension pre-solve → gather wheel contacts to feed the custom solver Solve → custom integrator IntegrateMotion → post-solve.”
  7. Then do interpolation in parallel over all vehicles: interpolate the 60 Hz fixed-step result to the current render frame by the accumulator remainder — so physics runs at 60 Hz, the picture runs at any framerate, and the vehicle still looks smooth.
  8. The post-physics bucket does the “post-move” update in parallel, flushing callbacks uniformly at the end, releasing the hot-reload flag, and handling crowd collision.

Across this whole chain, “determinism” comes from fixed step size + accumulator + hot-reload exclusion, and “parallelism” comes from three parallel-fors over the vehicle array + the lock-free registration queue. The two are not in conflict, because all modifications to shared cross-vehicle state are collected into the serial preparation segment, and in the parallel segment each vehicle touches only itself. This is exactly what the UE rebuild is most likely to lose — once you scatter vehicle updates into each actor’s Tick(), the variable dt destroys the fixed step’s determinism, and implicit inter-actor dependencies destroy parallel safety.

② Strengths relative to stock UE5.8

This chapter’s strengths comparison must also be split into “physics” and “traffic,” and the two have very different complexions.

On the physics half, UE5.8 gives plenty: ChaosVehiclesPlugin and ChaosModularVehicle are a complete vehicle-physics SDK, with the player vehicle’s suspension, drivetrain, and tire simulation all ready-made. The source runtime uses a custom rigid body + raycast suspension (not the PhysX/Chaos vehicle SDK); where it beats stock UE5.8 is in four points:

  • The same-vehicle exclusivity of player=physics / AI=kinematic. This is the biggest advantage — thousands of traffic vehicles do not run physics, only integrating “arc length + speed.” Following the UE5.8 intuition of mounting a Chaos Vehicle on every vehicle would directly crush the frame rate. The source runtime’s split is the precondition for scaling.
  • Smooth contact synthesis. Synthesize a contact point even on a miss, and smooth contact confidence over time. Chaos defaults to “wheel off the ground = zero force,” so going over a curb jitters; this layer is the ceiling of feel, and UE5.8 does not provide it.
  • The physics switch is a stackable reason bitmask. UE5.8 at most can use a single bool or SetSimulatePhysics, and cannot express “physics off due to both traffic and quest, requiring both reasons cleared before recovery.”
  • Determinism from fixed 60 Hz substeps + parallel vehicle list + hot-reload exclusion. UE5.8’s actor Tick is variable-dt; scattered across actors it loses determinism and parallel safety.

On the traffic half, as discussed earlier: UE5.8 has no unified built-in city-traffic scheduling system (ZoneGraph only gives the lane-graph structure; MassTraffic is in City Sample / experimental examples and does not ship with the standard engine — it must be self-built or migrated). So this half’s “strengths comparison” is a question of “is there a unified solution at all,” not “who is better.”

③ Porting to UE5.8: vehicle physics on Chaos, traffic scheduling migrated/self-built

This chapter must be split into two halves, because the two halves differ vastly in engine-readiness.

Vehicle physics: based on Chaos, but only for player vehicles. The engine has ChaosVehiclesPlugin and ChaosModularVehicle (Experimental). But three boundaries:

  1. Player=physics, AI=kinematic, the two mutually exclusive on the same kind of vehicle. This is the most important directional judgment: do not use Chaos Vehicle to run AI vehicles. AI vehicles should be kinematic components (Mass or UMoverComponent), integrating arc-length + speed along a ZoneGraph lane or spline and then setting the pose; Chaos is used only for player vehicles. The source runtime’s autopilot explicitly turns off physics and then sets the pose each step — that is exactly this. Running Chaos physics on thousands of traffic vehicles brings severe performance problems.
  1. The physics switch is a bitmask, not a bool. You must copy the dozen-or-so “disable reason” semantics, especially the stacking of “stream-init asleep,” “traffic,” and “quest cutscene.” In UE this maps to a state machine of “when to make a vehicle kinematic, when to exit physics simulation” — a single bool loses the streaming/traffic/quest stacking semantics.
  1. “Smooth contact synthesis” is the ceiling of feel and must be reproduced. Chaos wheels default to “off the ground = zero force” and jitter. You must overlay a time-smoothed “contact confidence” on top of custom suspension or Chaos wheel force — on a miss, maintain a decaying confidence over a decay time, synthesize a contact point at full droop, and multiply it into the wheel load. Without reproducing this layer, both AI and player vehicles jump over curbs and potholes.

Vehicle flow / traffic scheduling: no unified built-in solution; this is the only part in the article needing migration or self-build.

As verified in practice, there is no MassTraffic in the standard engine — it is a plugin exclusive to the official city demo project (City Sample) and does not ship with the engine. So “road-network scheduling for vehicle/pedestrian flow” has no unified built-in scheduling system in the standard UE5 engine: ZoneGraph gives the lane-graph structure, but the scheduling layer (occupancy/traffic lights/dead ends/global-pathfinding slicing) is absent. The rebuild has only two paths:

  • Migrate: bring the whole MassTraffic over from the official city demo project. It does solve “a city’s traffic” with Mass + ZoneGraph, and architecturally it maps closely to the source runtime’s “traffic lanes + traffic slots + global pathfinding.” But it is a large plugin coupled to demo content; migrating means stripping demo assets and aligning to this project’s Mass schema and ZoneGraph data — no small effort.
  • Self-build: write your own traffic scheduling on the engine’s ready-made ZoneGraph (providing the lane and intersection graph structure) — lane occupancy, traffic slots, traffic lights, dead-end detection, global-path slicing/resumption. The source runtime’s mechanisms (lane registration across jobs, static-collision extract-and-inject, intersection listeners, event-driven batch processing of dead ends, global-pathfinding 2-millisecond budget slicing) can serve as the blueprint for self-building.

Whichever path, the following mapping layer is certain and can be written first:

// Rebuild project: traffic slot ≈ ZoneGraph lane point; autopilot spline slot ≈ scripted spline
// both ultimately reduce to "produce a target Transform, then set it kinematically"
USTRUCT()
struct FCyberTrafficSlot
{
    GENERATED_BODY()
    FZoneGraphLaneHandle Lane;          // ZoneGraph lane handle
    float                DistanceAlongLane = 0.f;
    FVector              Position = FVector::ZeroVector;
    FVector              Forward  = FVector::ForwardVector;
    // a small POD of audio data is stamped into the traffic slot, played by the lightweight traffic path;
    // the full audio component takes over on LOD promotion and flips bCanPlaySounds=false to prevent double-play
    bool                 bCanPlaySounds = true;
};

There is also a duality running through the whole vehicle system that must be copied: the dual nature of stub and full actor. The same autopilot can drive both a full vehicle and merely a lightweight stub (writing the target transform onto the stub when streamed out). Traffic audio works by stamping a small POD (traffic metadata name, entity ID, can-play-sound, honk request) into the traffic slot, played by the lightweight traffic path; the full audio component takes over on LOD promotion and flips “can play sound” to false to prevent double-play. In UE this maps to a Mass fragment (streamed-out state) ↔ a full Pawn (loaded state), and the motion and audio math must be placed in a shared component so both ends can run it. This is precisely the core of the ZoneGraph traffic rebuild.

There is another easily-overlooked boundary, critical to feel: the control-authority handoff between player and AI must be interpolated, not switched instantly. When a traffic vehicle is taken over by a player getting in, or handed back to AI when the player gets out, directly switching authority makes the vehicle jump. The source runtime built a dedicated control-authority handoff interpolation for this — a set of easing curves that smoothly transition the pose from the AI-computed target to player physics, or vice versa. It also carries a safety valve: if the two ends differ by more than a teleport-magnitude threshold (e.g., a streaming teleport that instantly moves the vehicle far away), it cuts the interpolation and hard-switches, to avoid interpolating an abnormally long trajectory. In the UE rebuild, this handoff interpolation must land at the “kinematic component ↔ Chaos physics” switch point — it is a key part of the get-in/get-out feel, and omitting it produces a crude “stutter on entry, jump on exit” impression.

Finally, the three pillars of determinism: deferred registration (lock-free queue flushed uniformly into an ordered array in pre-physics, static-budget overflow fatal), fixed 60 Hz substeps (accumulator budgets the step count + parallel-for), and no-hot-reload during tick (two update flags make the container and config-store hot reload mutually exclusive). If the UE rebuild scatters these into each actor’s variable-dt Tick, it loses determinism and parallel safety — it should centralize the fixed step + parallel vehicle list in a world subsystem.

// Rebuild project: vehicle runtime centralized fixed step (design skeleton)
void UCyberVehicleRuntimeSubsystem::TickFixedStep(float Dt)
{
    FScopedReloadGuard NoReload(ReloadFlag);        // no hot reload during tick
    DrainRegistrationQueue();                        // lock-free queue → ordered vehicle list
    GatherVehiclesToTick();
    ParallelFor(Vehicles.Num(), [&](int32 i){ Vehicles[i]->PreMove(Dt); });

    Accumulator += Dt;
    int32 Steps = 0;
    while (Accumulator > FixedStep) { Accumulator -= FixedStep; ++Steps; }   // 1/60
    UpdateStreamedOutAutopilots(Steps);              // stub-only vehicles run kinematic
    if (Steps > 0)
        ParallelFor(Vehicles.Num(), [&](int32 i){ Vehicles[i]->FixedSteps(Steps, FixedStep); });

    ParallelFor(Vehicles.Num(), [&](int32 i){ Vehicles[i]->Interpolate(Accumulator / FixedStep); });
}

8. Stitched Into the Same Frame: Cross-System Readiness Gates

Cross-system readiness gates: producer → gate → consumer
Fig 10 · Cross-system readiness gates: producer → gate → consumer

People, vehicles, crowds, smart objects — these are not islands; they borrow strength from one another within a frame via interfaces, and the direction is very clear.

① The original design

In the source runtime the cooperation direction among these systems is unidirectional and clear: the NPC’s “drive/move” action obtains a handle to the crowd system through the community system to do traffic join/leave/complete; the vehicle system adds/removes “vehicle stubs” through the population system and borrows the crowd system to get traffic slots; the scene, quest, and interaction systems uniformly treat these as “resolvable entities” to wait on — what they wait for is a readiness subset, not a bare “spawn succeeded.”

The three big systems do not own one another; they cooperate via the common primary key of stub and entity ID. People and vehicles do not conflict not because of some global scheduler, but because every system follows the convention of “wait behind the correct gate” — the quest system waits for “gameplay-ready,” rather than referencing a not-yet-ready NPC the instant “spawn is accepted.”

② Strengths relative to stock UE5.8

The “readiness” signals UE5.8 gives are coarse: World Partition’s streaming completion is a cell-level boolean, the Subsystem mechanism exists, but there is no convention of a “composed readiness gate” — it does not indicate “this region’s resources are ready, prefabs are ready, prefetch is confirmed, therefore playable”; it only indicates “the cell finished loading.”

The strength of the source runtime’s design is that it makes “readiness” a composed state + a contract of unidirectional dependency:

  • Composed readiness gate. A region being playable is not a bool, but a listing of “which sub-states I satisfy (resources/attachment/sector/nodes/prefetch) and which I skipped.” Consumers can thereby precisely wait for true playability.
  • Consumers depend only on the gate, never read each other’s internals. Population waits for the “region readiness gate,” traffic waits for the “lane readiness gate,” AI waits for the “entity gameplay gate” — unidirectional dependency, and no one touches anyone else’s implementation.

This plugs exactly the trap UE5.8 is most likely to fall into: treating “the World Partition cell finished loading” directly as “the region is playable.” This trap is the very root of the introduction’s “letting an unregistered NPC drive an unfit vehicle into an unstreamed wall.” UE5.8 gives the Subsystem as the carrier, but the architecture discipline of “composed gate + unidirectional contract” must be established yourself.

③ Porting to UE5.8: make the readiness gate an interface

The rebuild project solidifies this cooperation into a set of wait gate interfaces, declared in a shared core plugin, the sole dependency channel among the systems:

// Cross-module wait gates (design): consumers depend only on the gate, not on each other's internals
| Gate                       | Producer              | Consumers                       |
| DataRecordReady           | CyberDataRuntime      | world/entity/quest/interaction  |
| GameplayAreaReady         | CyberWorldRuntime     | entity/quest/interaction/AI/population |
| EntityGameplayReady       | CyberEntityRuntime    | quest/interaction/AI/save       |
| TrafficLaneReady          | CyberVehicleRuntime   | population/AI                    |

Behind this is a non-negotiable rule in the migration contract: a module may depend only on an earlier contract state or an explicit interface, never on another module’s internal implementation detail. World-region readiness is not World Partition’s “loading done” bool, but a composed state — it must list which sub-states it satisfies:

// Rebuild project, shipped: the composed world-region readiness gate
USTRUCT(BlueprintType)
struct FCyberGameplayAreaGate
{
    GENERATED_BODY()
    FCyberWorldAreaId       AreaId;
    FCyberWorldReadinessState Readiness;          // one status bit each for resources/attachment/sector/nodes/prefetch
    ECyberRuntimeStatus     OverallStatus = ECyberRuntimeStatus::Unknown;
    TArray<FName>           SatisfiedSubStates;   // explicitly list which are satisfied
    TArray<FName>           SkippedSubStates;     // and which are skipped
    TArray<FString>         Warnings;
};

The consumer’s code therefore looks like this — it waits on the gate, rather than directly reading another’s internal state:

// Rebuild project: the population system waits for the "region readiness gate" before building stubs (design)
void UCyberPopulationRuntimeSubsystem::OnAreaGateChanged(const FCyberGameplayAreaGate& Gate)
{
    if (Gate.OverallStatus != ECyberRuntimeStatus::GameplayReady)
        return;   // gate not open, never jump the gun — even if World Partition says "loading done"

    // gate open, and knowing exactly which sub-states are satisfied, only then start orchestrating population in this region
    BeginPopulatingArea(Gate.AreaId, Gate.SatisfiedSubStates);
}

Note it does not ask World Partition “is this cell loaded.” Region readiness is a composed state — World Partition’s cell may have finished loading, but the prefab a quest requires is not yet in place and the prefetch snapshot is not yet confirmed; in that case OverallStatus should not be GameplayReady. Treating “WP loading done” directly as “region playable” is the very root of the introduction’s “hitting a wall that has not yet streamed in.”

The population system starts building stubs only behind the region readiness gate, the traffic system registers vehicle flow only after lane readiness, AI takes over only after entity-gameplay readiness — each gate is an observable, independent signal. This chapter is short, but it is the key to stitching the previous system chapters into a runnable whole: the contract among systems is precisely this string of honestly graded readiness states.


9. Driving the City With an LLM: From Data-Driven to Language-Driven

Driving the city with an LLM: three time layers + director + graded decision state
Fig 11 · Driving the city with an LLM: three time layers + director + graded decision state

By now the city can run itself, but its content and decisions are still written in advance: the NPC’s schedule is in the config store, behavior in the behavior tree, dialogue in the quest graph, and emergent events triggered by a respawn director according to rules. This is a data-driven city — rich, but bounded, and every spot must be filled by a human. This series is called “AI-native game development,” and this chapter answers the truly forward-looking question: if an LLM were to drive this city, how should it be done?

The conclusion first, because it determines the chapter’s whole architecture: the LLM is the director (planner), not the controller. It produces “high-level intent,” and the traditional systems above execute it. Stuffing an LLM into per-frame movement/physics/avoidance is a directional error (the triad of latency, cost, and uncertainty is unacceptable); placing the LLM at the layer of “deciding what an NPC wants to do and what should happen in the city” is its proper place.

① Swapping the driving force for an LLM: three time layers

Chapter 1 said the city is driven by “phase scheduling + data + events + budget.” LLM driving does not overturn this substrate, nor turn the LLM into an AI system running at runtime; rather, it lets the LLM participate in the “content layer’s” generation and the “decision layer’s” high-level orchestration — it is the planning + authoring layer, and does not take over per-frame execution. Sliced by time scale into three layers, the LLM’s role in each is entirely different:

LayerTime scaleWho does itThe LLM’s role
Fast layerPer frame (~16ms)Traditional codeNever. Movement, physics, avoidance, animation, pathfinding execution
Mid layerSub-second to seconds, runtime asyncTraditional + LLMThe LLM produces decisions/reactions/dialogue, async, structured output
Slow layerSeconds to minutes, offline/pregeneratedLLMThe LLM produces content: backstory, schedules, quest outlines, dialogue pools
  • The slow layer (offline content generation) — the safest, and should be shipped first. Use the LLM offline to generate NPC backstories, a day’s schedule, personality tags, quest outlines, and dialogue candidate pools, then land them as records in that config store from Chapter 1. Note: this layer is essentially still data-driven — only the data’s author changes from designer to LLM. At runtime not a single LLM call is made: zero latency, zero runtime cost, fully controllable. A city’s “density” and “diversity” can be amplified by an order of magnitude this way, with almost no new engineering risk.
  • The mid layer (runtime async decisions) — the core of LLM driving, and the hardest. When an NPC meets a situation outside its presets (the player does something unexpected, two NPCs’ schedules conflict, an emergent event occurs), submit the current world facts + this NPC’s persona + a set of legal candidate actions to the LLM, and the LLM returns a structured decision (which action to take, what to say to whom). Dynamic dialogue is also at this layer. It must be async — one call is hundreds of milliseconds to seconds, and must never block the game thread.
  • The fast layer (per-frame execution) — the LLM never touches. An NPC deciding “go to the bar to find someone” is intent produced by the LLM; but “how to walk there, avoid traffic, play the walk animation” is all done by the pathfinding (Chapter 6) / movement / animation systems above. The LLM gives the goal; the engine gives the process.

② The key insight: an LLM call is just one more “acceptance ≠ completion” async boundary

This is the key to stitching the first eight chapters together with the LLM. Recall the discipline running through the whole article — acceptance ≠ completion, grade the state, never flatten it into a single boolean value — it applies verbatim to LLM calls. An LLM request, a spawn token, a pathfinding query, a workspot command are the same kind of thing: a delayed, possibly-failing, staged asynchronous operation.

So the state of an LLM decision should also be graded:

UENUM()
enum class ECyberLlmDecisionStatus : uint8
{
    Requested,     // request assembled and submitted, but the model has not started
    Streaming,     // the model is producing tokens
    Parsed,        // structured output obtained, but not yet validated
    Validated,     // validation passed (action legal, target exists)
    Applied,       // decision written back to the command queue / fact bus
    Rejected,      // validation failed (hallucination/illegal action) → go to fallback
    TimedOut       // timed out → go to fallback
};

“The LLM returned” does not equal “the decision is usable” (it may be a hallucination), still less “the decision has executed.” Two gates — validation and mapping — lie in between. This state machine is isomorphic to Chapter 2’s entity lifecycle and Chapter 6’s pathfinding status bits — the LLM has not broken this city’s engineering discipline; it is merely one more async producer hanging off the driving substrate. This is also why making the city “graded state + fact bus + budgeted” in the first place is itself paving the way for LLM driving.

③ Architecture: the LLM as director, tools as the execution end, the fact bus as perceptual input

Connecting the LLM into the substrate above has three interfaces already ready-made, which is why the design of the first eight chapters is LLM-friendly:

1. Tool use: expose the city’s capabilities as the LLM’s tools. The “actions” of every system above — pathfind to a place, play a workspot, join traffic, occupy a smart object, write a fact, advance a quest — are wrapped as LLM-callable tools with structured parameters. The LLM does not manipulate the world directly; it calls tools, and the engine executes. The key is: the tool’s parameters are constrained to a legal set (the target can only be chosen from “nearby known locations,” the action only from “what this NPC can currently do”) — this is the fundamental means of curing hallucination; the LLM cannot “walk to a bar that does not exist,” because it simply is not in the tool’s enumeration.

// The LLM director's tools (design): each maps to a ready-made capability of some system above
// MoveTo(targetId)       → pathfinding system (Chapter 6)
// UseSmartObject(soId)   → smart object/workspot (Chapter 5)
// JoinTraffic(laneId)    → traffic (Chapter 7)
// SetFact(factId, value) → fact bus (Chapter 1)
// Say(targetId, line)    → dialogue/UI
// targetId / soId / laneId all come from the "current legal set"; the LLM cannot pick a value outside the set

2. The fact bus ↔ LLM context: FactsDB is the LLM’s world input. For the LLM to make decisions it must “see the world,” and what it sees is precisely that fact store from Chapter 1 — current time, weather, what this NPC knows, what happened nearby, its relationship with the player. Serialize the relevant facts into the LLM’s context (prompt), and the LLM’s decision writes back to facts/command queue. The fact bus is naturally the LLM’s “perceptual input + memory.” NPC memory consistency (it remembers the player attacked it yesterday) is guaranteed by fact persistence (save games) — reusing the earlier persistence grading, no need to build a separate memory system for the LLM.

3. Budgeted + cached + fallback: LLM calls must be scheduled like smart object resolution. LLM calls are expensive (token cost + latency), so they must be budgeted, with logic identical to Chapter 5’s smart object resolution:

  • Spatial localization: only NPCs near the player and relevant to the story call the LLM for decisions; distant NPCs use cheap rules / behavior trees (just as Chapter 3’s distant crowd dots do not run full AI). This is “cost equals distance” carried directly onto the LLM.
  • Budget: there is a cap on LLM calls per frame/per second (drawn from that FCyberFrameBudget in Chapter 1); over the cap, queue — just like spawn-queue throttling.
  • Cache: cache and reuse decisions for identical situations (the same kind of NPC meeting the same kind of event need not ask the model each time); use prompt caching for the unchanging parts of the prompt (world rules, persona), saving tokens and latency.
  • Fallback chain: LLM timeout/failure/illegal output → fall back to the traditional behavior tree or default schedule. This is the analogue of Chapter 5’s “missing-animation degradation chain” — when the LLM is unavailable, the city degrades into a data-driven city, rather than stalling. Moreover, during the hundreds of milliseconds the LLM is “thinking,” the NPC first uses a placeholder behavior (keep walking, play idle), switching when the decision returns — latency hidden by behavior, just as the workspot dwells when an animation is missing.

4. A global director. Beyond each NPC’s local decision, set up a global LLM director, analogous to the traditional respawn director — it decides, by the current pacing (what the player is doing, how long since the last dramatic climax), “when to add a new event to the city”: trigger a street incident, have a gang act, arrange a chance encounter near the player. The director does not manipulate details; it writes a “something should happen now” fact to the fact bus, and the concrete systems land it. This is the AI-native way to “give the city a sense of narrative breathing.”

④ How specifically to wire it up with Claude

Down to implementation, a mid-layer decision’s call looks like this (async, structured output, prompt caching):

// Rebuild project: LLM director subsystem (design skeleton)
void UCyberLlmDirectorSubsystem::RequestDecision(FCyberEntityId Npc)
{
    FCyberLlmRequest Req;
    Req.System   = BuildPersonaPrompt(Npc);          // persona + world rules, prompt-cacheable
    Req.Context  = SerializeRelevantFacts(Npc);      // fact bus → world state
    Req.Tools    = BuildLegalToolset(Npc);           // legal action set (cures hallucination)
    Req.Budget   = FrameBudget.Acquire(FrameBudget.LlmDecisionSeconds, /*want*/0.0);
    Decisions.Add(Npc, ECyberLlmDecisionStatus::Requested);

    AsyncLlmCall(Req, [this, Npc](FCyberLlmResult R)  // async, does not block the game thread
    {
        // back on the game thread: Parsed → Validated → Applied, advancing stage by stage
        if (!Validate(R)) { Fallback(Npc); return; }  // illegal/hallucination → fallback
        ApplyToCommandQueue(Npc, R.ToolCalls);        // map to command queue / quest graph
    });
}

A few Claude-side points:

  • Structured output goes through tool use: make the model call a predefined tool rather than emit free text — the output is naturally structured and naturally constrained to the legal set, and validation is mostly done at the tool-call layer.
  • prompt caching: for the long prefixes that are the same every time — world rules, NPC persona — use prompt caching. This is an enormous saving for “a city of NPCs sharing one world setting” — once the cache hits, both latency and cost drop sharply.
  • Model tiering: use a fast model (e.g., Haiku class) for a local NPC’s small decisions, and a strong model (e.g., Opus class) for the global director’s large orchestration — yet another “cost tiering,” the same idea as the crowd’s three tiers and AI LOD.
  • Latency budget: the latency of a mid-layer decision must be smaller than the reaction window the player can perceive; what cannot be hidden should be moved to the slow layer to be pregenerated.

⑤ Boundaries and pitfalls (all isomorphic to the foregoing)

  • Hallucination → use tool use + legal-set constraints, so the model cannot pick something that does not exist; add another validation for critical decisions (Chapter 8’s readiness-gate idea: a decision must pass a “legality gate” before being Applied).
  • Consistency/memory → fact-bus persistence; an NPC’s memory is a fact, not temporary text in a prompt.
  • Cost/latency → budgeted + cached + spatially localized + model-tiered, all a reuse of the earlier scheduling discipline.
  • Uncertainty → the LLM produces intent, the traditional systems produce execution, and the execution layer is deterministic; LLM failure has a fallback chain to catch it.
  • Acceptance ≠ completion → an LLM call is an async boundary, the state is graded, and “the model returned” is never treated as “the decision took effect.”

To close this chapter in a sentence: the LLM does not rewrite this city’s engineering for you; it connects onto the driving substrate the city has already built — writing content into the database, writing facts onto the fact bus, calling ready-made systems through tools, scheduled under the discipline of budget and fallback. Making the city “data-driven + graded state + fact bus + budgeted” is itself paving the way for LLM driving. AI-native is not tearing down and rebuilding, but letting this substrate’s content authoring and high-level decision orchestration shift from human-written to model-written — while the per-frame execution layer and discipline layer change not a single line. In other words, the LLM moves up to the planning and authoring layer, and does not sink down to the runtime control layer.


10. Migration Trade-offs: Standard First, Advanced Systems Are a Scaling Upgrade

Migration escalation path and three engine-readiness grades
Fig 12 · Migration escalation path and three engine-readiness grades

Finally, back to methodology. In rebuilding a city like this, the most likely strategic error is reaching for advanced systems at the very start — fretting over how to partition MassEntity archetypes and how to bake ZoneGraph data before the gameplay semantics even run. The rebuild project’s adaptation strategy fixes the order:

Each system chooses its implementation by this priority: ① UE standard features → ② UE advanced systems → ③ project plugin adaptation layer → ④ editor/commandlet tools → ⑤ engine source changes (only in the final stage).

Applied to the systems in this article, this is a “standard first, upgrade later” roadmap, each cell flagging “when it’s actually time to upgrade the weapon”:

SystemUse first (standard/existing)Upgrade later (advanced)Upgrade trigger
Driving coretick groups + DataTable + message subsystemMass phase graph + DataRegistry + custom scheduler/budgetSystem count and scale rise
Entity lifecycleActor/Component + WorldSubsystemcustom ECS / MassEntity scale approaches the Actor cap
Populationspawn zones + lightweight actorscustom DOD runtime (replicate the source; this project’s choice) / or MassEntity/MassCrowdupgrade once scale grows
NPC behaviorAIController + BehaviorTreeStateTree / custom plannerBT cannot express the needed state
Smart objectscomponents + trace + SmartObjectsStateTree deep integrationInteraction complexity rises
PathfindingRecast/Detour (engine-native)custom graded result + stitching layerMulti-graph stitching becomes the bottleneck
Vehicle physicsChaos Vehicles (player vehicle)custom kinematics (AI vehicle)Split needed from the start
Traffic schedulingZoneGraph self-build / migratefull MassTrafficTraffic scale requires Mass
Content/decision drivingdata-driven (config store + behavior tree + quest graph)LLM driving (offline gen → runtime director)Want a “living,” “improvising” city

Hidden in this table is the article’s most honest conclusion: the engine-readiness of these systems varies enormously.

  • Nearly provided natively: entity lifecycle (standard Actor/Subsystem, and the rebuild project has shipped it as a template), pathfinding underlayer (UE and the source runtime share the source, both Recast/Detour).
  • Have a strong ready-made skeleton but need gameplay semantics filled in: crowds (MassEntity/MassCrowd give three-tier LOD — but this project prefers “replicate the source’s custom data-oriented runtime,” with Mass as optional), NPC behavior (BehaviorTree/StateTree give the behavior framework), smart objects (the SmartObjects plugin gives interaction points), vehicle physics (Chaos gives the player vehicle). UE gives the “form” of these, but the source runtime’s truly valuable “scheduling discipline” — budgeted, graded state, degradation chain, deterministic substeps — the engine does not give, and this is the main workload.

A clarification, to avoid reading the whole article as “the source runtime is stronger everywhere”: the “② strengths comparison” concerns “semantic fit,” not “a ranking of better/worse.” In exactly two places UE5 is the stronger — not weaker — and a different paradigm: Chaos Vehicles provides a complete vehicle-physics SDK (the source runtime is a custom rigid body; each has trade-offs), and StateTree’s shared asset + compact instance data is cleaner and more engineered than the source runtime’s hand-written instance layout. In these two places the migration should adopt the UE paradigm and ride with it, rather than reproduce a more primitive implementation just to “copy the source runtime.”

  • No unified built-in solution, needs migration or self-build: vehicle flow / traffic scheduling. The standard engine does not ship MassTraffic (it is in City Sample), and ZoneGraph gives only the lane-graph structure, not the scheduling layer. This is the article’s only spot needing migration or building from scratch, and its schedule must be reserved separately.

This table also adds one cell at each end: at the top, the driving core (Chapter 1) — not a gameplay system but the phase/data/event/budget substrate that carries all the others, deciding “why they take a staged shape”; at the bottom, content/decision driving (Chapter 9) — the architecture’s ultimate upgrade direction: when the data-driven city is not “alive” enough, shift the content layer and the decision layer from human-written to model-written. The two, head and tail, frame the seven systems in between: the substrate decides how they run, and the LLM decides who authors them.

And whichever cell, the spine running through the whole article does not change: preserve the graded state of each system from the source runtime on the UE5 side exactly as-is, and never flatten it into a single boolean value. Clever behavior and beautiful traffic flow can grow slowly; but as long as some system quietly treats “I received it” as “I handled it,” this rebuilt city will, sooner or later, at some intersection, let an NPC not yet registered in the table drive a vehicle whose physics is not yet in place into a wall that has not yet streamed in.

To distill the article’s judgment into one executable migration order: first use standard Actor/Subsystem to lay a solid foundation of graded state (this is the validated template), then use engine-built systems like BehaviorTree/StateTree, SmartObjects, Recast/Detour, and Chaos to carry the “skeleton-ready” domains, and concentrate the saved engineering investment on the two things the engine does not provide — one is each system’s scheduling discipline (budgeted, degradation chain, deterministic substeps, visibility priority), the other is traffic, the only system that must be built from scratch or migrated. Get the order wrong — for instance, starting on MassTraffic migration or fretting over ZoneGraph data baking first — and you will sink into engine details before the gameplay semantics even run, which is exactly the most common way large migrations fail.

Abstracting one level higher, this methodology applies not only to this one city. Any migration of a mature proprietary engine’s runtime onto a general-purpose engine faces the same set of questions: which are algorithms the engine provides natively (use as-is), which are semantics the engine gives a framework for but you must complete (carry then fill), and which are gameplay strategies the engine has none of (self-build). Sort these three tiers clearly, and in each tier hold to the discipline of “don’t flatten the graded state into a boolean value,” and the migration is half done. The other half is patience — patiently building a dedicated status bit in the new engine for every “seemingly complete” callback.

Migrating a city that runs itself, what must be copied is never some ingenious algorithm, but this utterly unshortcuttable discipline.


AI Collaboration Retrospective

  • What the AI helped with: dispatched multiple parallel agents to do a source-level deep read of five major systems of the source runtime — population, NPC behavior (including both the traditional and new behavior frameworks, command queue, signals, pathfinding, movement strategy, AI tweak action, AI LOD), smart objects and workspots, navigation and traffic, vehicles and suspension — bringing back the real mechanisms and boundaries point by point (the command queue’s “reserve a sentinel slot,” pathfinding’s “pseudo-async + status-bit combination criteria,” smooth contact synthesis, the crowd’s three-tier handoff, the workspot’s missing-animation degradation chain, etc.); and mapped these mechanisms one by one onto UE5’s corresponding systems, distinguishing the three grades of “provided natively / has a skeleton needing semantics filled in / needs self-build.”
  • Where a human had to step in:the grade verification was forced out by a human — the user’s single line “are you sure these all have an implementation reference?” blocked the optimistic narrative that “every chapter has a ready-made wheel,” prompting hands-on verification of the engine plugins (confirming the real status of MassTraffic / the standalone ChaosVehicles naming, etc.), and the article’s positioning was therefore honestly changed from “implementation walkthrough” to “source-level evidence + rebuild design blueprint”; ② the anonymization boundary — the source runtime’s internal class names / file line numbers were kept only in the research notes, and the body uses generic engineering terminology throughout, not fingerprinting the original; ③ figures, glossary, and the English version were left to a later batch following “Chinese first → review → English → publish.”
  • How it was verified: every mechanism conclusion is traceable to a corresponding system’s source-level deep read (with file:line), and the UE5-side “has/has no ready-made system” conclusions come from hands-on verification of the engine’s plugin directory (which .uplugin files are present, which are not), not conclusions drawn from memory.

Leave a Reply

Discover more from AI Native Game Development

Subscribe now to keep reading and get access to the full archive.

Continue reading