Tactical Action · Architecture

World Streaming & Procedural Generation

A focused page within the Tactical Action Architecture documentation. The full map and every sibling page live in the Architecture hub.

6sections12 minread1diagram

On this page

V4's sandboxes are its hardest spatial problem. A Hitman level wants a 50 m-cell mansion full of patrolling staff; a Commandos or Desperados map wants a wide exterior with a hundred bodies in it; the roguelike-tactics campaign and the daily-mission ladder want a different, fair, fully reachable layout every run — and all of it has to stay inside a console frame budget while six genres share one engine. Three in-tree C++ subsystems carry that load, and they are deliberately small and deterministic so they can be reasoned about and tested: V4LevelOps owns World-Partition streaming profiles and HLOD pre-build planning, V4Procgen is a seeded grid-layout solver with bot-path playability proofs and daily seeds, and V4Crowd is a tiered crowd model with panic, disguise, exfil routing, and a performance governor. They are separate modules, but they meet at exactly one shared idea: the integer streaming cell — a FloorToInt(location / cellSize) lattice that the streamer loads against and the crowd demotes against, computed by identical math on both sides so the two halves can never disagree about which cell an NPC is standing in.

This page is the world-and-content companion to the netcode/modes cluster. It is the deep dive behind the "Sandbox Streaming," "Procedural Generation Pipeline," and "V4Crowd" summaries in the orientation hub ../V4_ARCHITECTURE.md, and it sits next to ./game-modes-live-service.md (the modes that consume a daily seed or a streamed map), ./per-cell-deep-dives.md (the genre cores that those modes layer on), and ./presentation-pipelines.md (the audio/VFX/HLOD art these systems reference but do not bake).

What ships, honestly#

Three layers, stated plainly.

  • Real, deterministic, and test-covered. All three modules compile — the linker has produced Binaries/Linux/libUnrealEditor-V4Procgen.so, …-V4Crowd.so, and the V4LevelOps library on this box. The algorithms are domain-specific, not renamed CRUD: a seed-stable constraint solver with a BFS reachability proof (V4Procgen), a capacity-aware nearest-exfil flee router and a relevance-sorted tick governor (V4Crowd), and a concentric loaded-ring / distant-HLOD-ring planner with a megabyte budget pruner (V4LevelOps). Each is pinned by a dedicated automation spec — V4.Procgen.Runtime.GraphsDeterminismValidationDailySeed, V4.Crowd.Panic.PropagatesAcrossPopulation, V4.Crowd.PerformanceBudget.DemotesToEightMilliseconds, and the V4LevelOps streaming spec — that assert computed values, not just truthiness.
  • Modeled as data, not baked art. Consistent with V4's tree-wide 0-binary-.uasset accounting (see ./high-level-architecture.md), every asset these systems name is a soft reference, not a cooked binary. A cell's PCGGraph is a TSoftObjectPtr<UPCGGraphInterface> pointing at /Game/V4Procgen/PCG/PCG_Tactical; a chunk's ChunkAsset is an FSoftObjectPath at /Game/V4Procgen/Chunks/…; an HLOD layer is a path string like /Game/V4LevelOps/HLOD/DA_HLOD_HitmanHighDetail. None of those targets exist as binaries in-tree. Crucially, the generation that actually runs is the hand-written C++ grid solver, not PCG-node executionV4Procgen links the PCG module and stores the soft graph pointer as authoring metadata, but GenerateMissionLayout never loads or evaluates a UPCGGraph.
  • Library-grade, not yet orchestrated. Each entry point is exercised by its automation spec and by nothing else in the tree. GenerateMissionLayout / DispatchDailySeed have no production caller (the "server broadcasts a seed at 00:00 UTC" loop is described in the monolith, not a running service here); BuildStreamingState and DemoteAgentsOutsideLoadedCells likewise have no shared orchestrator that calls both per tick. The two streaming halves agree on cell math by construction, but the glue that feeds the streamer's LoadedCells into the crowd's demotion pass at runtime is not in-tree. This is the honest gap between the assembled game loop and the proven building blocks.

World streaming — V4LevelOps#

World-Partition map profiles#

Streaming is configured per map by an FV4WorldPartitionMapProfile (V4LevelOps/Public/V4LevelOpsTypes.h:127): a CellSizeMeters, a StreamingRingRadiusCells, a HlodPrebuildRingRadiusCells, a HlodBudgetMegabytes / EstimatedHlodMegabytesPerCell pair, and an HlodLayerAsset soft path. RegisterWorldPartitionProfile (V4LevelOpsSubsystem.cpp:280) sanitizes the values — clamps the cell size and ring radii, forces HlodPrebuildRingRadiusCells ≥ StreamingRingRadiusCells + 1 so the HLOD ring is always strictly outside the loaded ring, and hard-pins any bHighDetailHitmanMap profile to 50 m cells (:297). BuildDefaultWorldPartitionProfiles (:316) ships four launch rows that encode the monolith's spec directly: DefaultSandbox at 100 m / 128 MB, and Paris, Sapienza, Miami (the Hitman sandboxes) at 50 m / 96 MB pointing at DA_HLOD_HitmanHighDetail. These are real authored constants the streaming spec reads back: it asserts the default sandbox uses 100 m cells and a one-cell ring, and the high-detail Hitman map uses 50 m cells.

The streaming ring and distant HLOD#

BuildStreamingState (V4LevelOpsSubsystem.cpp:358) is the per-frame planner. It resolves the player's cell with GetWorldPartitionCellForLocation (:453) — FloorToInt(Location / (CellSizeMeters × 100)), i.e. world centimetres to a metre-cell lattice — then fills two arrays. V4BuildCellRing (:59) emits the loaded set as the square ring of radius StreamingRingRadiusCells around the player (radius 1 → a 3×3 = 9-cell block). V4BuildDistantHlodCells (:75) emits the HLOD set as every cell out to HlodPrebuildRingRadiusCells minus the cells already in the loaded ring — the donut between the streamed interior and the horizon. The streaming spec proves the geometry exactly: a player at (5250, −1250) on the 50 m Paris profile lands in cell (1, −1), the one-cell ring yields 9 loaded cells including the diagonal neighbour (0, −2), and the radius-2 HLOD donut yields 16 cells (the 5×5 = 25 outer block minus the 3×3 = 9 inner block).

HLOD budget enforcement#

Pre-built HLOD for distant cells costs VRAM, so it is budgeted, not unbounded. BuildHlodForProfile (:385) computes EstimatedMegabytes = HlodCellCount × EstimatedHlodMegabytesPerCell and flags bWithinBudget against the profile's ceiling — for the Hitman profile that is 16 cells × 6 MB = 96 MB, exactly its 96 MB budget, which the spec confirms passes. When a profile overflows, EnforceHlodBudget (:422) does real work rather than just reporting failure: it divides the budget by the per-cell cost to get a maximum cell count and SetNum-truncates the HLOD list to fit, then recomputes the estimate. The spec drives this adversarially — it tightens the Hitman budget to 24 MB, watches bWithinBudget go false, then enforces and asserts the result is pruned to exactly 4 cells / 24 MB. This is a genuine bounded-resource planner, not a constant.

The streaming-crowd seam#

The streamer's output is a list of LoadedCells, and that is precisely the input the crowd's DemoteAgentsOutsideLoadedCells consumes. Both subsystems compute a cell with the same formula — V4LevelOps::GetWorldPartitionCellForLocation and V4Crowd::GetStreamingCellForLocation (V4CrowdSubsystem.cpp:519) are byte-for-byte the same FloorToInt(Location / (CellSizeMeters × 100)) — so an NPC the streamer considers "in cell (1,−1)" is the same cell the crowd considers it in. That shared contract is what lets the dense crowd ride the streaming grid for free; the honest caveat from "What ships" applies — the two are coded to agree, but no in-tree tick loop wires one into the other yet.

Procedural generation — V4Procgen#

Per-cell graphs and the chunk library#

V4Procgen registers one FV4ProcgenCellGraph per procedural cell. BuildDefaultCellGraphs (V4ProcgenSubsystem.cpp:205) authors five — PCG_Tactical, PCG_Stealth, PCG_RTST, PCG_ARPG, PCG_2D — each with a default grid size, objective count, MinimumCriticalPathLength, and BlockerDensity tuned to its genre (Tactical 6×5 / 15 % blockers, the 2D arcade cell a wide 8×3 / 6 %). Each graph carries a TSoftObjectPtr<UPCGGraphInterface> (V4ProcgenTypes.h:43) — the soft PCG reference described above. The art vocabulary is BuildLaunchChunkLibrary (:256): 55 FV4ProcgenChunkDefinition rows spread across the five graphs (12 Tactical, 12 Stealth, 11 RTST, 10 ARPG, 10 2D), each with a role (Spawn/Objective/Extraction/Connector/ Encounter/Landmark/Blocker), an archetype (Room/Hallway/Building/ TerrainTile/Arena/Platform), entry sockets, a footprint, and authored Procgen.Constraint.* tags. ValidateChunkLibrary (:321) is a real linter: it rejects the set unless it has ≥ 50 chunks, unique IDs, every one of the five graph IDs, all six archetypes, and a Procgen.Constraint. tag on every row — and the spec asserts all of those counts (55 total, 12/12/11/10/10 per graph, every archetype present).

The deterministic constraint solver#

The generator that actually runs is SolveChunkConstraints (:433) — a hand-written grid solver, not PCG. For each attempt it seeds a fresh FRandomStream(Request.Seed + Attempt × 7919) (:451), so the same seed reproduces the same world and a failed attempt deterministically perturbs the next. It places Spawn on the west edge and Extraction on the east edge, rejects pairs closer than MinimumExtractionDistance (Manhattan), scatters the required objectives subject to a minimum spawn distance, then lays blockers up to BlockerDensity × gridArea — but it only keeps a blocker if the layout still passes a playability check (:551), so the density target can never wall off the mission. Finally it fills every remaining cell with a role-tagged FV4ProcgenChunk and stable-sorts by SortKey. The spec proves determinism the strong way: it generates the same request twice and asserts the two layouts produce an identical signature string spanning seed, spawn, extraction, every objective, and every chunk's coord/role/blocking flag — a test that would fail instantly against a Math::random stub.

Playability and NPC-placement proofs#

A mission is only shipped if a bot can actually finish it. ValidatePlayability (:622) builds the waypoint list spawn → objectives… → extraction and runs FindPathThroughWaypoints (:913), which chains FindPathSegment (:835) — a real breadth-first search over the 4-neighbour grid that treats bBlocksTraversal chunks as walls and returns the reconstructed path. If any segment is unreachable the mission is rejected with a populated FailureReason; the spec confirms a deliberately walled 3×3 layout is rejected and that the bot path on a valid layout starts at spawn, ends at extraction, and visits every objective. ValidateNpcPlacement (:661) layers on top: it requires a playable bot path first, then counts cells whose role or tags admit an NPC (objective, encounter, landmark, connector, or an explicit Procgen.Constraint.NpcSpawn tag), excluding blockers and the spawn/extraction tiles, and fails if fewer than the requested minimum qualify — the spec asks for ≥ 8 and checks the count mirrors the returned coordinate list.

Daily seeds#

The daily ladder is plumbed but server-less here. DispatchDailySeed (:723) stores a {CellId, DateId, Seed, ServerSignature, DispatchUtc} record keyed by CellId:DateId; BuildDailyDateId (:786) formats a UTC calendar day; and GenerateDailyMissionLayout (:757) looks up the stored seed and runs the same solver so every client that downloads the same signed seed assembles a bit-identical mission. The spec round-trips it: dispatch seed 424242 for 2026-05-23, reload it, generate, and assert the layout carries seed 424242 and the date id. The signature validation and the broadcast service that issues the seed are the described-not-built part.

Crowds — V4Crowd#

Tiers and the performance governor#

V4Crowd keeps a flat TArray<FV4CrowdAgentRecord> and classifies each agent into Background / Medium / Hero by distance and relevance. EvaluateTierForAgent (V4CrowdSubsystem.cpp:111) promotes to Hero inside HeroRadius (600 cm) or above HeroRelevanceThreshold (0.85), to Medium inside MediumRadius (1800 cm) or above 0.35, else Background. The budget governor is the heart of it. Each tier has a per-agent cost in GetTierCostMilliseconds (:594) — Hero 0.035 ms, Medium 0.015 ms, Background 0.005 ms — so a 400-agent all-Background crowd estimates 2.0 ms, comfortably under the 8 ms MassTickBudgetMilliseconds. EnforcePerformanceBudget (:428) sorts by ascending relevance (then descending tier) and demotes the cheapest-to-lose agents one rung at a time until the estimate fits. The perf spec forces all 400 agents to Hero (14 ms, over budget), enforces, and asserts the estimate returns ≤ 8 ms while some Hero/Medium agents survive — fidelity is shed from the least-relevant edge inward, not uniformly.

Panic propagation and exfil routing#

A panic stimulus arrives as an FV4CrowdMassSignal. HandlePanicMassSignal (:163) walks the crowd, and for each agent inside the radius adds panic scaled by a linear distance falloff clamped to [0.25, 1.0] (:178); crossing 0.65 promotes a Background agent to Medium so a panicking bystander cannot be a frozen low-LOD prop. It then calls AssignFleeTargetsForPanic (:338), a real capacity-aware router: each fleeing agent picks the nearest exfil node with remaining capacity, falling back to the nearest node overall if all are full, and arrival time is distance / FleeSpeedCentimetersPerSecond (400 cm/s = the monolith's 4 m/s). AdvanceFleeingAgents (:258) integrates motion toward the target each tick and snaps-and-clears on arrival. The panic spec fires a signal that reaches all 400 generated agents in under 8 ms, confirms the first agent gets a flee target and a positive arrival estimate, advances a tick and checks the agent moved closer, and — the capacity proof — sets a near node to capacity 1 and watches the second agent spill to the farther node.

Disguise line-of-sight checks#

The Hitman "blend in until someone who knows better sees you" rule is HandleDisguiseLineOfSightMassSignal (:195). On a line-of-sight signal it runs RecognizeObservedFaction (:646), which returns Disguised if the observer accepts the presented disguise id, Friendly for own/allied factions, Hostile for a listed enemy, else Neutral. A hostile recognition compromises the disguise, bumps the observer's panic, and promotes it off Background. The spec exercises both edges: a guard whose hostile list contains Intruder flags the disguise as compromised and is promoted, but the same guard presented an accepted KitchenStaff disguise returns zero hostiles — the disguise suppresses the response.

Streaming demotion and the Mass integration status#

This is where the crowd rejoins the streamer. DemoteAgentsOutsideLoadedCells (:469) takes the streamer's LoadedCells and a cell size, recomputes each agent's streaming cell with the shared formula, marks anyone outside the ring bOutsideStreamingRing, and demotes them one tier (Hero→Medium, Medium→ Background) while tallying the transitions. The perf spec builds a 3×3 loaded ring and three agents — one inside, one Hero far east, one Medium far west — and asserts exactly two are out-of-ring, the Hero demotes to Medium, the Medium to Background, and the inside agent is untouched. One honest note on the Mass framing: V4Crowd.Build.cs really does link MassEntity, MassNavigation, MassAIBehavior, MassMovement, MassReplication, and MassSignals, and GetMassIntegrationStatus (:539) reports each flag from a live FModuleManager::IsModuleLoaded query rather than a constant struct — so a dropped dependency would honestly flip a flag false. But the dense simulation itself is the TArray loop above; each record stamps an FMassEntityHandle (MakeGeneratedAgent, :631) as a forward-looking handle, and the subsystem does not yet run Mass processors over an FMassEntityManager. It is Mass-linked and Mass-aware, not Mass-driven.

How a sandbox tick fits together#

The intended flow — assembled from the real functions above, even though no single in-tree loop wires all three subsystems together yet — looks like this:

flowchart TD Seed[Daily/run seed] --> Procgen subgraph Procgen [V4Procgen] Solve[SolveChunkConstraints<br/>FRandomStream seed+attempt*7919] --> Play{ValidatePlayability<br/>BFS spawn-&gt;objectives-&gt;exfil} Play -- unreachable --> Solve Play -- ok --> Npc[ValidateNpcPlacement<br/>count NPC-eligible cells] end Npc --> Layout[FV4ProcgenMissionLayout<br/>grid + roles + bot path] Player[Player location] --> LevelOps subgraph LevelOps [V4LevelOps] Cell[GetWorldPartitionCellForLocation<br/>floor of loc / cellSize] --> Ring[V4BuildCellRing -> LoadedCells] Ring --> Hlod[V4BuildDistantHlodCells -> HLOD donut] Hlod --> Budget[EnforceHlodBudget -> prune to MB ceiling] end Ring -- LoadedCells, cellSize --> Crowd subgraph Crowd [V4Crowd] Demote[DemoteAgentsOutsideLoadedCells<br/>shared cell math] --> Tier[EnforcePerformanceBudget<br/>relevance-sorted to 8 ms] Panic[HandlePanicMassSignal] --> Flee[AssignFleeTargetsForPanic<br/>capacity-aware nearest exfil] end Layout -.feeds NPC spawns.-> Crowd