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. |
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
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);
}
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. |