Remnant Item System¶
Summary: Remnant Items are narrative-tied uniques rolled inside sealed Remnant Realm sub-levels. Players interact with a world-placed portal → enter a streamed realm → complete a Hold-the-Ground event → receive a sealed item with hidden Echo Mods and a Desire progression task → equip the item and fulfil the Desire through play → the item auto-awakens, revealing and activating its Echo Mods. The system is server-authoritative, rides the existing item / equipment / modifier pipelines, and uses per-instance state modules on
UItemObjectfor the Sealed → Awakened lifecycle.
Table of Contents¶
- Architecture Overview
- Core Concepts
- The Remnant Loop
- Portal & Realm Entry
- Hold-the-Ground Event
- Item Rolling
- Desire Tracking & Awakening
- Echo Mod Reveal Gate
- Replication Strategy
- Public Contracts
- Source References
- Related Systems
- Recent Changes
Architecture Overview¶
┌────────────────────────────────────────────────────────────────┐
│ WORLD (persistent) │
│ │
│ ARemnantPortalActor ──── UInteractableComponent │
│ │ │
└─────────┼──────────────────────────────────────────────────────┘
│ OnInteracted (server)
▼
┌────────────────────────────────────────────────────────────────┐
│ URemnantRealmSubsystem │
│ • WorldSubsystem — lifetime scoped to UWorld │
│ • Caches entry transform per player │
│ • Builds FTransitionRequest (InWorldTeleport) │
└─────────┬──────────────────────────────────────────────────────┘
│ StartTransition
▼
┌────────────────────────────────────────────────────────────────┐
│ UTransitionStateManager │
│ • Multicast freeze + fade │
│ • OnRequestDynamicSublevelLoad → subsystem handler │
│ • Client-ready tracking for coop │
└─────────┬──────────────────────────────────────────────────────┘
│ ULevelStreamingDynamic::LoadLevelInstance
▼
┌────────────────────────────────────────────────────────────────┐
│ REALM SUB-LEVEL (streamed) │
│ │
│ ARemnantEventActor_HoldGround │
│ │ leash tick + progress timer │
│ └── ARemnantWaveSpawner ── AEnemySpawner[] │
│ │
│ On success → URemnantItemFactory::RollRemnantItem │
│ → ALootContainer dropped │
└─────────┬──────────────────────────────────────────────────────┘
│ pickup → inventory
▼
┌────────────────────────────────────────────────────────────────┐
│ PLAYER (persistent) │
│ │
│ UItemObject (sealed Remnant) │
│ ├── FRemnantFragment (marker, in manifest) │
│ └── FRemnantItemState (state module) │
│ State = Sealed │
│ EchoMods[] — hidden, inactive │
│ ActiveDesire — progress tracked │
│ │
│ URemnantTrackerComponent (on AEternalPlayer) │
│ Subscribes to kill events, ticks Desire progress │
│ On Desire complete → AwakenItem: │
│ State = Awakened │
│ Each EchoMod.bIsRevealed = true, bIsActive = true │
│ UEquipmentComponent refresh → Echo GEs spawn │
└────────────────────────────────────────────────────────────────┘
Key Design Principles¶
| Principle | Implementation |
|---|---|
| Server authority | All public subsystem + tracker methods require HasAuthority() |
| Reuse, don't fork | Items ride UItemObject, modifiers ride FRolledModifier (with Source=Echo), equipment apply loop runs the normal FEquipmentFragment path |
| Marker fragment, rich state module | FRemnantFragment has no fields — identifies the item. FRemnantItemState carries all mutable per-instance data |
| Transition-manager integrated | Realm entry uses the same freeze/fade/client-ready pipeline as hub/dungeon transitions via ELevelTransitionType::InWorldTeleport |
| No level unload | The realm is a sub-level streamed into the current world. Player doesn't leave the persistent world. |
| Realm brings its own light | Sharing the persistent world means sharing its single active lighting sublevel, so the realm's sky/sun/fog rides FTransitionRequest.LightingConfig — swapped behind the fade, host lighting cached and restored on exit or disconnect |
Core Concepts¶
Why Remnants Exist¶
The GDD positions Remnants as "physical manifestations of the world's repeating cycle" carrying "powerful, lore-driven properties that cannot be found on standard gear." They supply:
- A bounded challenge loop (Hold-the-Ground is the MVP variant, more event types deferred)
- A long-tail progression hook (Desire ticks while playing normally, not during the realm)
- A reveal moment (Sealed → Awakened pays off hours of play with an instant visible change)
- A PoE-adjacent mechanic layer that compounds with the planned Corruption system
The mechanic itself is theme-agnostic — one Era tag and one item pool were enough to validate the full loop before the narrative + art pass lands.
Portals are no longer debug placements: they now arrive procedurally, as Room.RemnantRealm rooms
injected into a dungeon's topology by chance (see
Dungeon System, with a per-world-map-node
chance override). The Forest domain ships the first full content set — RD_Forest_Remnant room data,
L_Room_Forest_Remnant room level, and the LS_Factory lighting sublevel the portal swaps in as the realm's
own environment. Spawn frequency tuning and era-color visuals remain deferred.
State Module Lifecycle¶
A Remnant lives in two states (ERemnantState):
| State | Echo Mods | Desire | Tooltip | Equipment Apply Loop |
|---|---|---|---|---|
Sealed |
bIsRevealed=false, bIsActive=false |
Progress ticks while equipped | ??? for Echo Mod lines; shows Desire progress X / Y |
Skips Echo Mods (gate via bIsActive); other mods apply normally |
Awakened |
bIsRevealed=true, bIsActive=true |
No longer relevant (MVP: one Desire per item) | Full Echo Mod text; Desire line removed | Includes Echo Mods (gate passes); full GE spawn |
Era Burdens (shipped alongside Echo Mods) follow the same reveal discipline: each rolled Remnant
carries BurdenSlotCount (MVP: 1) negative modifiers (FRemnantBurden, Source=EraBurden) picked from
the pool's BurdenModPool. Sealed = hidden/inactive like Echo Mods; Awakening reveals and activates them
together — the payoff moment surfaces the curse with the power. Burden pools are registered as auxiliary
pools (resolvable by ID, excluded from loot rolls), same as Echo pools. See RemnantEraBurdens.plan.md
for design rationale.
Transition is one-shot and one-way for MVP. Multi-rank Desire chains are deferred.
Fragment vs State Module¶
| Aspect | FRemnantFragment |
FRemnantItemState |
|---|---|---|
| Where it lives | UItemObject::ItemManifest (static template) |
UItemObject::StateModules (runtime instance) |
| Purpose | Marker — identifies the item as a Remnant | Carries mutable per-instance state |
| Fields | None — empty marker | State, EchoMods, Burdens, ActiveDesire, Era, PoolRef |
| Replication | Via manifest replication | Via ReplicatedUsing=OnRep_StateModules |
| Persistence | Rides with manifest (ItemID lookup) |
Serialized via wrapper entry in FItemSaveState.StateModules |
See Item State Modules for the general pattern and how future mechanics (Corruption, etc.) will layer on the same system.
Server Authority Boundary¶
| Operation | Server | Client |
|---|---|---|
| Portal interact → subsystem Enter | ✅ via UInteractableComponent server RPC |
❌ |
| Realm sub-level stream | ✅ | Clients see OnLevelShown via replication |
| Event progress tick | ✅ | Reads replicated ProgressSeconds |
| Wave triggers | ✅ | Wave state replicates |
| Reward roll | ✅ | Receives item via replicated container |
| Desire tick on kill | ✅ | Reads via OnRep_StateModules |
| Awaken transition | ✅ | Receives state change via OnRep_StateModules + tooltip VM re-snapshot |
The Remnant Loop¶
Interact with portal (server)
│
▼
Subsystem caches entry transform
│
▼
Build FTransitionRequest (InWorldTeleport)
│
▼
UTransitionStateManager freezes everyone + fades
│
▼
ULevelStreamingDynamic loads realm sub-level
│
▼
Player teleported to realm spawn point
│
▼
Interact with event actor (server)
│
▼
Progress timer ticks while in-radius + wave spawner fires
│
├── Player dies → ExitRealm(success=false)
│
└── Timer full → RollRemnantItem + drop loot container
→ Closing phase (player picks up)
→ ExitRealm(success=true)
│
▼
Subsystem teleports player to cached entry transform
│
▼
Sub-level unloads
│
▼
Player equips sealed Remnant
│
▼
URemnantTrackerComponent ticks Desire on every credited kill
│
▼
Desire satisfied → AwakenItem
│
├── State = Awakened
├── EchoMod[i].bIsRevealed = bIsActive = true
└── UEquipmentComponent refresh → Echo GEs spawn
Portal & Realm Entry¶
ARemnantPortalActor¶
World-placed actor carrying an UInteractableComponent. On interact, calls the subsystem with the player, the portal's actor transform (the return destination), and the RealmLevel soft reference.
| Property | Purpose |
|---|---|
RealmLevel |
TSoftObjectPtr<UWorld> — the sub-level streamed on enter |
RealmSpawnLocation |
World offset where the realm level instance is placed. Zero (default) resolves to the subsystem's reserved offset, outside any generated dungeon's extent — set it only to override for hand-built test levels |
RealmLighting |
FEnvironmentLightingConfig — the realm's own sky/sun/fog/post sublevel, swapped in behind the fade and restored on exit. Leave unset to keep the host's lighting |
One-shot — the actor destroys itself after first interact.
The portal carries no era; the realm's event actor owns it (see Era Provenance).
Realm-Owned Environment Lighting¶
The realm is a sub-level of the persistent world, not a separate map, so it cannot bring its own sky
along — the world has exactly one active lighting sublevel. RealmLighting therefore rides the transition
rather than the level:
EnterRealm
│
├── RealmLighting.IsValid()?
│ ├── PreRealmLighting = TSM->GetLightingConfig() (cache the host's)
│ └── Request.LightingConfig = RealmLighting
│
└── StartTransition → existing RequestLoadLighting multicast swaps it behind the fade
ExitRealm
└── Request.LightingConfig = PreRealmLighting → host lighting restored, cache cleared
HandlePlayerDestroyed (disconnect)
└── the disconnecting player never runs the exit transition, so the cleanup path
calls RequestLoadLighting(PreRealmLighting) directly
No new replication: the swap reuses the transition manager's existing lighting multicast, and the fade hides the sublevel churn.
URemnantRealmSubsystem¶
A UWorldSubsystem that orchestrates enter/exit. Owns only realm-specific state; delegates freeze/fade/client-sync to the transition manager.
EnterRealm(Player, EntryTransform, RealmLevel, SpawnLocation)
│
├── HasAuthority check
├── Cache entry transform in per-player map
├── Bind OnDestroyed for disconnect cleanup
├── Subscribe to TransitionStateManager (idempotent)
└── StartTransition(FTransitionRequest{ InWorldTeleport, RealmLevel, SpawnLocation })
HandleSublevelLoadRequest (TSM callback)
│
├── Forward direction → ULevelStreamingDynamic::LoadLevelInstance
│ → TSM->NotifyDynamicSublevelLoaded(level) before
│ signalling local streaming complete
└── Exit direction → immediately signal streaming complete (no load work)
ExitRealm(Player, bSuccess)
│
├── HasAuthority check
├── Flag bExitPending
├── StartTransition (return direction — uses cached transform)
└── HandleTransitionComplete unloads the sub-level + clears the cache entry
The subsystem does not own the transition UI, freeze multicast, or client-ready tracking — those live in UTransitionStateManager. See Transition Manager Integration (pending doc) for the shared pipeline.
Spawn Routing into the Streamed Realm¶
A realm run is an InWorldTeleport transition — the player never leaves the persistent world, so the landing PlayerStart must be resolved from the streamed realm sub-level, not the hub/fixed level the player came from. The pipeline tracks this explicitly:
RemnantRealmSubsystem loads sub-level
│
└── TSM->NotifyDynamicSublevelLoaded(level) ← TSM records the active transition's sublevel
│
PlayerSpawnManager (forward InWorldTeleport entry):
├── Context = EPlayerStartContext::InWorldTeleportPoint
├── Search TSM->GetActiveDynamicSublevel() EXCLUSIVELY
│ (no fallback to WorldMapSubsystem's current streamed/hub level)
└── No matching PlayerStart → log error (content authoring bug)
Why no hub fallback: falling back to the hub's streamed level silently teleported the player back to the hub start when entering a realm from a non-test boot path (e.g. L_Dungeon → Training Grounds → portal). The realm sub-level owns its landing PlayerStart; searching it exclusively makes a missing/mislabelled realm PlayerStart fail loudly instead of masquerading as a successful hub spawn. The exit direction uses the cached return transform and bypasses the PlayerStart pipeline entirely.
Hold-the-Ground Event¶
The MVP event type (GEN-01). One event actor per realm sub-level. Designer places + configures it alongside spawners.
Event Flow¶
Player interacts with ARemnantEventActor_HoldGround (server)
│
├── Multicast_HideInteractable — removes prompt from all clients
├── bActive = true (replicated)
├── WaveSpawner.BeginWaves()
└── Tick begins
Tick (server, 60Hz):
├── In-radius check via squared distance
│ ├── In → ProgressSeconds += DeltaSeconds (clamped to target)
│ └── Out → pause (no reset for MVP)
│
├── On ProgressSeconds >= ProgressTargetSeconds:
│ bActive = false
│ bIsClosing = true
│ SpawnRemnantReward (rolls via URemnantItemFactory + spawns ALootContainer)
│ Start ClosingSecondsRemaining countdown
│
└── On ActivePlayer death (OnDeath):
bActive = false
URemnantRealmSubsystem::ExitRealm(Player, success=false)
Closing phase tick:
ClosingSecondsRemaining -= DeltaSeconds
On zero → ExitRealm(Player, success=true)
Leash Behavior¶
Progress pauses on leash-break, doesn't reset. Cheapest signal to implement, tolerant of combat positioning. Reset-on-break is considered for later event types where the challenge warrants it.
Wave Spawner¶
ARemnantWaveSpawner holds an authored TArray<FRemnantWaveEntry> (TriggerAtSeconds + TArray<AEnemySpawner*>). Sorted at BeginPlay. Ticks elapsed seconds and fires each wave's spawners on schedule. Wraps the existing AEnemySpawner primitive — no generation coupling.
World-Space Progress Widget¶
Event actor owns a UWidgetComponent hosting URemnantEventProgressWidget. Drives:
- Fill proportion from
ProgressSeconds / ProgressTargetSeconds - Final stretch pulse when remaining time ≤
FinalStretchThresholdSeconds - Wave imminent flash when next wave triggers within
WaveImminentWindowSeconds - Closing countdown once reward drops
Widget state is fed by a client-side URemnantEventViewModel populated from the event actor's replicated properties.
Item Rolling¶
URemnantItemFactory::RollRemnantItem(Outer, Pool)¶
Static factory called server-side on event success. Pool is the realm's URemnantItemPoolDataAsset.
Pick base manifest from Pool.BaseItems (random)
│
Copy manifest, run standard UItemGenerationLibrary::GenerateItemProperties
(rolls prefix/suffix/implicit modifiers at item level — same as loot path)
│
Build FRemnantItemState:
State = Sealed
EchoMods: pick N unique defs from Pool.EchoModPool.ModifierDefinitions
(N = Pool.EchoModSlotCount)
Each wrapped in FEchoMod with Source=Echo, bIsRevealed=false
ActiveDesire: pick 1 from Pool.DesirePool, Progress=0
Era, PoolRef: stamped from the source pool
│
Item->AddStateModule<FRemnantItemState>(State)
│
Return UItemObject (caller drops it via ALootContainer)
Pool Data Asset¶
URemnantItemPoolDataAsset is a UPrimaryDataAsset registered under PrimaryAssetType "RemnantItemPool". Holds:
| Field | Purpose |
|---|---|
Era |
Gameplay tag for the era this pool represents |
BaseItems |
Soft references to UItemManifestDataAsset eligible for rolling |
EchoModPool |
Separate UModifierPoolDataAsset (kept out of loot rolls — see below) |
DesirePool |
Authored FDesire templates |
EchoModSlotCount |
How many Echo Mods each roll gets (MVP: 2) |
BurdenModPool |
Era-linked negative modifiers, registered as an auxiliary pool |
BurdenSlotCount |
How many Burdens each roll carries (MVP: 1) |
Era Registry¶
UEraRegistryDataAsset (single global asset, PrimaryAssetType "EraRegistry") maps each Era tag to its
reward pool and realm levels. Realm event actors author only their Era tag and resolve the pool through
the registry (ResolveItemPool); the explicit RewardPool override remains for hand-authored cases.
Save-time validation cross-checks that each entry's pool is labeled with the same era, so a portal/pool
era mismatch can't ship silently. A future Domain SourceEraTag (REC-2/REC-9) resolves through the
same rows.
Where Era Lives — the Era → Item Chain¶
Era is a property of the realm that dropped the item, never of the item's manifest. This trips up designers looking for an "era" field in the Item Manager, so state the chain plainly:
ARemnantEventActor_HoldGround.Era (authored per realm level, on the event actor inside it)
│ ResolveRewardPool(): explicit RewardPool override wins, else...
▼
UEraRegistryDataAsset::ResolveItemPool(Era)
│ FEraDefinition { EraTag, ItemPool, RealmLevels }
▼
URemnantItemPoolDataAsset { Era, BaseItems[], EchoModPool, BurdenModPool, DesirePool }
│ URemnantItemFactory::RollRemnantItem picks a base from BaseItems
▼
UItemObject + FRemnantItemState { State, EchoMods, Burdens, ActiveDesire, Era, PoolRef }
▲
Era stamped here, on the rolled INSTANCE
Consequences worth internalizing:
FRemnantFragmenton the manifest is a pure marker — no fields, and specifically no era. It only says "this base is eligible to become a Remnant".URemnantItemFactoryskips any pool base that lacks it.- Two realms of different eras can roll the same base manifest into items whose
FRemnantItemState.Eradiffers. The manifest cannot know which. - The portal carries no era. Its dead
Erafield was deleted in the world-validation pass; the realm's event actor owns it. - Answering "what era does this item belong to?" for a manifest is a reverse lookup: find every era
whose pool lists the manifest in
BaseItems. The Item Manager does exactly this and shows the result as a read-only Drops in era row on Remnant manifests (amber when no pool lists it — that base can never drop; amber when several do — the item's era depends on where it dropped).
Echo Mod Pool Registration¶
Echo Mods live in their own UModifierPoolDataAsset that is intentionally excluded from the global loot lookup. UModifierPoolManager keeps three parallel pool kinds:
| Pool kind | Contributes to FindModifierByID? |
Contributes to GetFilteredModifiers (loot rolls)? |
|---|---|---|
LoadedPools (standard loot) |
✅ | ✅ |
DungeonPools |
✅ | ✅ |
RemnantEchoPools |
✅ | ❌ |
This means Echo Mods are resolvable by ID everywhere — tooltip rendering, modifier cache rebind on load, GE application — but cannot leak into ordinary loot rolls. Registration happens in UModifierSubsystem::LoadAndRegisterRemnantEchoPools at subsystem init by scanning PrimaryAssetType "RemnantItemPool".
Live Echo Mods¶
The pool ships with six Echo Mods, each a GrantedAbility proc backed by an ability base class (see Ability Classes):
| Echo Mod | Mechanic | Ability base |
|---|---|---|
echo_pressure_release |
Hits build pressure; nova burst at cap | UPressureBuilderAbility |
echo_static_charge |
Every 4th hit → small Electric AoE | UPressureBuilderAbility (Event.StaticCharge.Released) |
echo_hemorrhage_cascade |
Bleed stacks; burst all at 5 as Physical | UStatusCascadeAbility |
echo_ember_feedback |
When struck, 25% chance to Ignite attacker | UOnDamagedAbility |
echo_vital_surge |
Kills while ≤35% HP restore 10% MaxHealth (health-gated heal channel) | UOnKillAbility (HealEffectClass) |
echo_killing_fervor |
Each credited kill stacks a power buff (paired with a drain) | UOnKillAbility (OnKillEffectClasses → GA_KillingFervor, stacking GE_KillingFervor_Power + GE_KillingFervor_Drain) |
UOnKillAbility carries two independent channels — the health-gated heal (Vital Surge) and the unconditional per-kill OnKillEffectClasses (Killing Fervor). The health threshold gates only the heal; it is not a heal-only base. See Ability Classes.
Desire Tracking & Awakening¶
URemnantTrackerComponent¶
Component on AEternalPlayer (player controller scope — matches inventory / compendium ownership). Lifetime spans the player's session; equipped items tick automatically.
BeginPlay (server only):
Subscribe to game-wide kill credit event
HandleKillCredited(Victim):
Compute kill facts once: bVictimIsBleeding, WearerHealthFraction
For each UItemObject in equipment slots:
if has FRemnantItemState and state == Sealed:
if DoesKillSatisfyDesire(ActiveDesire.TaskTag, facts):
ActiveDesire.Progress += 1 (capped at Target)
MarkStateModulesDirty (forces replication)
if Progress >= Target: AwakenItem(Item)
AwakenItem(Item):
State = Awakened
For each EchoMod:
bIsRevealed = true
bIsActive = true
UEquipmentComponent::RefreshItemModifiers(Item)
→ equipment removes + re-runs apply loop for that item
→ Echo GEs now pass the source gate and spawn
MarkStateModulesDirty
Kill Attribution & Desire Routing¶
Kills credited through the GAS damage pipeline broadcast Event.Combat.KillCredited to every contributor's pawn. The tracker computes per-kill facts once (victim bleeding? wearer health fraction?) and tests each equipped sealed Remnant's Desire via DoesKillSatisfyDesire(TaskTag, bVictimIsBleeding, WearerHealthFraction):
FDesire.TaskTag |
Counts the kill when |
|---|---|
Desire.Kill.Any (or empty) |
Always (back-compat fallback) |
| Unknown tag | Always, but logs an error once per tag (authoring drift must not be silent) |
Desire.Kill.Bleeding |
Victim was Bleeding |
Desire.Kill.LowHealth |
Wearer was below 50% Health (LowHealthDesireThreshold) |
Environmental / hazard kills that bypass GAS (world damage volumes, fall damage) do not credit. Attribution through those paths is deferred — would require a world-damage → GAS bridge or a parallel credit event.
Echo Mod Reveal Gate¶
Echo Mods are standard FRolledModifier structs with Source = ECraftingModifierSource::Echo. They're stored inside FRemnantItemState::EchoMods (each wrapped in an FEchoMod adding bIsRevealed and bIsActive flags), not in the item's FEquipmentFragment::Modifiers array.
Apply-Time Gate¶
The equipment apply loop — the same one that handles implicit / prefix / suffix / scaling mods on every equipped item — runs a ShouldApply(Item, Mod) check before spawning a GE:
| Modifier Source | Gate |
|---|---|
Drop (standard loot roll) |
Always true |
Crafted (crafting station output) |
Always true |
Echo (Remnant) |
True iff FEchoMod::bIsActive on the owning item's state module |
Sealed Remnants equip normally; only their prefix/suffix/implicit mods apply. On awaken, bIsActive flips and UEquipmentComponent::RefreshItemModifiers(Item) re-runs the apply loop for that item, spawning Echo GEs as regular equipment-owned handles. No dual-ownership — equipment is the sole GE handle owner for all modifier sources.
Tooltip Reveal Gate¶
FModifierDefinition::FormatDescription(Value, bRevealValue) returns a placeholder (???) when bRevealValue = false. The tooltip ViewModel reads per-Echo-Mod bIsRevealed from the state module and passes it to the formatter. Non-Echo mods always reveal.
Desire line (Kill any enemy X / Y) renders on the tooltip while the item is Sealed, suppressed after awaken.
Replication Strategy¶
What Replicates¶
| Property | Where | Replication |
|---|---|---|
UItemObject::StateModules |
UItemObject |
ReplicatedUsing=OnRep_StateModules, broadcasts OnItemStateModulesChanged |
Event ProgressSeconds, bActive, bIsClosing, ClosingSecondsRemaining |
ARemnantEventActor_HoldGround |
UPROPERTY(Replicated) — clients read for widget state |
Wave ElapsedSeconds, NextWaveIndex, bActive |
ARemnantWaveSpawner |
UPROPERTY(Replicated) — clients use for UI countdowns |
Sub-Struct Mutation Gotcha¶
TArray<TInstancedStruct<>> property mutations inside a contained struct (e.g. flipping FEchoMod::bIsRevealed) don't flag the parent array dirty automatically. Server callers mutate via GetStateModuleMutable<T>() and then call UItemObject::MarkStateModulesDirty() to force a net update. Listen-server host also broadcasts OnItemStateModulesChanged locally since it won't receive its own OnRep.
Client-Side Reaction¶
Tooltip ViewModels, equipment UI, and any other consumer of Remnant state subscribe to UItemObject::OnItemStateModulesChanged. On fire, they re-snapshot from the state module — no property-by-property change callbacks.
Multiplayer Scope¶
MVP is solo. All code paths are server-authoritative (so coop-safe in principle), but the edge cases (partial-death event exit policy, per-player leash radius, shared entry transforms for party entry) are deferred until coop rules are locked.
Public Contracts¶
URemnantRealmSubsystem (WorldSubsystem)¶
| Method | Parameters | Purpose |
|---|---|---|
EnterRealm |
(Player, EntryTransform, RealmLevel, SpawnLocation) |
Begin a realm run. Server-only. |
ExitRealm |
(Player, bSuccess) |
End a realm run, teleport player back. Server-only. |
IsPlayerInRealm |
(Player) |
Query whether a player is currently inside a realm. |
GetActiveRealmLevel |
— | Returns the streamed ULevel* while a realm is active, else nullptr. Used by GameState so items dropped during a realm run are owned by the realm level. |
ARemnantPortalActor¶
| Property | Purpose |
|---|---|
Era |
Gameplay tag passed through to the rolled item |
RealmLevel |
Soft reference to the sub-level streamed on interact |
RealmSpawnLocation |
Landing point inside the realm |
ARemnantEventActor_HoldGround¶
| Property | Purpose |
|---|---|
LeashRadius |
In-radius threshold for progress tick |
ProgressTargetSeconds |
Event completion time |
WaveSpawner |
Placed spawner reference |
RewardPool |
Pool used for RollRemnantItem on success |
ClosingDurationSeconds |
Time the realm stays open after reward drop |
URemnantTrackerComponent¶
Lifecycle-driven — subscribes to kill events in BeginPlay, ticks equipped items internally. One pure helper is public/static for unit testing:
| Member | Purpose |
|---|---|
DoesKillSatisfyDesire(TaskTag, bVictimIsBleeding, WearerHealthFraction) |
Static. Routes a kill to a Desire's TaskTag (Any / Bleeding / LowHealth) |
LowHealthDesireThreshold |
Constant 0.5f — wearer health fraction for Desire.Kill.LowHealth |
URemnantItemFactory¶
| Method | Parameters | Purpose |
|---|---|---|
RollRemnantItem |
(Outer, Pool) |
Static. Rolls a full Sealed Remnant with Echo Mods + Desire. |
Data Types¶
| Struct / Enum | Purpose |
|---|---|
ERemnantState |
Sealed / Awakened |
FEchoMod |
Rolled modifier + bIsRevealed + bIsActive gates |
FDesire |
TaskTag, Progress, Target, DescriptionTemplate |
FRemnantItemState |
ERemnantState, EchoMods, ActiveDesire, Era, PoolRef — attached as state module |
FRemnantFragment |
Empty marker fragment in item manifest |
URemnantItemPoolDataAsset |
Primary data asset: base items, Echo Mod pool, Desire pool |
Source References¶
| Component | Location |
|---|---|
URemnantRealmSubsystem |
Public/Remnant/Subsystems/RemnantRealmSubsystem.h |
URemnantTrackerComponent |
Public/Remnant/Components/RemnantTrackerComponent.h |
URemnantItemFactory |
Public/Remnant/Factory/RemnantItemFactory.h |
ARemnantPortalActor |
Public/Remnant/Actors/RemnantPortalActor.h |
ARemnantEventActor_HoldGround |
Public/Remnant/Actors/RemnantEventActor_HoldGround.h |
ARemnantWaveSpawner |
Public/Remnant/Actors/RemnantWaveSpawner.h |
FRemnantFragment |
Public/Remnant/Fragments/RemnantFragment.h |
FRemnantItemState, FEchoMod, FDesire |
Public/Remnant/Types/RemnantTypes.h |
URemnantItemPoolDataAsset |
Public/Remnant/Data/RemnantItemPoolDataAsset.h |
| Echo pool registration | Private/Inventory/SubSystems/ModifierSubsystem.cpp → LoadAndRegisterRemnantEchoPools |
| Echo pool storage | Public/Inventory/Modifiers/ModifierPoolManager.h → RemnantEchoPools |
| Sublevel tracking for spawn routing | Public/GameMode/Components/TransitionStateManager.h → NotifyDynamicSublevelLoaded() / GetActiveDynamicSublevel() |
| InWorldTeleport PlayerStart resolution | Private/GameMode/Components/PlayerSpawnManager.cpp |
| Log category | Public/Remnant/RemnantLog.h |
Related Systems¶
- Item System —
UItemObject,FItemManifest, identity + replication - Item Fragments — Fragment composition and marker pattern
- Item State Modules — Runtime state pattern used by
FRemnantItemStateand future mechanics - Equipment System — Apply loop, source-based gate,
RefreshItemModifiers - Loot System —
ALootContainerdrop path used for reward delivery - Replication Overview — Sub-struct dirty flagging,
OnReppatterns - API Layer — Save payload shape for Remnant state modules
Recent Changes¶
| Date | Change | Impact |
|---|---|---|
| 2026-08-06 | Procedural portals + realm-owned lighting | Portals arrive as chance-injected Room.RemnantRealm dungeon rooms (Forest ships RD_Forest_Remnant / L_Room_Forest_Remnant / LS_Factory); ARemnantPortalActor::RealmLighting swaps the world's lighting sublevel behind the transition fade, cached as PreRealmLighting and restored on exit and on disconnect cleanup. RealmSpawnLocation documented as a world offset — zero uses the subsystem's reserved offset, so procedural portals need no authored position. Dead Era property row removed |
| 2026-07-08 | Era → item chain documented; era provenance surfaced in the Item Manager | Era belongs to the realm's reward pool, never to the manifest. A Remnant manifest's details panel now shows a read-only Drops in era row derived by reverse lookup through DA_EraRegistry, and flags bases no pool lists |
| 2026-07-07 | Killing Fervor Echo Mod (6th live mod) | echo_killing_fervor → GA_KillingFervor, a UOnKillAbility using the new unconditional OnKillEffectClasses channel (stacking GE_KillingFervor_Power + GE_KillingFervor_Drain); the base class's health threshold now gates only the heal channel, not the whole ability |
| 2026-06-11 | Echo pool filled (5 live mods) + Desire TaskTag routing | Pressure / Static Charge / Hemorrhage Cascade / Ember Feedback / Vital Surge Echo Mods backed by new proc ability bases; DoesKillSatisfyDesire routes kills to Desire.Kill.Any/Bleeding/LowHealth; new Event.Combat.KillCredited / DamageReceived events |
| 2026-04 | Remnant persistence via state module wrapper | FRemnantItemState round-trips through save/load; equipped awakened Remnants survive relog |
| 2026-04 | Echo pool registered with UModifierPoolManager (3rd pool kind) |
Echo Mod IDs resolve everywhere via FindModifierByID; sealed from loot rolls |
| 2026-04 | Era + PoolRef moved from FRemnantFragment to FRemnantItemState |
Fragment is now a pure marker; origin identity persists with the instance |
| 2026-04 | Realm teleport routed through UTransitionStateManager::InWorldTeleport |
Unified freeze/fade/client-ready with hub/dungeon transitions; coop-ready machinery |
| 2026-04-24 | InWorldTeleport spawns routed to the streamed realm sub-level | Realm PlayerStart resolves from the loaded sub-level exclusively (no hub fallback); fixes wrong-spawn from non-test boot paths and surfaces missing realm PlayerStarts as a loud authoring error |
| 2026-04-21 | Initial MVP | Portal → realm → Hold-the-Ground → sealed item → equip → auto-awaken loop |