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 theV4LevelOpslibrary 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 theV4LevelOpsstreaming spec — that assert computed values, not just truthiness. - Modeled as data, not baked art. Consistent with V4's tree-wide
0-binary-
.uassetaccounting (see ./high-level-architecture.md), every asset these systems name is a soft reference, not a cooked binary. A cell'sPCGGraphis aTSoftObjectPtr<UPCGGraphInterface>pointing at/Game/V4Procgen/PCG/PCG_Tactical; a chunk'sChunkAssetis anFSoftObjectPathat/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 execution —V4Procgenlinks thePCGmodule and stores the soft graph pointer as authoring metadata, butGenerateMissionLayoutnever loads or evaluates aUPCGGraph. - Library-grade, not yet orchestrated. Each entry point is exercised by its
automation spec and by nothing else in the tree.
GenerateMissionLayout/DispatchDailySeedhave no production caller (the "server broadcasts a seed at 00:00 UTC" loop is described in the monolith, not a running service here);BuildStreamingStateandDemoteAgentsOutsideLoadedCellslikewise 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'sLoadedCellsinto 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:
Related#
- ./game-modes-live-service.md — the modes that request a daily seed, host a streamed sandbox, and own the leaderboard the seed feeds
- ./per-cell-deep-dives.md — the genre-core modules whose crowds, schedules, and disguises this page streams and populates
- ./presentation-pipelines.md — the HLOD layers, ambience, and crowd-density art these systems reference as soft assets
- ./high-level-architecture.md — the module
split and the 0-binary-
.uassetcontent model - ../V4_ARCHITECTURE.md — the orientation hub