Open-World Narrative · Architecture

Perception, Crowd & the Morality System

A focused page within the Open-World Narrative Architecture documentation. The full map and every sibling page live in the Architecture hub.

7sections12 minread1diagram

On this page

V5 is seven games wearing one trench coat. A 1947 LA detective's witness on a rain-slick street, a Hong Kong triad lookout, an 1899 frontier shopkeep sizing up a rider, a Witcher-cell village watchman, a sci-fi station marine — all of them read the world with the same semantics: the same notion of a stimulus, the same Idle → Curious → Alert → Hostile escalation, the same idea of "a crime was witnessed, and someone is now walking toward an authority to report it." And every one of those acts lands on a single moral compass that the cross-cell Mind-Palace and the Bureau XP economy can read, even though each cell measures right-and-wrong on its own local axis (Cop/Triad here, Family/Outsider there, Honor/Bounty out west, Paragon/Renegade in orbit).

V5 keeps that honest across cells by pushing all of it into four small, deterministic C++ modules that share one design rule: the logic is pure functions over plain data, and the caller holds the state. V5Perception turns geometry into suspicion; V5Crowd turns a Mass-Entity density profile into a panicking, fleeing, bark-shouting population; V5Honor is the morality/reputation core — honor bands, bounty, witness reports, and the cross-cell bridge; and V5Wanted is the star-rated, era-scaled law-enforcement response the bounty system escalates into. None of it is a TypeScript contract package — the source of truth is the Unreal C++ under V5/ue/Source/V5Perception, …/V5Crowd, …/V5Honor, …/V5Wanted and each module's Private/Tests. This page is the architecture-side companion for V5's shared AI and morality systems, part of the Shared Gameplay Foundations group; the orientation hub is ../V5_ARCHITECTURE.md.

What ships, honestly#

Five claims, plainly, so the rest reads at face value.

  • Real, deterministic, value-tested logic. All four modules compile — the linker has produced Binaries/Linux/libUnrealEditor-V5Perception.so, …-V5Crowd.so, …-V5Honor.so, and …-V5Wanted.so on this box. The algorithms are domain-specific, not renamed CRUD, and the automation specs assert computed values rather than shapes: that assault-then-loot-then-help on a fresh honor meter lands at exactly -8 → -11 → -7, that a 1.5× train-robbery charge pushes a bounty to $240, that an all-light-side bridge input produces a moral compass > 0.9 and an all-dark one < -0.9, that an NPC on a panic radius boundary at 50 m is still "affected" but one metre further out is not. Those fail against placeholders.

  • The crowd vocabulary is a real authored corpus, not a generator. V5Crowd loads V5/ue/Content/V5Crowd/Data/crowd_catalog.json — a 1.5 MB, 6,050-line file of ambient barks plus 8 density profiles and 6 body-language era bundles — and ValidateCrowdCatalogJson is a real linter that rejects the set unless it has ≥ 5000 bark lines, unique ids, all six era bundles, and coverage of the five playable crowd cells. The bytes are authored on disk, not faked by a handful of templates pretending to be thousands.

  • Stateless-library design is the architecture, not a shortcut. Every system class in these four modules is a UBlueprintFunctionLibrary of static BlueprintPure functions — 33 of them — operating on USTRUCT value types. There is no UV5PerceptionComponent, no actor, no UWorldSubsystem, and no Behavior Tree, Blackboard, EQS, UAISense, or StateTree anywhere in V5Perception/V5Crowd/V5Honor/V5Wanted (a grep returns nothing). The pawn or Blueprint that owns the NPC holds the CurrentSuspicion/FV5WantedState and calls these functions each tick. That is a deliberate data-oriented choice — reproducible, server-friendly, trivially testable — but it is also the honest caveat: these are library-grade building blocks with no in-tree runtime loop wiring perception → crowd → honor → wanted together. No production caller of UV5_Perception_StateMachine::Evaluate exists outside the specs.

  • Mass-linked, not Mass-driven. V5Crowd.Build.cs links MassEntity, and BuildPopulationBatch emits a real FV5CrowdMassEntityBatch — a lightweight entity count, a spawn radius, a derived EntitySpacingMeters, and a template id like mass.crowd.urban.civilian. But the density/panic/promotion math runs on plain structs, not a live FMassEntityManager processor graph — Mass-aware authoring metadata, mirroring V4's posture.

  • One documented approximation in the bridge. The monolith's moral-compass formula lists two separate period weights (w_period_made, w_period_vice); the shipped ComputeMoralCompass collapses them into a single PeriodWeight applied to both the Family-rep and case-rating terms, with the normalizer using PeriodWeight * 2. The compass is real and tested; this is the one place the code simplifies the design doc, called out here so it isn't mistaken for drift.

Perception: one stimulus model for every NPC#

V5Perception is the cross-cell sensory core. It has no per-NPC object — it is a set of pure functions in V5Perception/Private/V5PerceptionSystems.cpp keyed by a FV5PerceptionTuning the caller supplies.

Cell tuning — 7 cells × 6 NPC classes#

BuildCellTuningDatasets authors one dataset per V5 cell (Urban, Period, Frontier, Hunter, SciFi, Steampunk, MindPalace — the spec asserts the count equals GetAllV5Cells().Num()), each multiplying a base table of six NPC classes by a per-cell SightScalar/HearingScalar. The scalars encode the fiction: Frontier sees and hears keener (1.12 / 1.18), Hunter keener still (1.18 / 1.25), the MindPalace dream-space is deliberately dull (0.75 / 0.75), and SciFi has sharp eyes but muffled ears (1.25 / 0.90). Within a dataset, an EliteGuard carries a 55 m sight range and a low 42-point alert threshold while a Civilian gets 28 m and a sluggish 60 — so the same stimulus escalates a trained guard faster than a bystander, by data, not by branching code.

The suspicion state machine#

UV5_Perception_StateMachine::Evaluate is the integration step. Each stimulus is converted to a suspicion increment by StimulusSuspicion, and the table is domain-specific: a Sight stimulus is worth 34 × Strength, but 52 if the target is a known suspect (gated on line-of-sight and being inside SightRangeMeters, else zero); Sound is 24 × Strength inside HearingRadiusMeters; a WitnessReport is a flat 48 × Strength; and Damage is 65, or 100 — instant Hostile — when bHostileAction is set. De-escalation (DeescalationPerSecond, default 4/s) is applied only on a tick where the stimulus added nothing, so a guard with a live stimulus this frame never bleeds suspicion at the same time — the same "perceive or decay, never both" invariant V4 enforces. StateForSuspicion then maps the clamped [0,100] value through the tuning's Curious/Alert/Hostile thresholds, and bEscalated is a true state-rank comparison against the previous state. (One honest difference from V4: there is no hysteresis margin here — it is a plain threshold comparison, with no guard against strobing on a boundary.) The spec walks a guard Idle → Curious on a sound, → Alert on a known-suspect sighting, and → Hostile on a hostile-action damage event, checking bShouldInvestigate flips off once Hostile.

Sight, hearing, and the sound-absorption model#

EvaluateSight is a real cone test: normalize observer-forward and to-target, take the dot product, convert to a half-angle, and require Distance ≤ EffectiveRange and Angle ≤ SightConeDegrees × 0.5 and a caller line-of-sight bool. Hearing is the more interesting half. ComputeTransmittedStrength runs the loudness through a stack of FV5PerceptionAbsorptionLayer walls, each attenuating by a material coefficient × thickness — Foliage 0.18, Drywall 0.35, Brick 0.58, Stone 0.72, Bulkhead 0.82 per metre — clamped so a single wall can never block more than 95 %. EvaluateHearing then declares a sound heard only if the transmitted strength clears 0.12 and the listener is inside the strength-scaled radius. The spec proves a brick-plus-bulkhead double wall drops a full-strength sound below audibility at a distance that was clearly audible in open air — sound that genuinely routes around geometry, not a flat radius.

Witness propagation and "investigate last-known"#

UV5_Perception_WitnessReport::Propagate is the NPC-to-NPC alert spread: every recipient inside PropagationRadiusMeters (default 80 m) joins the alerted set, the propagated suspicion is Urgency × 65, and recipients flag alerted only if that clears 50. BuildMoveRequest closes the loop — for a Curious or Alert state it emits an FV5PerceptionInvestigateMoveRequest aimed at the stimulus's LastKnownLocation, with a tighter 120 m acceptance radius when Alert versus 220 m when Curious. That last-known plumbing turns a suspicion spike into an actual search behavior the pawn can drive.

Crowd simulation — Mass-batched density, panic, and barks#

V5Crowd (V5Crowd/Private/V5CrowdSystems.cpp) turns a density profile into a living street.

Density batching and pawn promotion. BuildPopulationBatch takes a FV5CrowdDensityProfile (Urban targets 250 NPCs within 100 m, per the catalog), scales the count by a console-throttle percentage and a streaming-churn dampener, then derives EntitySpacingMeters as sqrt(πr² / count) so the crowd is laid out by area, not stacked — the spec confirms desktop keeps the full 250 while a console build throttles below it. EvaluatePromotion is the lightweight-entity-to-pawn rule: an NPC promotes to a full pawn inside its role radius — Named 18 m, Generic 30 m, Ambient 60 m — or unconditionally if it is mission-critical, mid-interaction, or already promoted, each path stamping a distinct ReasonTag.

Panic propagation. EvaluatePanicAtLocation is the heart of the crowd response. The effective radius is the base radius scaled by a stimulus multiplier (Gunshot 1.0, VehicleCrash 1.15, Alarm 1.25, MonsterAttack 1.5, Explosion 1.7), a line-of-sight factor (1.0 vs 0.55 when occluded), an indoor factor (0.8), and an age decay that fades the stimulus over 45 s down to a 0.25 floor — so a gunshot's panic footprint shrinks the longer ago it fired. Inside that radius the panic intensity is a clamped distance falloff, the NPC flees along the vector away from the stimulus, and bShouldFlee trips on intensity ≥ 0.25 or unconditionally for explosions and monster attacks. The spec pins the edges precisely: at 25 m from a 50 m gunshot the NPC has ≥ 0.5 intensity and flees outward (+X), exactly on the 50 m boundary it is affected but only startles, at 51 m it is untouched, an explosion still reaches 80 m, and the same gunshot occluded no longer reaches an NPC at 30 m.

Witness reporting into Honor. UV5_Crowd_WitnessReport::BuildWitnessReport is the seam where the crowd hands a crime to the morality system. It fails loud on each precondition — missing witness/authority, no line of sight, crime beyond 85 m, or zero bounty at stake each return a tagged reason and submit nothing — and only then calls into V5Honor's BuildWitnessReport, optionally shaving the report delay 20 % when panic-accelerated. The spec confirms a witness with a bounty in view does submit to Honor and that panic acceleration keeps the delay under baseline.

Body language and the bark corpus. SelectBodyLanguageLibrary picks the era-correct animation set, and SelectAmbientBark is a graceful-degradation matcher: it filters the 6,050-line corpus by era + cell + mood + zone, falls back to era+cell+mood, then era+mood, and picks within the match set by abs(Seed) % count, so the same seed deterministically yields the same line.

The morality system — Honor, bounty, and the moral compass#

V5Honor is the signature open-world-narrative system: a persistent reputation/morality core whose effects ripple through greetings, prices, encounters, and law enforcement.

Honor meter and bands. HonorScore lives in [-100, 100]. HonorDeltaForAction is the authored consequence table — AssaultCivilian -8, LootBodyInTown -3, HelpStranger +4, StoryMercy +12, StoryCruelty -12 — and GetHonorBand buckets the score into VeryLow (≤ -60), Low (< -20), Neutral (≤ 20), High (< 60), VeryHigh (≥ 60). The score round-trips through the GAS attribute set UV5_AttributeSet_Honor (which also carries the ParagonScore, RenegadeScore, CopRep, TriadRep, FamilyRep, OutsiderRep, and WitcherHumanity sub-axes that feed the bridge below).

Bounty and witness reports. GetBaseBounty prices crimes — Trespass $5, Theft $10, Assault $15, Murder $60, TrainRobbery $120 — and ApplyBountyCharge adds nothing for an unwitnessed crime, otherwise scales by a state multiplier (the spec proves a witnessed murder posts $60 and a 1.5× train robbery adds $180 to reach $240). BuildWitnessReport is the tension mechanic: a witness will report only if there is both a witness id, an authority id, and a bounty at stake, and the time-to-report is clamp(90 + distance×0.6 − low-honor-acceleration + high-honor-delay, 15, 180) — so an infamous (very-low-honor) player is reported faster, a respected one gets a grace delay, and the spec confirms the low-honor report fires sooner and that advancing past the timer transitions the report Fleeing → Reported.

NPC reactions. The honor band drives lived-in reactions: BuildGreeting swaps lines and tone tags (hostile at VeryLow, admiring at VeryHigh), BuildDiscount gives shopkeepers a 10 % discount at High and 20 % at VeryHigh, and BuildEncounterFlavor reweights the encounter director — high honor biases toward help-needed events, low honor toward ambushes, and a standing bounty adds bounty-hunter weight proportional to dollars owed.

The cross-cell bridge and the Wanted escalation#

ComputeMoralCompass is the meta-axis: it folds every cell's local morality into a LightScore and a DarkScore (max(0, CopRep) and case-rating on the light side, TriadRep/OutsiderRep and RenegadeScore on the dark), then normalizes the difference into a MoralCompass in [-1, 1]. GetBureauXPMultiplier rewards conviction in either direction — 1.5× at magnitude ≥ 0.7, 1.25× at ≥ 0.4, 1.0× near zero. On enforcement, V5Wanted's EvaluateStarLevel maps heat to a 0–5 star rating (thresholds 10/25/45/70/100), ApplyCrimeEvent adds heat (×1.35 if violent, +6 for a vehicle escape) only when witnessed, TickEscalation ramps heat faster at higher stars while police hold contact, and BuildSpawnPlan assembles an era-scaled response — modern LAPD escalating to attack-helo at 4★ and tank/VTOL at 5★, an 1899 Marshal + PosseRider, a Hunter VillageWatch + LordsGuard, a sci-fi FactionTrooper + PatrolCruiser. Players shed it via TickOffTheRadar (90 s clear once ≥ 35 m from last-known with no police visual/audio), TickSafehouseHide (3× faster in a garage), or TryPayoff (baseCostPerStar × stars² + bounty, then a 900 s cooldown).

flowchart TD Act[Player action / crime] --> Honor[V5Honor: HonorDeltaForAction<br/>HonorScore in -100..100] Act --> Bounty[ApplyBountyCharge<br/>gated on bWitnessed] Honor --> Band[GetHonorBand: VeryLow..VeryHigh] Band --> React[Greetings / shop discount /<br/>encounter flavor] Bounty --> Witness[Witness BuildWitnessReport<br/>time = 90 + dist*0.6 -/+ honor] Witness --> Wanted[V5Wanted: EvaluateStarLevel 0-5] Wanted --> Spawn[BuildSpawnPlan<br/>era-scaled: Marshal / LAPD / Watch / Faction] subgraph Bridge [Cross-cell meta-axis] Axes[Cop/Triad - Family/Outsider -<br/>Honor - Humanity - Paragon/Renegade] --> Compass[ComputeMoralCompass<br/>Light - Dark, clamp -1..1] Compass --> XP[Bureau XP x1.0 / x1.25 / x1.5] Compass --> Gate[Mind-Palace deduction gating] end Honor --> Axes Spawn --> Clear[Off-radar 90s / safehouse 3x / payoff]

Edge cases and failure modes#

  • Hostile action skips the curve. A bHostileAction stimulus snaps suspicion straight to 100 — no slow climb to Hostile when someone is actively attacking.
  • Sound routes around walls. A double brick/bulkhead wall can drop an audible sound below the 0.12 audibility floor at a distance that was clearly heard in the open — occlusion is multiplicative, not a flat cutoff.
  • Unwitnessed crimes are free. Both ApplyBountyCharge and ApplyCrimeEvent no-op on an unwitnessed flag — no bounty, no heat — so a clean kill genuinely leaves no trail.
  • Infamy is read faster. Witness TimeToReportSeconds accelerates with negative honor and delays with positive — a notorious player is reported sooner than a respected one at the same distance.
  • Panic decays with age, not just distance. The 45 s age scalar shrinks a stimulus's panic radius over time; a stale gunshot stops scaring people.
  • The crowd-to-honor seam fails loud. A witness with no line of sight, a crime past 85 m, or zero bounty submits nothing and returns a tagged reason rather than half-reporting.
  • The un-orchestrated seam. Each function is exercised by its spec and nothing else in-tree; the modules agree by construction but no runtime loop yet chains perception → crowd witness → Honor → Wanted. This is the honest gap between the proven blocks and an assembled loop.

How it connects#

This perception/morality core is the hub the rest of V5's NPC behavior reads. The honor attribute set and the animation tells that present a suspicion state mount on the shared Ability System and animation/input stack in ./gas-animation-input.md. The crowd density profiles ride the same World-Partition grid, and the Wanted/encounter directors wire to the GameFeatures mode plugins, in ./modes-streaming-procgen.md. The server-authoritative replication of suspicion, honor, bounty, and wanted state — distinct from the spaceship cell's rollback model — is in ./netcode-authority-and-determinism.md. The orientation hub is ../V5_ARCHITECTURE.md.