Skip to content

Recipe 1: Interactive Loot Chest & Container ​

This recipe demonstrates how to build a fully persistent interactive treasure chest. It persists its open/closed state, inventory contents, and handles destruction safely without duplicating loot drops on load.


1. Feature Requirements ​

  1. Persistent State: The chest remembers if it has been opened (bIsOpen), its remaining items (StoredItems), and its gold count.
  2. Animation / Mesh State: On load, if bIsOpen == true, the chest immediately displays its open lid pose without replaying the opening sound.
  3. Loot Dropping: Interacting opens the chest and spawns loot items in front of it.
  4. Destruction & Load Safety: If smashed by a player, it drops any remaining loot and is destroyed. When a save is loaded, WasDestroyedByLoad ensures that loot and death particles are not re-spawned.

2. Blueprint Implementation ​

Step 1: Create the Actor Blueprint ​

  1. Create a new Actor Blueprint named BP_LootChest.
  2. Add a StaticMeshComponent for the chest base and a child StaticMeshComponent for the chest lid.
  3. Add the UAegisPersistenceComponent.
  4. In the Persistence Component details, leave Scope = World and bPersistTransform = True.

Step 2: Define Variables and Tag Them ​

Create the following variables in the My Blueprint panel, and tick SaveGame in their Advanced details:

Variable NameTypeSaveGame Ticked?Default ValuePurpose
bIsOpenBooleanYesfalseTracks open/closed state
GoldAmountIntegerYes150Gold stored inside
LootItemIDsArray of NamesYes["Sword_01", "Potion_Health"]Items stored inside

Step 3: Interaction Logic (Open & Drop Loot) ​

text
[Event: OnPlayerInteract]
       │
       ▼
   [Branch: bIsOpen == false?]
   ├── False ──► (Already open; do nothing)
   └── True  ──► [Set bIsOpen = true]
                      │
                      ▼
                 [Spawn Loot Actors in World]
                 [Play Sound: "ChestOpen"]
                 [Play Open Timeline (Rotate Lid 90°)]
                      │
                      ▼
                 [Mark Changed (Aegis)] (Notifies Aegis this chest is dirty!)

Step 4: BeginPlay (Restoring Visual State) ​

When the level loads or streams in, restore the lid angle without playing the opening chime:

text
[Event BeginPlay]
       │
       ▼
   [Branch: bIsOpen == true?]
   ├── True  ──► [Set Lid Relative Rotation: Pitch=90°] (Already opened; snap open!)
   └── False ──► [Set Lid Relative Rotation: Pitch=0°]  (Still closed)

Step 5: EndPlay (Guarding Against Duplicate Loot on Load) ​

text
[Event End Play]
       │
       ▼
[Was Destroyed By Load (Aegis)]
       │
       ▼
   [Branch]
   ├── True  ──► (Actor cleaned up by save load; DO NOTHING!)
   └── False ──► [Spawn Remaining Loot] ──► [Spawn Splinter Debris FX]

3. C++ Implementation ​

Here is the complete, production-ready C++ implementation:

cpp
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Identity/AegisPersistenceComponent.h"
#include "Identity/AegisPersistenceEvents.h"
#include "InteractiveChest.generated.h"

UCLASS()
class YOURGAME_API AInteractiveChest : public AActor, public IAegisPersistenceEvents
{
    GENERATED_BODY()

public:
    AInteractiveChest();

    /** Triggers player interaction */
    UFUNCTION(BlueprintCallable, Category = "Chest")
    void Interact(APawn* InstigatorPawn);

protected:
    virtual void BeginPlay() override;
    virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

    // --- IAegisPersistenceEvents Interface ---
    virtual int32 GetAegisStateRevision_Implementation() const override;

    // --- Components ---
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
    TObjectPtr<UStaticMeshComponent> ChestBaseMesh;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
    TObjectPtr<UStaticMeshComponent> ChestLidMesh;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Persistence")
    TObjectPtr<UAegisPersistenceComponent> PersistenceComponent;

    // --- Persistent Gameplay Properties (Marked SaveGame) ---
    UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Chest|Data")
    bool bIsOpen = false;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Chest|Data")
    int32 GoldAmount = 150;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Chest|Data")
    TArray<FName> StoredItemIDs;

private:
    /** Internal state revision counter for sub-millisecond change detection */
    UPROPERTY(SaveGame)
    int32 StateRevision = 0;

    void ApplyVisualOpenState(bool bAnimate);
    void DropLootItems();
};
cpp
#include "InteractiveChest.h"
#include "Core/AegisSubsystem.h"
#include "Kismet/GameplayStatics.h"

AInteractiveChest::AInteractiveChest()
{
    PrimaryActorTick.bCanEverTick = false;

    ChestBaseMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("ChestBase"));
    RootComponent = ChestBaseMesh;

    ChestLidMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("ChestLid"));
    ChestLidMesh->SetupAttachment(RootComponent);

    // Attach Aegis Persistence
    PersistenceComponent = CreateDefaultSubobject<UAegisPersistenceComponent>(TEXT("PersistenceComponent"));
    PersistenceComponent->SetScope(EAegisScope::World);
    PersistenceComponent->SetShouldPersistTransform(true);

    // Populate initial default loot
    StoredItemIDs.Add(TEXT("IronSword"));
    StoredItemIDs.Add(TEXT("HealthPotion"));
}

void AInteractiveChest::BeginPlay()
{
    Super::BeginPlay();

    // If loaded as already opened, snap lid open immediately without re-animating
    if (bIsOpen)
    {
        ApplyVisualOpenState(/*bAnimate=*/ false);
    }
}

void AInteractiveChest::Interact(APawn* InstigatorPawn)
{
    if (bIsOpen)
    {
        return; // Already looted
    }

    bIsOpen = true;
    StateRevision++; // Bump revision counter for change detection

    ApplyVisualOpenState(/*bAnimate=*/ true);
    DropLootItems();

    // Inform Aegis that this actor is dirty
    if (UAegisSubsystem* Aegis = UAegisSubsystem::Get(this))
    {
        Aegis->MarkChanged(this);
    }
}

int32 AInteractiveChest::GetAegisStateRevision_Implementation() const
{
    // Return revision counter so Aegis skips serializing this chest when unchanged!
    return StateRevision;
}

void AInteractiveChest::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    Super::EndPlay(EndPlayReason);

    // CRITICAL: Check if destroyed because a loaded save marked it dead
    UAegisSubsystem* Aegis = UAegisSubsystem::Get(this);
    if (Aegis && Aegis->WasDestroyedByLoad(this))
    {
        // Cleaned up by save load; skip loot drops and particle effects!
        return;
    }

    // Normal destruction during active gameplay:
    if (!bIsOpen)
    {
        DropLootItems();
    }
}

void AInteractiveChest::ApplyVisualOpenState(bool bAnimate)
{
    const FRotator OpenRotation(0.0f, 0.0f, -90.0f);
    ChestLidMesh->SetRelativeRotation(OpenRotation);
}

void AInteractiveChest::DropLootItems()
{
    // Gameplay logic to spawn pickup actors in front of the chest
    StoredItemIDs.Empty();
    GoldAmount = 0;
}

4. Key Takeaways from this Recipe ​

  1. StateRevision Optimization: By implementing IAegisPersistenceEvents::GetAegisStateRevision, Aegis only serializes this chest when Interact increments the revision. In an open world with 5,000 chests, 4,999 of them cost 0 microseconds during autosaves!
  2. WasDestroyedByLoad: Always check this method in EndPlay to ensure that loaded games don't duplicate loot drops.
  3. Restoring State in BeginPlay: Check your SaveGame booleans in BeginPlay to set visual state (such as opening meshes, lighting torches, or playing particle loops).

Aegis Save — Enterprise World Persistence for Unreal Engine 5.