Save systems are the kind of thing you don't think enough about until you're knee-deep in complex game state and realise that serialising it badly means either corrupted saves or six months of migration headaches. We started designing Starbud Station's save system early — before most systems were complete — because the shape of what needed saving informed the architecture of the systems themselves.
The game saves a lot. Island layout and structure placements. All inventory containers across every island. Machine job queues and their elapsed progress. Shop level and unlock state. The day/time cycle. Trader relationship scores. Player positions. Active trader visits. All of this needs to survive a session end and come back correctly, including in the middle of an active co-op session where multiple players are present.
The ISaveable Interface
The foundation of the system is an ISaveable interface that every system capable of saving state implements. The interface defines two functions: GatherSaveData, which the system calls to package its state into a serializable struct, and RestoreSaveData, which unpacks that struct back into live state on load.
UINTERFACE(MinimalAPI) class USaveable : public UInterface { GENERATED_BODY() }; class ISaveable { GENERATED_BODY() public: virtual FSystemSaveData GatherSaveData() const = 0; virtual void RestoreSaveData(const FSystemSaveData&) = 0; };
The USaveManagerSubsystem iterates over all registered saveable systems when a save is triggered, calls GatherSaveData on each, packs everything into a UStarbudSaveGame object, and writes it to disk. On load, the process reverses: read from disk, iterate saveables, call RestoreSaveData. Systems don't need to know about each other, and the save manager doesn't need to know what any individual system's data looks like internally.
System Registration
Systems register themselves with the USaveManagerSubsystem during their own initialization. This decouples the save manager from needing a hardcoded list of every saveable — if a new system is added to the game, it self-registers and gets picked up automatically on the next save/load cycle.
void UInventoryManagerSubsystem::Initialize(FSubsystemCollectionBase& Collection) { Super::Initialize(Collection); if (USaveManagerSubsystem* SaveMgr = GetGameInstance() ->GetSubsystem<USaveManagerSubsystem>()) { SaveMgr->RegisterSaveable(this); } }
Each system owns its own save data struct. The inventory system packages all container states. The machine system packages active job queues with elapsed timers. The shop level system packages the current level, XP, and unlock state. These structs are defined as USTRUCT types with full reflection support, which means Unreal's serializer handles the binary packing automatically.
Handling Version Changes
The save file includes a version number. Every time the save data schema changes — a new field added, a struct renamed, a system's data format updated — the version increments. On load, the save manager checks the save file version against the current game version and runs any applicable migration functions before handing data to systems.
We keep migrations additive where possible. Adding a new field to a save struct with a sensible default value means old saves load correctly without a migration step — the field simply isn't present, and the default kicks in. Migrations are only written when data needs to be transformed, not just extended.
Migrations are stored as a versioned array of functions in the save manager. Version 1 to 2 might rename an inventory container ID format. Version 2 to 3 might convert a float-based timer to an integer representation. They run in sequence on any save file that's behind the current version, so a player coming back after a long break gets all migrations applied in one load.
Co-op Considerations
In a co-op session, save authority belongs to the server. Only the server calls GatherSaveData and writes to disk. Client state — player positions, personal inventory, active interactions — is submitted to the server via RPC before a save is written, ensuring the complete session state is captured even across multiple connected clients.
When a client joins a session mid-play, they don't load from the save file. They receive the current live game state from the server through standard replication. The save file is only relevant at session start. This means there's no concept of a "client save" — there's one canonical save file, owned by whoever is hosting, and everyone's experience derives from that.
Autosave and Manual Save
The game autosaves at the end of each in-game day. Players can also trigger a manual save from the station terminal at any time. Both paths run through the same USaveManagerSubsystem function — there's no separate code path for autosave vs manual. The only difference is the slot name used when writing to disk, so players can maintain a backup save independently of the autosave slot.
The save write itself is asynchronous. The game doesn't freeze while serializing. The UGameplayStatics::AsyncSaveGameToSlot path handles this natively in Unreal, and we bind a completion delegate to show a save confirmation in the station terminal UI once the write completes.