← cosmin bararu

The world is not a UObject_

Before the rewrite, Towers of Aghasba already supported more than 100,000 interactables. The goal was to reduce the cost of that existing world, then test the same record model at one million entries.

A world object can look simple on screen and still need persistent identity and state after its visual representation streams out. It may be an Actor now, an instanced mesh later, and nothing visual at all when that part of the world streams out. Some of its simulation may still need to continue while the player is somewhere else entirely.

The cost appeared in memory, garbage collection, streaming bookkeeping, saves, and replication.

We shipped across Windows, Steam Deck, and PS5. Their constraints differ, but all three have finite memory and frame time. At this scale, a few dozen bytes multiplied by the persistent population stops being a rounding error.

A lighter Actor would not solve this. The persistent fact itself had to stop being an object by default.

The original brain-and-proxy design

When I first designed the entity system, the goal was simple: the logic for something in the world should not disappear just because its mesh was streamed out.

World Partition needs to be free to unload an Actor, component, or entire visual proxy as the player moves. A building, plant, decoration, or world object can still have state that matters. So I split it into two lifetimes: a lightweight persistent UObject brain, and a disposable presentation proxy.

The proxy could become an Actor, convert into foliage or a hierarchical instanced static mesh (HISM), collapse into hierarchical level of detail (HLOD), or vanish. The brain carried identity and game logic. It could continue persistent simulation without requiring the visual side to remain loaded.

I did not build it as an ECS. Most gameplay code wanted one stable object it could ask questions, and the visual side wanted a handle it could replace as streaming changed. That made ordinary interactions direct, kept Blueprint extension points familiar, and let HLOD or instancing reduce visual memory without erasing the underlying thing.

Diagram of the original persistent UObject brain connected through World Partition to disposable Actor, HISM or foliage, and HLOD representations.
the original split was successful: presentation could leave while the world state remained. scroll horizontally to inspect the full diagram.

This architecture supported more than 100,000 interactables without tying all persistent state to rendered Actors. Over time, the persistent brain became the default home for far more state than I originally intended.

The brain became the default owner

The brain gradually became the owner of nearly every piece of persistent gameplay state.

Most additions solved a real production problem. Together they created separately allocated UObjects, GUID-keyed maps, weak pointers, full transforms, representation-heavy handles, and a larger object graph for garbage collection.

The connecting handle could carry a persistent GUID, a weak brain pointer, configuration and visual UObjects, a foliage handle, a full transform, sparse-grid ownership, an asset-load request, a streaming-cell identifier, and callbacks. It had become a small object model of its own.

The garbage collector was doing exactly what we asked it to do. We were asking it to understand too much of the world.

We had already reduced some costs. A proxy converted to foliage could unregister the brain, and dense decoration could use HISM instead of individual Actors. The common ownership model was still heavier than the common entity needed.

The bytes inside one class were only part of the pressure because separate allocations scatter state across cache lines. Maps add capacity and indirection, while weak references and delegates add bookkeeping. Then GC must discover and walk the object graph even when the gameplay question was only, "does record 8143 still exist, and what cell owns it?"

Three kinds of relevance

I had been looking at Actor versus HISM. The more useful split was simulation relevance, network relevance, and visual relevance.

Simulation relevance answers whether persistent state must advance. Growth, construction progress, production, cooldowns, ownership, and scheduled behavior can matter with no player nearby. Network relevance answers which current results a connection needs. Visual relevance answers whether the client needs an Actor, collision, UI, HISM mutation, HLOD, or another materialized view.

Those sets overlap, but they are not nested by engine rule. A distant factory may need server simulation and no client representation. A static landmark may need an HLOD and no active simulation. A nearby decoration may need one HISM instance but no Actor. A selected building may temporarily need all three: authoritative simulation, replication to the owning connection, and a rich interactive proxy.

Nested diagram separating globally persistent simulation records from connection-relevant network cells and the smaller visually materialized subset. The production comparison uses 100,000 interactables, and the record benchmark also completed at one million entries.
one persistent identity can be simulation-relevant, network-relevant, and visually relevant on three different schedules. scroll horizontally to inspect the full diagram.

Neither 100,000 nor one million means that many ticking Actors. It means the world and the player's save can contain that many things with identity and state. The relevant subset can become rich. The rest should stay compact and boring.

This also keeps the common single-player case honest. Separating relevance does not require a job system around every click. A single interaction can resolve one record directly. The machinery becomes valuable when streaming, saving, networking, or simulation produces a real range of work.

The record owns the world

The invariant changed to this:

A persistent thing in the world is a record. An Actor, HISM instance, foliage instance, HLOD, UObject brain, or Mass entity is one possible representation of that record.

One identity through every transition

Take stone wall record 82,417. It begins with no visual representation and advances its persistent construction state. When its cell becomes relevant, the client reconstructs the record and materializes it as HISM. Interaction promotes the same record to an Actor. A placement change updates its canonical state, after which it can stream out and return in the next session without changing identity.

Record reconstruction through the first visible HISM binding used 142 microseconds of CPU time in the measured lifecycle. Promotion, placement, stream-out, and save work happen at different times and are measured separately. Network latency is excluded because it depends on the deployment.

Worked trace showing stone wall record 82,417 retaining one persistent identity while moving from no representation to HISM, Actor, and back to none across replication, interaction, stream-out, saving, and reload.
stone wall record 82,417 keeps one persistent identity while its sparse representation binding changes. scroll horizontally to inspect the full trace.

The hot record uses a generational runtime ID and a transform quantized relative to its world cell. Shared dictionary indices replace asset paths, common flags use dense columns, and typed fragment pools hold specialized state. Compact relation edges carry ownership, placement connections, and parent-child links.

Persistent GUIDs still exist because saves and migration need durable identity, but they can live in cold storage. Representation bindings are sparse because an invisible record should not pay for an Actor pointer, foliage handle, HLOD handle, and Mass handle simultaneously.

I like that the common record is boring. Boring data is easy to copy, sort, validate, page, and reconstruct. The interesting behavior can sit in processors and explicit fragments where it is visible.

Diagram placing the canonical world record, typed fragments, relations, and sparse representation bindings between World Partition, GAS, saves, spawners, persistent processors, replication, UI, Actors, HISM, HLOD, and optional Mass.
the record layer owns persistent identity and state. It integrates with Unreal's systems instead of replacing them. scroll horizontally to inspect the full diagram.

GAS still owns activation, costs, tags, prediction, cancellation, and player-facing failure. World Partition still owns presentation lifetime. RepGraph still decides connection relevance. Actors and components still own interactive materialization. Mass remains available where homogeneous processors actually fit.

GAS still owns actions

The record layer should not become a second gameplay-authority framework. Placement is a useful example. GAS activates the ability, checks player state, applies costs, predicts, cancels, and reports failure. The server transaction then resolves stable record and port IDs, verifies the target revision and occupancy, reserves the port, recomputes the transform, validates collision, creates the record, and commits both endpoints.

The authority path commits collision, resources, port state, the new record, and both endpoints as one transaction. A failed collision check leaves resources and occupancy unchanged. Concurrent claims pass through compare-and-reserve, so only one can commit. If spawning fails, both endpoint changes roll back. The record store owns the persistent result, but it does not steal the action from GAS.

Spawners keep their domain logic

Towers has tens of thousands of spawners with existing game-specific behavior. Rewriting them during the entity migration would combine two risky changes and complicate saves.

The smaller integration is better: spawners create and resolve stable record IDs instead of requiring a brain pointer or a GUID-heavy universal handle. Their managers keep their behavior. The population they own can still use compact storage, cell partitioning, sparse representation, and batch reconstruction.

Blueprint, UI, and Mass stay at the edges

UI and quest code do not need direct pointers into fragment storage. They request immutable snapshots and focused queries. Blueprint remains a supported extension surface without determining the storage layout of every dormant object.

Mass is similar. It is useful when a population needs the same active processing, but it does not have to become the persistence identity or save format. A record can project into Mass for a suitable workload, materialize as an Actor for interaction, fall back to HISM, or have no representation at all without becoming a different thing.

Some entities use an explicit UObject-backed behavior path. Some Towers entities have deeply scripted behavior, and forcing those into a universal struct abstraction would only move complexity somewhere less honest. The difference is that a UObject brain becomes an explicit, measured cost instead of the default cost paid by every entity.

Large populations are batch workloads

Towers is often played single-player, but that does not make its workloads small. A large World Partition stream-in can decode and reconcile HISM or foliage instances across many cells. We can replace millions of grass blades before presentation is allowed to appear. Save restoration and join in progress reconstruct large record sets. Tens of thousands of spawners contribute ownership and decisions.

Stone wall record 82,417 is plain data while workers reconstruct its cell and becomes an Unreal representation only during the game-thread commit. Immutable source ranges can be divided deterministically. Each worker produces a thread-local batch with no shared per-record queue and no atomic counter bouncing between cores for every blade or record. A worker spills a whole batch. One merge stage orders those batches. A bounded game-thread commit performs the side effects Unreal requires.

The thread-local detail matters. Even a lock-free shared queue can turn one cache line into a meeting point for every producer. When the unit of work is tiny and the population is huge, the coherence traffic becomes the work. Local arrays let each core write to memory it owns. Publication happens at batch granularity, where one synchronization event represents hundreds or thousands of prepared operations.

Pipeline from World Partition, HISM and foliage, grass replacement, save or join-in-progress restoration, and spawners through immutable ranges and worker-local batches into a bounded game-thread commit.
parallelism prepares complete batches. One controlled boundary mutates UObjects, components, collision, physics, and HISM state. scroll horizontally to inspect the full diagram.

Ordinary single-player interactions remain direct. Batching is reserved for ranges large enough to amortize scheduling, merge, and publication while keeping the game-thread budget predictable.

Deterministic chunking also makes recovery easier. The same input snapshot can produce the same ordered command stream whether it came from a save, join in progress, or a streamed cell. The worker phase stays free of UObjects. Component registration, collision, physics, HISM mutation, and actor creation remain on the game thread because parallel preparation does not change Unreal's ownership rules.

Replication follows cells

A server may simulate persistent state globally. That does not mean every connection should receive the whole world.

Records are grouped by loose world cell and replication policy. When record 82,417's cell becomes relevant, the Replication Graph selects that cell for the connection. Compact pages carry record changes, relation edges, registered fragment data, revisions, and schema fingerprints. Routine payloads use runtime IDs, config indices, and quantized transforms rather than persistent GUIDs and asset paths.

A fixed cell key provides the coarse location, and signed cell-relative coordinates provide the fine location in a compact, deterministic form. The common record stores quantized yaw. Pitch, roll, and scale appear only in configurations that need them, so their extra bytes are visible in those payloads.

Server world divided into loose cells, with RepGraph choosing different cell sets for two clients and compact pages reconstructed on workers before bounded game-thread commits.
world population can grow by adding cells without making every connection receive every record. scroll horizontally to inspect the full diagram.

Joining clients reconstruct copied page data off-thread and publish completed batches to the game thread. Revisions reject stale state. A snapshot fingerprint catches dictionary or schema disagreement before arbitrary bytes are decoded as the wrong data.

Each connection pays for its relevant working set, not the total persistent population. Position, policy, interaction, and ownership determine that set.

It also means join in progress is reconstruction, not Actor spawning at world scale. A client validates a bounded page, rebuilds plain records and edges on workers, then materializes only the representation needed by its current cells. A later cell revision changes that slice without changing persistent identity.

The save cannot become empty

The easiest entity rewrite starts from a new game. That was not acceptable.

The migration treats the old save as immutable. It writes a new generation beside it, then reopens and validates the result before switching authority.

If structurally valid data cannot materialize because a config was renamed or removed, it is quarantined with its identity, transform, payload, and reason. Unknown optional fragments are preserved. Unknown required state fails closed.

Fail-closed save migration from immutable legacy bytes through decode, record reconstruction, semantic validation, new-generation write, reopen verification, and promotion, with quarantine and safe failure routes.
migration earns authority only after a new generation survives semantic validation and a real reopen. scroll horizontally to inspect the full diagram.

A decoder failure, missing asset, interrupted write, or validation mismatch never authorizes an empty world to overwrite the player's history. Failure returns to the supported legacy writer or a read-only recovery state. That rule is slower to build and much easier to trust.

Occupancy and connection state follow the same principle. Saves store sorted record snapshots and stable edges. Derived bitsets can be reconstructed and checked from those edges during load instead of duplicating authority in two formats that may disagree. Corrupt or conflicting edges are quarantined, and they never authorize an empty world.

At 100,000 records

I use 100,000 as the matched comparison point because both architectures were measured at that population.

The controlled core benchmark held 100,000 spatial records across 228 populated nodes in 5.36 MiB.

The same controlled record benchmark also completed at 1,000,000 records. The tables retain 100,000 as the comparison baseline because that is the population shared with the legacy production comparison.

We also exercised the system with more than 500,000 interactables on PS5. It remained stable, and we observed no garbage-collection spikes during the test.

5.36 MiB100,000 core spatial records
56.18 Ballocated bytes per core record
0record-layer UObject delta

The batch timings measure total throughput for bulk add and release operations.

100,000-record operationMedianP95
Add62.358 ms81.073 ms
Release79.847 ms100.415 ms
Full GC while records are retained7.149 ms8.456 ms

The production comparison uses the same save, population mix, relevant cells, dirty-record rate, build, hardware, memory boundary, and sampling point. Unless a row says p95, timings are medians across five cold starts. The streamed-cell case reconstructs 18,400 relevant records across 42 cells. Memory is sampled 60 seconds after load, after transient staging has been flushed, and excludes shared meshes, materials, and textures.

Memory and UObject population show the ownership change directly. Creating 100,000 core records measures bulk registry construction. Streamed-cell reconstruction measures worker-side rebuild before publication. Game-thread commit p95 measures one bounded slice of Unreal-side mutation, not the entire population in one frame. Full-GC p95 measures the same loaded world at the same sampling point.

Matched production comparison of the same 100,000-entity world on legacy and record-first paths across memory, UObject population, record creation, streamed-cell reconstruction, game-thread commit time, and garbage collection.
the matched production comparison reduces resident entity-state memory by 70.3%, entity-attributable UObjects by 92.0%, and the four emphasized runtime costs by 71.0% to 74.7%. scroll horizontally to inspect the full comparison.

Persistent identity, temporary representations

The original architecture tried to protect persistent game logic from the lifetime of visual Actors. The record-first architecture returns to that idea and applies it to ownership itself. A world fact can survive a representation change, a streamed-out cell, a reconnect, a worker reconstruction, and a save migration without needing to be a UObject.

The rewrite did not remove Unreal's higher-level systems. It made them temporary views or specialists around the record.

The world already supported more than 100,000 interactables. The record-first design made that core smaller, and the same benchmark completed at one million entries without adding those records to Unreal's garbage-collected object graph.