Saving System

NihiloCore provides a policy-driven save system built around one reusable data type, UNihiloSaveGame, and a composable service, UNihiloSavingService. Rather than each plugin defining its own USaveGame subclass and save/load plumbing, four independent concerns — what gets saved, where it's written, when a write actually happens, and which slot name it's written under — are each expressed as a swappable, inline-editable policy object. This is the same system that powers Gateway's stat, challenge, and reward persistence.

Core Pieces

The system is five cooperating pieces. Most day-to-day usage only touches the first two directly.
Piece Role
UNihiloSavingService The entry point game code interacts with. Owns a profile set, tracks the current pending payload, and exposes Save, SaveImmediate, and Load. Abstract — plugins subclass it to add domain-specific convenience methods.
UNihiloSaveGame The one concrete USaveGame type used across the ecosystem. Holds any USTRUCT as an FInstancedStruct payload, so no plugin needs its own save-data class.
UNihiloSlotPolicy Resolves the final slot name from a base name — PIE-instance suffixing, per-player scoping, or unchanged.
UNihiloSaveStoragePolicy Performs the actual read/write/delete against one destination. Local disk by default.
UNihiloSaveTimingPolicy Decides when a requested save actually executes — immediately, debounced, or deferred until shutdown.
info
Every policy class supports EditInlineNew Blueprint subclasses, so a project can add its own slot, storage, or timing behavior without touching NihiloCore's source.

Save Profiles

FNihiloSaveProfileSet holds two independent FNihiloSaveProfile instances — DebugProfile and ShippingProfile — and resolves to whichever is active via GetProfile, gated on UE_BUILD_SHIPPING. This lets a project use PIE-safe, isolated slot naming and fast local-only saves during development, while shipping builds use fixed slot names and whatever storage destinations the real game needs — with no manual toggling.
Each profile owns its own BaseSlotName, a single UNihiloSlotPolicy, and an array of UNihiloSaveStoragePolicy instances — full independence between debug and shipping, not just a shared config with a couple of overridden fields.

Slot Policies

A slot policy answers one question: given a base slot name, what should the actual save slot be called?
cpp FString ResolveSlotName(const UObject* WorldContextObject, const FString& BaseSlotName) const;
Policy Behavior
Fixed Returns the base name unchanged. The typical shipping choice.
PIE Scoped Appends the current PIE instance ID (e.g. _PIE1) so multiple PIE clients testing multiplayer locally never share a save file. bDeleteSaveOnPIEEnd optionally removes that instance's save file when the PIE session ends.
Per Local Player Suffixes by local player/controller ID, for split-screen setups.
A service can also bypass resolution entirely at runtime with OverrideSlot — useful for an explicit save-slot menu — and return to policy-driven resolution with ClearSlotOverride.

Storage Policies

A storage policy performs the actual persistence against one destination: WriteSave, ReadSave, and DeleteSave. UNihiloSaveStoragePolicy_Local is the built-in implementation, wrapping UGameplayStatics's slot-based save functions.
Because each profile holds an array of storage policies rather than a single one, redundant multi-destination saving falls out naturally — add more than one entry and a save request writes to every destination. On load, the service reads from every destination and keeps whichever returns the most recent result.
Each storage policy owns its own UNihiloSaveTimingPolicy rather than sharing one across the whole profile — a fast local write and a rate-limited platform upload can run on entirely independent schedules within the same save request.
Platform and Console Storage
info
No platform-specific storage backends ship by default. Console platform SDKs are distributed under NDA and cannot legally ship inside a publicly distributed plugin. Subclass UNihiloSaveStoragePolicy against your own licensed SDK access and add the resulting class to a profile's StoragePolicies array like any other.

Timing Policies

A timing policy decides when a requested save actually executes. RequestSave is called every time game logic wants a save to eventually happen; the policy decides whether and when to invoke the bound write callback.
Policy Behavior
Instant Executes immediately on every request.
Debounced Coalesces rapid repeated requests, waiting SaveDelayTime seconds of inactivity before writing. MaxDelayTime is an upper bound, so a continuously-updating stat still saves periodically rather than never.
On End Never fires on its own — it just tracks that a save is owed, and only writes when the owning service is deinitialized. Good for data that's cheap to recompute in memory but unnecessary to persist mid-session.
Every timing policy exposes CancelPendingSave, used internally when a higher-priority write supersedes a scheduled one.

Save vs. Save Immediate

Save is the routine path — updates the pending payload and asks each storage's timing policy to schedule a write on its own terms. This is what ordinary gameplay events should call.
SaveImmediate is the escape hatch — bypasses every timing policy, cancels any pending scheduled writes, and writes to every destination synchronously. Reserve it for moments that must persist right now — an explicit "Save Now" action, or a rare, significant event — not routine per-frame saves.
cpp Example.cpp
// Routine save — respects each storage's configured timing policy
void AMyPlayerController::OnStatChanged()
{
    FMyProgressData Progress = BuildCurrentProgress();
    SavingService->SaveGeneric(Progress);
}

// Forced save — bypasses timing, writes to every destination now
void AMyPlayerController::OnMatchCompleted()
{
    FMyProgressData Progress = BuildCurrentProgress();
    SavingService->SaveGenericImmediate(Progress);
}
info
The pending payload is always the latest data passed to Save, not a snapshot taken at request time. A slow, rate-limited destination that fires minutes after its last request still writes whatever is current at that moment.

Payload and Timestamps

Any USTRUCT can be saved by packing it into a UNihiloSaveGame's payload with SetPayload, and read back with GetPayload. This is what lets Gateway, and any future plugin, share one save-game type without needing their own subclass.
cpp GatewaySavingService.cpp
void UGatewaySavingService::SaveGatewayProgress(const FGatewayProgress& Progress)
{
    UNihiloSaveGame* SaveGame = NewObject<UNihiloSaveGame>(this);
    SaveGame->SetPayload(Progress);
    Save(SaveGame);
}

FGatewayProgress UGatewaySavingService::LoadGatewayProgress()
{
    if (UNihiloSaveGame* SaveGame = Cast<UNihiloSaveGame>(Load()))
    {
        if (const FGatewayProgress* Progress = SaveGame->GetPayload<FGatewayProgress>())
        {
            return *Progress;
        }
    }
    return FGatewayProgress();
}
UNihiloSaveGame implements INihiloTimestampedSaveGame, stamping SaveTime automatically whenever a payload is set. This interface is what multi-destination loading compares against to determine which storage's result is most recent — a project supplying its own USaveGame type can still participate by implementing the interface directly, with no dependency on the base class.

Using a Custom Save Game Type

UNihiloSaveGame is the default, but nothing in the service requires it. Override GetSaveGameClass on a service subclass to point at your own USaveGame type, and implement INihiloTimestampedSaveGame on it to keep redundant multi-destination saving working correctly. This keeps adopting the saving system from requiring a project to restructure an existing save-data hierarchy.

Lifecycle

Call When
Initialize Once the owning object is ready, passing a world context object. Initializes every configured storage and timing policy for the active profile.
IsInitialized Check before calling Load in response to an RPC or other early-lifecycle trigger — a service on a just-joined client may not have finished initializing yet, so callers should defer rather than assume readiness from a non-null pointer alone.
Deinitialize When the owning object is destroyed or the session ends. Force-flushes any pending write on every storage policy — cancelling scheduled timers and writing synchronously — so debounced or on-end data is never silently lost to a timer that never got the chance to fire.