Skip to content

Latest commit

ย 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

Data-Oriented Building โ€” An ECS-Style Building System

ไธ€ๅฅ—่ƒฝๆ‰ฟ่ฝฝๆตท้‡ใ€ๆฐธไน…ๅปบ็ญ‘็š„ๅปบ้€ ็ณป็ปŸๆŠ€ๆœฏๅ‚็…งๅฎž็Žฐ๏ผš็”จๅฎžไฝ“ + ็‰‡ๆฎต๏ผˆECS / Mass ้ฃŽๆ ผ๏ผ‰ๅ–ไปฃ"ๆฏไธชๅปบ็ญ‘ไธ€ไธช Actor"๏ผŒ้…ๅˆๅฎžไพ‹ๅŒ–ๆธฒๆŸ“ใ€ๅขž้‡ๅŒๆญฅใ€ไปฅๅŠๆŒ‰้œ€็š„ Actor ๅฏน่ฑกๆฑ ใ€‚

A reference implementation of an open-world building system built to hold a very large number of persistent objects. Building pieces are lightweight entities composed of fragments (an ECS/Mass-style model) rather than one Actor each โ€” rendered instanced, replicated through a throttled delta channel, and promoted to a real Actor only on demand from a shared pool.

Created Engine Paradigm Type


๐Ÿ“Œ Context

This distills a system I designed and owned on Light of Motiram (Tencent, Unreal Engine ยท C++), where players build homes freely and buildings persist permanently โ€” a single base can hold thousands of pieces. I was responsible for the whole framework: persistence, synchronization, entity/proxy management, and the modular workflow other engineers and designers extended.

Related systems I owned on the same title: players assemble watercraft out of these very building pieces โ€” see watercraft-physics โ€” and production buildings are worked by creatures via automation-ai-productionline.

This repository is a clean-room reference. Original code written for portfolio purposes, reduced to the load-bearing architecture. It contains no proprietary or third-party source; engine-facing types are illustrative stand-ins.


๐ŸŽฏ The problem, precisely

One AActor per building piece blows up on three axes at once:

Axis Failure at scale
CPU / memory Thousands of actors, components, and tick registrations.
Network Thousands of replicated actors saturating the channel.
Rendering Thousands of draw calls.

And a fourth, softer constraint that shaped the architecture as much as performance: the core building rules were redesigned several times during development, so the system had to be reshapeable without rewrites.


๐Ÿงญ Architecture โ€” entities, not actors

flowchart LR
    DB[("Persistence (DB)<br/>version #s")] <-->|dump / load| Store["<b>Entity Store</b><br/>(fragments)"]
    Store <-->|delta channel| Clients(["Clients"])
    Store --> Rep["<b>Representation</b><br/>Instanced Static Mesh<br/>thousands โ†’ a few batches"]
    Store --> Pool["<b>Proxy Pool</b><br/>shared Actors ยท on demand<br/>only near / interacted pieces<br/>promoted to a real Actor"]

    classDef store fill:#1f6feb22,stroke:#1f6feb,stroke-width:1px;
    classDef db fill:#6f42c122,stroke:#6f42c1,stroke-width:1px;
    class Store store;
    class DB db;
Loading

The central idea: the number of live Actors is decoupled from the number of buildings. Most pieces are pure data plus an instanced mesh; only the handful a player is near become actors. Everything else in the design follows from that decision.

  • Entity = a set of fragments โ€” a piece is data (transform, health, type, support, coatingโ€ฆ), no actor or components until it needs one.
  • Representation draws pieces as Instanced Static Meshes โ€” thousands of identical walls collapse into a few draw batches.
  • Proxy pool lends a capped number of shared Actor proxies for collision/interaction/UI.
  • Persistence dumps/loads entities with version numbers for incremental saving.

๐ŸŽฎ Gameplay: pieces, components, and the build flow

The data-oriented core above is the storage / performance layer. The gameplay a player actually touches lives in the actor-side build pieces and the build flow that places them.

Build pieces = a base actor + pluggable components

A building object isn't a monolithic class โ€” it's an ABuildPiece composed of only the components it needs. A wall needs snapping + support; a door adds interaction; a furnace adds production. Concrete objects subclass the base and initialize their own distinctive data.

Component Responsibility
BuildSnapComponent Snap points โ€” what this piece can snap to, and what can snap to it
CombinableSlotComponent Slots that accept / reject specific pieces (a wall slot only takes a door or window)
BuildInteractComponent Proximity region that surfaces context actions (open door, deposit, light furnace)
// A concrete object is a small subclass that just wires its own data + interaction.
class ADoorPiece : public ABuildPiece
{
    virtual void InitDistinctiveData() override { /* door-specific state */ }
    virtual void OnInteract(APlayerCharacter* Player, int32 InteractionId) override
    {
        ToggleOpen();   // open / close โ€” the door's whole gameplay is one override
    }
};

See src/BuildPiece.h.

The build flow: hold โ†’ snap โ†’ validate โ†’ place

While the player "holds" a piece in preview, each frame the flow raycasts from the camera, gathers snap candidates (an active snap point on the held piece meeting a passive snap point on a nearby piece), ranks them by priority, snaps to the best, and validates placement (overlap / support / build-count limit / area) before allowing a commit โ€” the preview turns green or red on that result.

// Rank snap candidates and take the best; lower Priority value wins.
bool UBuildHoldFlow::ResolveBestSnap(const TArray<FSnapCandidate>& Candidates, FTransform& OutPose) const
{
    const FSnapCandidate* Best = nullptr;
    for (const FSnapCandidate& C : Candidates)
        if (!Best || C.Priority < Best->Priority) Best = &C;

    if (!Best) return false;
    OutPose = ComputeSnappedPose(*Best);   // align the active point onto the passive point
    return true;
}

// Placement is only legal if it passes every rule.
EPlaceRejectReason UBuildHoldFlow::ValidatePlacement(const FTransform& Pose) const
{
    if (IsOverlapping(Pose))     return EPlaceRejectReason::Overlapping;
    if (!HasEnoughSupport(Pose)) return EPlaceRejectReason::NoSupport;     // no floating pieces
    if (ReachedBuildLimit())     return EPlaceRejectReason::ReachBuildLimit;
    if (!InBuildableArea(Pose))  return EPlaceRejectReason::OutOfArea;
    return EPlaceRejectReason::None;
}

Validation order is deliberate: cheap rules first (build-count, area), expensive ones last (overlap sweep, trial support calculation) โ€” because this runs every frame while holding. The same flow, switched by mode, also drives select / move / demolish / repair / paint. See src/BuildHoldFlow.h.

Structural support: nothing floats

Support isn't a per-piece flag โ€” it's a field that propagates. Pieces touching terrain (or a support-supplying object like a totem) are sources and receive full support. Everyone else inherits from the neighbours they touch, decayed by distance and direction:

// ไผ ๆ’ญๆŸ่€— / Propagation loss: lerp between horizontal and vertical loss by direction,
// then scale by centre distance โ€” the further from support, the less you inherit.
float PropagateSupport(float NeighbourSupport, const FSupportParams& P,
                       float DirectionT, float CentreDistance)
{
    const float Loss = FMath::Lerp(P.HorizontalLoss, P.VerticalLoss, DirectionT);
    return NeighbourSupport - Loss * CentreDistance * NeighbourSupport;
}

Gameplay consequences that fall out of this, with no special-case code:

  • Cantilevers sag. Horizontal loss is tuned higher, so building outward runs out of support faster than building upward โ€” you need pillars to reach far.
  • Rubble can't prop up rubble. A neighbour below its own minimum contributes nothing.
  • Multi-point support is sturdier. Two or more supports underneath grant a bonus.
  • Below minimum โ†’ collapse, which dirties the neighbours and can cascade.

Two engineering constraints shaped the implementation: it's a global convergence problem (A supports B supports C, so one edit ripples), solved by iterative relaxation until the field is stable; and it must be amortized across frames, since recomputing thousands of pieces in one tick would stall the game thread. See src/BuildSupportSystem.h.

Production entities: fuel, workload, products

Furnaces, campfires, kilns and mills share one model โ€” three queues (fuel / inputs / products). The notable decision: progress is tracked as workload, not remaining seconds.

// ๅทฅไฝœ้‡่€Œ้žๅ‰ฉไฝ™็ง’ๆ•ฐ / Workload, not remaining time โ€” because throughput is modified by
// entity level, creature helpers, buffs and weather. Each tick simply subtracts at the
// CURRENT rate, so mid-run speed changes and offline settlement need no retro-conversion.
UPROPERTY(Replicated) TArray<int32>  InputWorkloads;   // remaining workload per input
UPROPERTY(Replicated) double         FirstFuelTime;    // burn time left on the head fuel

A workbench is simply a queue-work entity with capacity 1 โ€” which is what let benches, cooking stations and furnaces share one UI and one code path. Some entities need no fuel at all (a windmill runs on wind, a mill on creature labour); that's config, not a new class. See src/ProductionEntity.h.


1. Fragment composition + replicated / transient split

Decision. Keep the replicated agent as small as possible; push everything that doesn't need to sync into a transient side-struct held by pointer, off the hot array.

Why. The replicated array is walked every frame for representation and replication. Cold data (asset handles, the bound proxy, selection state) has no business on that hot path โ€” it inflates per-object memory and hurts cache locality on the traversal that runs at scale. Separating the two keeps the hot array tight and fast, and keeps agent copies inside the fast-array cheap.

// BuildingAgent.h โ€” replicated agent holds ONLY what must cross the wire; cold state lives
// behind a TSharedPtr, off the hot array, so copying agents never drags it along.
USTRUCT()
struct FBuildingAgent
{
    GENERATED_BODY()

    UPROPERTY() FTransformFragment Transform;   //  \
    UPROPERTY() FHealthFragment    Health;      //   } replicated fragments โ€” compact & cache-friendly
    UPROPERTY() FTypeFragment      Type;        //   /
    UPROPERTY() FSupportFragment   Support;     //  /

    TSharedPtr<FBuildingTransient> Transient;   // NOT replicated: asset handle, bound proxy, selection...
};

See src/BuildingAgent.h.


2. Delta replication with throttling + weak-net adaptation

Decision. Replicate through a fast-array delta channel with a per-frame change/delete budget that shrinks under packet loss.

Why. Even as data, thousands of pieces cannot all sync in one tick โ€” and a naive burst makes congestion worse exactly when the network is already struggling. A per-frame budget spreads the cost; making that budget network-adaptive turns a hard failure (channel collapse under loss) into graceful degradation (slower, but stable).

// BuildingClientBubble.h โ€” good network pushes a healthy batch; detected loss throttles hard.
int32 UBuildingClientBubble::GetChangesBudgetThisFrame() const
{
    return bBadNetwork ? Val_MaxChangesPerUpdate_BadNet   // e.g. 5
                       : Val_MaxChangesPerUpdate;         // e.g. 50
}

void UBuildingClientBubble::ServerTick()
{
    int32 Budget = GetChangesBudgetThisFrame();
    for (uint32 Entity : DirtyEntities)          // dirty set, priority-ordered (distance/visibility)
    {
        if (Budget-- <= 0) break;                // remainder waits for next tick
        MarkAgentDirtyForReplication(Entity);
    }
}

See src/BuildingClientBubble.h.


3. On-demand actors from a shared proxy pool

Decision. Lend shared proxy actors from a capped pool, bound to an entity only while the player is near, and reclaimed on exit.

Why. Interaction, collision, and UI genuinely need an Actor โ€” but only for the pieces a player can actually touch, which is a tiny, bounded set at any moment. A pool with a hard Val_MaxProxies cap guarantees the live-actor count stays fixed regardless of how many thousands of buildings exist. That cap is the guarantee that makes the whole "entities, not actors" claim hold under adversarial player behaviour (e.g. someone building 10,000 walls).

// ProxyPool.h โ€” a LockCount lets several systems (collision/interaction/UI) share one proxy;
// the actor returns to the pool (not destroyed) when the last lock releases.
FProxyHandle UProxyPool::Acquire(FEntityHandle Entity, FName Reason);   // near range -> bind
void         UProxyPool::Release(FProxyHandle Handle, FName Reason);    // out of range -> reclaim

See src/ProxyPool.h.


4. Modularity as a workflow (why the team could scale on it)

Building behaviours are composable at three levels, so a new object is assembled, not coded:

Level Unit Example
Data Fragment support value, coating, produce state
Behaviour Component snapping, interaction, level-up, production
Object Entity type a door / furnace / incubator = a specific combination

This is the open/closed principle expressed as a pipeline: adding a new building object touches no framework code. That is what let other engineers add features โ€” and designers author a large variety of objects โ€” efficiently and in parallel, throughout the project.


๐Ÿ—‚๏ธ Repository layout

data-oriented-building/
โ”œโ”€โ”€ README.md
โ””โ”€โ”€ src/
    โ”œโ”€โ”€ BuildPiece.h             Gameplay: base piece + pluggable functional components
    โ”œโ”€โ”€ BuildHoldFlow.h          Gameplay: hold โ†’ snap โ†’ validate โ†’ place build flow
    โ”œโ”€โ”€ BuildSupportSystem.h     Gameplay: support propagation, collapse, iterative relaxation
    โ”œโ”€โ”€ ProductionEntity.h       Gameplay: fuel / workload / product queues, queue-work benches
    โ”œโ”€โ”€ BuildingAgent.h          Core: fragment composition + replicated/transient split
    โ”œโ”€โ”€ BuildingClientBubble.h   Core: delta replication โ€” per-frame budget + weak-net throttle
    โ””โ”€โ”€ ProxyPool.h              Core: on-demand shared-Actor pool with a hard cap

A reduced reference: the load-bearing data model, replication throttle, and pooling, with the engine's fast-array/ISM/subsystem plumbing abstracted so the design reads clearly.


๐Ÿ’ก What this demonstrates

Data-oriented / ECS thinking applied to a concrete scaling problem; fluency across replication, instanced rendering, memory layout, and pooling; and โ€” the part I care about most โ€” an architecture designed for constant change and for other people to extend, not just to perform.

๐Ÿ“œ Notes

Original reference code authored by me for portfolio purposes. No proprietary or third-party source is included; engine-facing types (fragments, client bubble, proxy actors) are illustrative stand-ins for the real integration points.

About

ECS-style building system for massive persistent worlds (UE C++): entities and fragments over per-actor, instanced rendering, throttled delta replication, on-demand actor pool.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages