Save systems are one of those things that seem simple until you ship. Load a file, write some data, done. Except then a player reports their 40-hour save is gone. Or your update breaks every existing save file. Or saves work fine on PC but corrupt on Switch.
The save system is invisible infrastructure. Players never think about it when it works. They uninstall your game when it doesn't.
This post compares three approaches across two major engines and a roll-your-own path: Godot's Resource-based saving, Unreal Engine's SaveGame system, and custom serialization using JSON, MessagePack, or SQLite. For each, we cover the architecture, working code, tradeoffs, and when to pick it. We also cover the parts nobody talks about until it's too late: versioning, migration, corruption handling, and cloud save integration.
What to Save (And What Not To)
Before comparing implementations, you need to decide what goes into a save file. This sounds obvious. It isn't. The most common save system mistake is saving too much.
Save This
Player state. Health, mana, stamina, position, rotation, current level, active abilities. This is the obvious stuff.
Progression data. Quest states, unlocked abilities, skill trees, achievement flags, discovered map regions, NPC relationship values.
Inventory. Items, quantities, equipped slots, storage containers. If your game has item durability, enchantments, or socket systems, each item needs a unique identifier plus its modifier data.
World state changes. Doors the player unlocked, enemies they killed (if persistent), switches they toggled, story-critical environmental changes. Only save state that differs from the default — if a door starts locked and is now unlocked, save that. Don't save every door's state.
Settings. Audio levels, graphics options, control bindings, accessibility preferences. These usually live in a separate file from game saves, and for good reason — players expect settings to persist even if they delete their save data.
Don't Save This
Derived data. If you can recalculate it from saved data, don't save it. Total play time can be recalculated from session timestamps. Character stats derived from level + equipment + buffs should be recalculated on load, not saved. Saving derived data creates desync bugs when your formulas change.
Runtime-only state. Active particle systems, sound cue playback positions, animation states, physics simulation states. These should be reconstructed during the load process, not serialized. Trying to serialize a physics body's full state is a recipe for non-deterministic bugs.
Asset references by memory address. Save asset paths or IDs, never pointers. This seems obvious, but it's a real source of bugs in both Godot and Unreal when developers serialize objects that contain reference types.
Temporary state. Cooldown timers, active VFX, in-progress tweens. If the player saves mid-combat, you have a choice: either save enough state to resume mid-combat (complex), or save the last stable state before combat began (simpler, slightly worse UX). Most indie games choose the latter because the former doubles the save system complexity.
The "Minimum Viable Save" Rule
Here's a useful rule of thumb: your save file should contain the minimum data needed to reconstruct the player's experience to a point where they can't tell the difference between "resumed from save" and "never stopped playing." Anything beyond that minimum is unnecessary complexity.
For a platformer, that might be: current level, collected items, unlocked abilities. For an open-world RPG, it's considerably more. But even in RPGs, most developers save too much rather than too little.
Approach 1: Godot Resource-Based Saving
Godot has a built-in mechanism that most engines don't: the Resource system. Resources are serializable data containers that Godot can save and load natively. This makes them a natural fit for save data.
Architecture
The basic architecture is straightforward:
- Define a custom Resource class that holds your save data
- Populate it from your game state
- Use
ResourceSaver.save()to write it to disk - Use
ResourceLoader.load()to read it back
The Resource system handles serialization automatically for built-in types: integers, floats, strings, Vector2/3, Color, arrays, dictionaries, and nested Resources.
Basic Implementation
# save_data.gd — Define your save data structure
class_name SaveData
extends Resource
@export var player_position: Vector3
@export var player_health: float
@export var player_max_health: float
@export var current_level: String
@export var inventory: Array[ItemData] = []
@export var quest_states: Dictionary = {}
@export var world_flags: Dictionary = {}
@export var play_time_seconds: float
@export var save_timestamp: String
@export var save_version: int = 1
# item_data.gd — Nested Resource for inventory items
class_name ItemData
extends Resource
@export var item_id: String
@export var quantity: int
@export var durability: float
@export var custom_properties: Dictionary = {}
# save_manager.gd — The actual save/load logic
extends Node
const SAVE_DIR := "user://saves/"
const SAVE_EXTENSION := ".tres"
func save_game(slot: int) -> Error:
var save_data := SaveData.new()
# Populate from game state
var player = get_tree().get_first_node_in_group("player")
if player:
save_data.player_position = player.global_position
save_data.player_health = player.health
save_data.player_max_health = player.max_health
save_data.current_level = get_tree().current_scene.scene_file_path
save_data.inventory = InventoryManager.get_save_data()
save_data.quest_states = QuestManager.get_save_data()
save_data.world_flags = WorldState.get_save_data()
save_data.play_time_seconds = TimeManager.get_total_play_time()
save_data.save_timestamp = Time.get_datetime_string_from_system()
save_data.save_version = 1
# Ensure directory exists
if not DirAccess.dir_exists(SAVE_DIR):
DirAccess.make_dir_recursive_absolute(SAVE_DIR)
var path := SAVE_DIR + "slot_%d%s" % [slot, SAVE_EXTENSION]
var error := ResourceSaver.save(save_data, path)
if error != OK:
push_error("Failed to save game: %s" % error_string(error))
return error
func load_game(slot: int) -> SaveData:
var path := SAVE_DIR + "slot_%d%s" % [slot, SAVE_EXTENSION]
if not ResourceLoader.exists(path):
push_warning("Save file not found: %s" % path)
return null
var save_data: SaveData = ResourceLoader.load(path) as SaveData
if save_data == null:
push_error("Failed to load save data from: %s" % path)
return null
return save_data
The JSON Export Alternative
Godot's .tres format is human-readable, but it's Godot-specific. If you need save files that work outside Godot (cloud saves to a web service, cross-platform save sharing, save editors), JSON is a better choice.
# json_save_manager.gd
extends Node
const SAVE_DIR := "user://saves/"
func save_to_json(slot: int, save_data: SaveData) -> Error:
var json_dict := {
"version": save_data.save_version,
"timestamp": save_data.save_timestamp,
"player": {
"position": var_to_str(save_data.player_position),
"health": save_data.player_health,
"max_health": save_data.player_max_health,
},
"current_level": save_data.current_level,
"inventory": _serialize_inventory(save_data.inventory),
"quests": save_data.quest_states,
"world_flags": save_data.world_flags,
"play_time": save_data.play_time_seconds,
}
if not DirAccess.dir_exists(SAVE_DIR):
DirAccess.make_dir_recursive_absolute(SAVE_DIR)
var path := SAVE_DIR + "slot_%d.json" % slot
var json_string := JSON.stringify(json_dict, "\t")
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return FileAccess.get_open_error()
file.store_string(json_string)
file.close()
return OK
func _serialize_inventory(items: Array[ItemData]) -> Array:
var result := []
for item in items:
result.append({
"id": item.item_id,
"qty": item.quantity,
"durability": item.durability,
"props": item.custom_properties,
})
return result
Pros and Cons
| Aspect | Resource (.tres) | JSON Export |
|---|---|---|
| Setup effort | Very low — just define Resources | Moderate — manual serialization |
| Type safety | Full — Resources are typed | None — manual validation needed |
| Nested objects | Automatic via nested Resources | Manual serialization per type |
| Human readable | Yes (text format) | Yes |
| Cross-platform data | Godot only | Universal |
| Performance | Fast for small-medium saves | Fast for small, scales linearly |
| Binary option | .res format available | MessagePack or similar needed |
| Save editors | Godot editor or text editor | Any JSON editor |
When to Use Godot Resources
Use the Resource approach when your game is Godot-only, your save data fits neatly into typed structures, and you don't need save file interop with external services. It's the fastest path to a working save system in Godot.
Switch to JSON when you need cloud save support with a web backend, save file portability, or when your save structure is highly dynamic (roguelike with procedurally generated item properties, for example).
What Doesn't Work
Large world state. If you're saving the state of thousands of objects (a Factorio-style factory, a Dwarf Fortress-style simulation), the Resource approach serializes everything into a single file in a single operation. This blocks the main thread. For large saves, you need chunked saving or a background thread approach.
Circular references. Godot Resources don't handle circular references well. If Object A references Object B and Object B references Object A, serialization will fail or produce unexpected results. Design your save data as a tree, not a graph.
Script class changes. If you rename a variable in your SaveData class, existing .tres files that reference the old name will silently drop that data on load. There's no built-in migration system. You have to handle this yourself.
Approach 2: Unreal Engine SaveGame
Unreal has a first-party save system built around the USaveGame class. It's more opinionated than Godot's Resource approach — Unreal provides the slot management, serialization format, and async operations out of the box.
Architecture
The Unreal save system has three layers:
- USaveGame — Your data class. Subclass this and add UPROPERTY fields.
- UGameplayStatics — Static functions for save/load operations.
- Slot system — Saves are identified by string slot names, not file paths.
Unreal handles the serialization format internally (it uses FArchive-based binary serialization). You don't choose the format, and you can't easily inspect save files with a text editor. This is a tradeoff: less flexibility, less room for mistakes.
Basic Implementation
// MySaveGame.h
#pragma once
#include "GameFramework/SaveGame.h"
#include "MySaveGame.generated.h"
USTRUCT(BlueprintType)
struct FInventoryItemSave
{
GENERATED_BODY()
UPROPERTY(SaveGame)
FName ItemID;
UPROPERTY(SaveGame)
int32 Quantity;
UPROPERTY(SaveGame)
float Durability;
UPROPERTY(SaveGame)
TMap<FString, FString> CustomProperties;
};
USTRUCT(BlueprintType)
struct FQuestStateSave
{
GENERATED_BODY()
UPROPERTY(SaveGame)
FName QuestID;
UPROPERTY(SaveGame)
int32 CurrentStage;
UPROPERTY(SaveGame)
bool bCompleted;
UPROPERTY(SaveGame)
TMap<FString, int32> ObjectiveProgress;
};
UCLASS()
class MYGAME_API UMySaveGame : public USaveGame
{
GENERATED_BODY()
public:
UMySaveGame();
UPROPERTY(SaveGame)
int32 SaveVersion;
UPROPERTY(SaveGame)
FDateTime SaveTimestamp;
// Player state
UPROPERTY(SaveGame)
FVector PlayerPosition;
UPROPERTY(SaveGame)
FRotator PlayerRotation;
UPROPERTY(SaveGame)
float PlayerHealth;
UPROPERTY(SaveGame)
float PlayerMaxHealth;
UPROPERTY(SaveGame)
FName CurrentLevelName;
// Inventory
UPROPERTY(SaveGame)
TArray<FInventoryItemSave> InventoryItems;
// Quests
UPROPERTY(SaveGame)
TArray<FQuestStateSave> QuestStates;
// World state — key-value pairs for flexibility
UPROPERTY(SaveGame)
TMap<FString, bool> WorldFlags;
UPROPERTY(SaveGame)
TMap<FString, FString> CustomKeyValues;
UPROPERTY(SaveGame)
float TotalPlayTimeSeconds;
};
// MySaveGame.cpp
#include "MySaveGame.h"
UMySaveGame::UMySaveGame()
{
SaveVersion = 1;
PlayerHealth = 100.f;
PlayerMaxHealth = 100.f;
TotalPlayTimeSeconds = 0.f;
}
// SaveManager.h — Game instance subsystem for save management
#pragma once
#include "Subsystems/GameInstanceSubsystem.h"
#include "SaveManager.generated.h"
UCLASS()
class MYGAME_API USaveManager : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "Save")
bool SaveGame(const FString& SlotName, int32 UserIndex = 0);
UFUNCTION(BlueprintCallable, Category = "Save")
UMySaveGame* LoadGame(const FString& SlotName, int32 UserIndex = 0);
UFUNCTION(BlueprintCallable, Category = "Save")
void AsyncSaveGame(const FString& SlotName, int32 UserIndex = 0);
UFUNCTION(BlueprintCallable, Category = "Save")
void AsyncLoadGame(const FString& SlotName, int32 UserIndex = 0);
UFUNCTION(BlueprintCallable, Category = "Save")
bool DoesSaveExist(const FString& SlotName, int32 UserIndex = 0);
UFUNCTION(BlueprintCallable, Category = "Save")
bool DeleteSave(const FString& SlotName, int32 UserIndex = 0);
UFUNCTION(BlueprintCallable, Category = "Save")
TArray<FString> GetAllSaveSlots();
private:
UMySaveGame* PopulateSaveData();
void ApplyLoadedData(UMySaveGame* SaveData);
void OnAsyncSaveComplete(const FString& SlotName, int32 UserIndex, bool bSuccess);
void OnAsyncLoadComplete(const FString& SlotName, int32 UserIndex, USaveGame* LoadedData);
};
// SaveManager.cpp
#include "SaveManager.h"
#include "MySaveGame.h"
#include "Kismet/GameplayStatics.h"
bool USaveManager::SaveGame(const FString& SlotName, int32 UserIndex)
{
UMySaveGame* SaveData = PopulateSaveData();
if (!SaveData) return false;
return UGameplayStatics::SaveGameToSlot(SaveData, SlotName, UserIndex);
}
UMySaveGame* USaveManager::LoadGame(const FString& SlotName, int32 UserIndex)
{
if (!UGameplayStatics::DoesSaveGameExist(SlotName, UserIndex))
{
UE_LOG(LogTemp, Warning, TEXT("No save found in slot: %s"), *SlotName);
return nullptr;
}
USaveGame* Loaded = UGameplayStatics::LoadGameFromSlot(SlotName, UserIndex);
UMySaveGame* SaveData = Cast<UMySaveGame>(Loaded);
if (!SaveData)
{
UE_LOG(LogTemp, Error, TEXT("Failed to load save from slot: %s"), *SlotName);
return nullptr;
}
ApplyLoadedData(SaveData);
return SaveData;
}
void USaveManager::AsyncSaveGame(const FString& SlotName, int32 UserIndex)
{
UMySaveGame* SaveData = PopulateSaveData();
if (!SaveData) return;
FAsyncSaveGameToSlotDelegate Delegate;
Delegate.BindUObject(this, &USaveManager::OnAsyncSaveComplete);
UGameplayStatics::AsyncSaveGameToSlot(SaveData, SlotName, UserIndex, Delegate);
}
void USaveManager::AsyncLoadGame(const FString& SlotName, int32 UserIndex)
{
FAsyncLoadGameFromSlotDelegate Delegate;
Delegate.BindUObject(this, &USaveManager::OnAsyncLoadComplete);
UGameplayStatics::AsyncLoadGameFromSlot(SlotName, UserIndex, Delegate);
}
bool USaveManager::DoesSaveExist(const FString& SlotName, int32 UserIndex)
{
return UGameplayStatics::DoesSaveGameExist(SlotName, UserIndex);
}
bool USaveManager::DeleteSave(const FString& SlotName, int32 UserIndex)
{
return UGameplayStatics::DeleteGameInSlot(SlotName, UserIndex);
}
UMySaveGame* USaveManager::PopulateSaveData()
{
UMySaveGame* SaveData = Cast<UMySaveGame>(
UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass())
);
if (!SaveData) return nullptr;
SaveData->SaveVersion = 1;
SaveData->SaveTimestamp = FDateTime::Now();
// Gather player state
APlayerController* PC = GetWorld()->GetFirstPlayerController();
if (PC && PC->GetPawn())
{
SaveData->PlayerPosition = PC->GetPawn()->GetActorLocation();
SaveData->PlayerRotation = PC->GetPawn()->GetActorRotation();
}
// Additional population from your game subsystems...
return SaveData;
}
void USaveManager::ApplyLoadedData(UMySaveGame* SaveData)
{
// Apply loaded data to game state
APlayerController* PC = GetWorld()->GetFirstPlayerController();
if (PC && PC->GetPawn())
{
PC->GetPawn()->SetActorLocation(SaveData->PlayerPosition);
PC->GetPawn()->SetActorRotation(SaveData->PlayerRotation);
}
// Additional restoration to your game subsystems...
}
void USaveManager::OnAsyncSaveComplete(const FString& SlotName, int32 UserIndex, bool bSuccess)
{
if (bSuccess)
{
UE_LOG(LogTemp, Log, TEXT("Async save complete: %s"), *SlotName);
}
else
{
UE_LOG(LogTemp, Error, TEXT("Async save failed: %s"), *SlotName);
}
}
void USaveManager::OnAsyncLoadComplete(const FString& SlotName, int32 UserIndex, USaveGame* LoadedData)
{
UMySaveGame* SaveData = Cast<UMySaveGame>(LoadedData);
if (SaveData)
{
ApplyLoadedData(SaveData);
}
else
{
UE_LOG(LogTemp, Error, TEXT("Async load failed or returned null: %s"), *SlotName);
}
}
Blueprint-Friendly Usage
The system above is fully callable from Blueprints thanks to the UFUNCTION(BlueprintCallable) macros. For teams working primarily in Blueprints, the Blueprint Template Library includes a complete save system that handles health, inventory, quest state, and custom key-value persistence with slot management built in. It saves you from writing the boilerplate shown above and comes with save slot UI, auto-save support, and migration hooks. If you're building in Blueprints and don't want to set up save infrastructure from scratch, it's worth a look.
Async Save and Load
One thing Unreal handles well is async save operations. AsyncSaveGameToSlot and AsyncLoadGameFromSlot run serialization on a background thread, which matters more than you'd think.
Save files for small indie games are typically 10-100 KB. At that size, synchronous saves are imperceptible. But as your game grows — open world with thousands of state flags, large inventories with complex items, multiple map layers of persistent data — saves can reach 1-10 MB. Synchronous saves at that size cause visible frame hitches.
Always use async saves once your save data exceeds ~100 KB. The API is barely more complex, and it prevents a category of performance bugs that only show up in endgame saves (when players have the most data and the strongest opinions about your game).
Pros and Cons
| Aspect | Unreal SaveGame |
|---|---|
| Setup effort | Low — subclass and add properties |
| Serialization | Automatic binary, not human-readable |
| Async support | Built-in, first-party |
| Slot management | Built-in |
| Platform save paths | Handled automatically |
| Blueprint support | Full UPROPERTY/UFUNCTION exposure |
| Inspecting saves | Difficult — binary format |
| Custom formats | Not supported without bypassing the system |
| Console certification | Handles platform-specific save locations |
What Doesn't Work
Inspecting save files. Unreal's binary save format is fast and compact but not human-readable. Debugging save issues means adding logging to your load path. You can't just open a save file and see what's inside. For development, consider writing a debug command that loads a save and dumps the contents to the log.
Partial saves. The SaveGame system saves everything in the USaveGame object as a single blob. You can't save just the inventory without also saving everything else. If you need partial updates (save settings separately from game state, for example), use multiple save slots or multiple USaveGame subclasses.
Structural changes. Adding new UPROPERTY fields to your USaveGame is fine — they'll default-initialize when loading old saves. Removing or renaming fields is where things get tricky. Removed fields are silently ignored (data is lost). Renamed fields appear as new fields (old data is lost, new field is default). There's no built-in migration.
Approach 3: Custom Serialization
Sometimes neither the engine's built-in system nor simple JSON fits your needs. Custom serialization gives you full control over format, performance, and compatibility — at the cost of writing and maintaining the serialization layer yourself.
When Custom Serialization Makes Sense
- Cross-engine games. If you're porting between engines or building a game framework that targets multiple engines, a custom format decouples your save system from any specific engine.
- Very large saves. Games with massive world state (simulation games, sandbox games, city builders) benefit from formats designed for their specific data patterns.
- Streaming saves. If you need to load parts of the save without loading the whole thing (loading the current chunk of an open world), you need a format that supports random access.
- Modding support. If you want players to be able to read and modify save files, you need a well-documented, human-friendly format with stable structure.
JSON: The Universal Choice
JSON is the most common choice for custom save serialization. It's human-readable, every language has a parser, and it's good enough for most games.
// Unreal Engine — JSON save/load using FJsonObject
#include "Json.h"
#include "JsonUtilities.h"
#include "Misc/FileHelper.h"
bool SaveToJson(const FString& FilePath, const FMyGameState& GameState)
{
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
Root->SetNumberField("version", GameState.SaveVersion);
Root->SetStringField("timestamp", FDateTime::Now().ToString());
// Player data
TSharedRef<FJsonObject> Player = MakeShared<FJsonObject>();
Player->SetNumberField("x", GameState.PlayerPosition.X);
Player->SetNumberField("y", GameState.PlayerPosition.Y);
Player->SetNumberField("z", GameState.PlayerPosition.Z);
Player->SetNumberField("health", GameState.PlayerHealth);
Player->SetNumberField("max_health", GameState.PlayerMaxHealth);
Root->SetObjectField("player", Player);
// Inventory
TArray<TSharedPtr<FJsonValue>> Items;
for (const auto& Item : GameState.Inventory)
{
TSharedRef<FJsonObject> ItemObj = MakeShared<FJsonObject>();
ItemObj->SetStringField("id", Item.ItemID.ToString());
ItemObj->SetNumberField("qty", Item.Quantity);
ItemObj->SetNumberField("durability", Item.Durability);
Items.Add(MakeShared<FJsonValueObject>(ItemObj));
}
Root->SetArrayField("inventory", Items);
// Serialize to string
FString OutputString;
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString);
FJsonSerializer::Serialize(Root, Writer);
return FFileHelper::SaveStringToFile(OutputString, *FilePath);
}
bool LoadFromJson(const FString& FilePath, FMyGameState& OutGameState)
{
FString JsonString;
if (!FFileHelper::LoadFileToString(JsonString, *FilePath))
{
return false;
}
TSharedPtr<FJsonObject> Root;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(JsonString);
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
{
return false;
}
OutGameState.SaveVersion = Root->GetIntegerField("version");
// Player data
TSharedPtr<FJsonObject> Player = Root->GetObjectField("player");
if (Player.IsValid())
{
OutGameState.PlayerPosition.X = Player->GetNumberField("x");
OutGameState.PlayerPosition.Y = Player->GetNumberField("y");
OutGameState.PlayerPosition.Z = Player->GetNumberField("z");
OutGameState.PlayerHealth = Player->GetNumberField("health");
OutGameState.PlayerMaxHealth = Player->GetNumberField("max_health");
}
// Inventory
const TArray<TSharedPtr<FJsonValue>>* ItemsArray;
if (Root->TryGetArrayField("inventory", ItemsArray))
{
for (const auto& ItemVal : *ItemsArray)
{
TSharedPtr<FJsonObject> ItemObj = ItemVal->AsObject();
FInventoryItemSave Item;
Item.ItemID = FName(*ItemObj->GetStringField("id"));
Item.Quantity = ItemObj->GetIntegerField("qty");
Item.Durability = ItemObj->GetNumberField("durability");
OutGameState.Inventory.Add(Item);
}
}
return true;
}
# Godot — JSON save/load
extends Node
func save_to_json(path: String, game_state: Dictionary) -> Error:
var json_string := JSON.stringify(game_state, "\t")
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return FileAccess.get_open_error()
file.store_string(json_string)
file.close()
return OK
func load_from_json(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return {}
var json_string := file.get_as_text()
file.close()
var json := JSON.new()
var error := json.parse(json_string)
if error != OK:
push_error("JSON parse error at line %d: %s" % [
json.get_error_line(), json.get_error_message()
])
return {}
return json.get_data()
MessagePack: JSON But Smaller and Faster
MessagePack is binary-compatible with JSON's data model but compresses to 50-70% of JSON's size and parses faster. If your save files are large enough that JSON's file size or parse time matters (typically >500 KB), MessagePack is a drop-in improvement.
The tradeoff is readability. You can't open a MessagePack file in a text editor. For development you might use JSON and switch to MessagePack for release builds, or always write a JSON debug dump alongside the MessagePack file.
Libraries exist for both engines. In Godot, the msgpack-gdscript addon works. In Unreal, several C++ MessagePack libraries integrate cleanly.
SQLite: When You Need Random Access
SQLite is overkill for most games. But for games with massive, structured save data — city builders, factory games, grand strategy — it offers something JSON and MessagePack don't: random access queries.
Instead of loading the entire save file into memory to find one piece of data, you can query exactly what you need:
-- Load just the inventory for a specific character
SELECT item_id, quantity, durability FROM inventory WHERE character_id = 'player_1';
-- Load only the chunk the player is currently in
SELECT * FROM world_blocks WHERE chunk_x = 5 AND chunk_y = 12;
-- Get save metadata without loading game state
SELECT save_version, timestamp, play_time FROM save_metadata WHERE id = 1;
This matters when your save file would be tens of megabytes as a flat file. Loading 50 MB of JSON into memory, parsing it, finding the one value you need, and discarding the rest is wasteful. SQLite reads only the pages it needs.
The downside is complexity. You need schema management, migration scripts, and SQLite libraries integrated into your engine. It's real database work, and it's only worth it if your game's data access patterns actually benefit from random access.
Custom Serialization Comparison
| Format | File size | Parse speed | Human readable | Random access | Library needed | Best for |
|---|---|---|---|---|---|---|
| JSON | Large | Moderate | Yes | No | Built-in | Most games |
| MessagePack | Medium | Fast | No | No | Third-party | Large saves, shipping builds |
| SQLite | Medium | Fast (queries) | No (but queryable) | Yes | Third-party | Simulation, sandbox, strategy |
| BSON | Medium | Fast | No | No | Third-party | MongoDB integration |
| FlatBuffers | Small | Very fast | No | Yes (read) | Third-party | Performance-critical, read-heavy |
Versioning and Migration
This is the section everyone skips until their game has been on sale for six months, they ship an update that adds a new feature, and every existing save file breaks. Don't skip this section.
The Problem
Your save format will change. You'll add new fields, remove old ones, rename things, restructure data. Every change risks breaking existing saves. Players who lose their progress leave negative reviews. It's that simple.
Version Numbers
Every save file needs a version number. This is non-negotiable. Write it as the first field in your save data. Check it first when loading.
# Godot migration example
func load_and_migrate(path: String) -> SaveData:
var raw := load_from_json(path)
if raw.is_empty():
return null
var version: int = raw.get("version", 0)
# Apply migrations in sequence
if version < 1:
raw = _migrate_0_to_1(raw)
if version < 2:
raw = _migrate_1_to_2(raw)
if version < 3:
raw = _migrate_2_to_3(raw)
# Now parse the fully migrated data
return _parse_save_data(raw)
func _migrate_0_to_1(data: Dictionary) -> Dictionary:
# Version 0 had health as integer, version 1 uses float
if data.has("player"):
data["player"]["health"] = float(data["player"].get("health", 100))
data["player"]["max_health"] = 100.0 # New field
data["version"] = 1
return data
func _migrate_1_to_2(data: Dictionary) -> Dictionary:
# Version 2 restructured inventory from simple array to object array
if data.has("inventory"):
var old_inv: Array = data["inventory"]
var new_inv := []
for item_id in old_inv:
new_inv.append({
"id": item_id,
"qty": 1,
"durability": 1.0,
})
data["inventory"] = new_inv
data["version"] = 2
return data
func _migrate_2_to_3(data: Dictionary) -> Dictionary:
# Version 3 added quest system — just set defaults
if not data.has("quests"):
data["quests"] = {}
data["version"] = 3
return data
// Unreal migration example
UMySaveGame* MigrateSaveData(UMySaveGame* OldSave)
{
if (OldSave->SaveVersion < 2)
{
// Version 2 added quest system
OldSave->QuestStates.Empty();
// Set default quest states for any in-progress content
}
if (OldSave->SaveVersion < 3)
{
// Version 3 renamed PlayerHealth to CurrentHealth
// Since we can't rename UPROPERTY, we handle this in code
// Old saves will have PlayerHealth populated, CurrentHealth at default
// Apply migration logic here
}
OldSave->SaveVersion = CURRENT_SAVE_VERSION;
return OldSave;
}
Migration Principles
Migrations are sequential, not random. A save at version 1 always goes through migration 1->2, then 2->3, then 3->4. Never skip migrations. Never migrate directly from 1 to 4.
Migrations are additive. Prefer adding new fields with defaults over removing or renaming fields. If you must restructure, copy data to the new structure and leave the old structure in place (it'll be ignored on future loads).
Test migrations with real save files. Keep save files from every version of your game. Your test suite should load each historical save, run migrations, and verify the result. Automated migration tests catch issues before your players do.
Set a support floor. You don't have to support saves from every version forever. Decide a minimum supported version (perhaps two major updates back) and display a clear message for older saves: "This save is from an older version and can't be loaded. Start a new game or roll back to version X."
Handling Corruption
Save corruption is rare but devastating. It happens when a write operation is interrupted (crash, power loss, full disk), when a bug writes invalid data, or when external tools modify the save file incorrectly.
The Write-Rename Pattern
Never write directly to the save file. Instead:
- Write to a temporary file (e.g.,
save_slot_1.tmp) - If the write succeeds, rename the temporary file to the save file
- The rename operation is atomic on most filesystems — it either completes or doesn't
func safe_save(slot: int, data: String) -> Error:
var save_path := SAVE_DIR + "slot_%d.json" % slot
var temp_path := save_path + ".tmp"
var backup_path := save_path + ".bak"
# Write to temp file
var file := FileAccess.open(temp_path, FileAccess.WRITE)
if file == null:
return FileAccess.get_open_error()
file.store_string(data)
file.close()
# Backup existing save
var dir := DirAccess.open(SAVE_DIR)
if dir and dir.file_exists(save_path.get_file()):
dir.rename(save_path.get_file(), backup_path.get_file())
# Rename temp to save
if dir:
var err := dir.rename(temp_path.get_file(), save_path.get_file())
if err != OK:
# Restore backup if rename failed
dir.rename(backup_path.get_file(), save_path.get_file())
return err
return OK
Checksums
Add a checksum to your save data so you can detect corruption on load:
func save_with_checksum(data: Dictionary) -> String:
var payload := JSON.stringify(data)
var checksum := payload.md5_text()
var wrapper := {
"checksum": checksum,
"data": data,
}
return JSON.stringify(wrapper, "\t")
func load_with_checksum(json_string: String) -> Dictionary:
var json := JSON.new()
var err := json.parse(json_string)
if err != OK:
return {} # Parse failure — file is corrupt
var wrapper: Dictionary = json.get_data()
var stored_checksum: String = wrapper.get("checksum", "")
var data: Dictionary = wrapper.get("data", {})
var payload := JSON.stringify(data)
var computed_checksum := payload.md5_text()
if stored_checksum != computed_checksum:
push_error("Save file checksum mismatch — file may be corrupt")
return {} # Or attempt recovery from backup
return data
Recovery Strategy
When corruption is detected:
- Try loading the
.bakbackup file - If backup is also corrupt, try loading any auto-save slots
- If no valid saves exist, show the player a clear error message — don't crash, don't silently start a new game
- Log the corruption details for debugging
Cloud Save Integration
Cloud saves add complexity but solve real problems: players switching devices, reinstalling games, and hardware failures.
Steam Cloud
Steam Cloud is the simplest integration for PC games. It automatically syncs files in a designated directory. You configure which files to sync in the Steamworks dashboard, and Steam handles the rest.
// steamworks app config (simplified)
// Sync all .sav files in the save directory
"cloud" {
"root" "saves"
"path" "remote"
"pattern" "*.sav"
}
The catch: Steam Cloud has a per-user storage quota (typically 1 GB, configurable). If your save files are large, you need to be mindful of total storage across all slots.
Conflict resolution. When saves exist both locally and in the cloud with different timestamps, Steam shows the player a conflict dialog. Your save files should include enough metadata (timestamp, play time, level/chapter) that the player can make an informed choice.
Epic Online Services
EOS provides a Title Storage and Player Data Storage API. It's more flexible than Steam Cloud but requires more code.
Custom Backend
For cross-platform cloud saves (PC + console + mobile), you need a custom backend. The basic architecture is:
- Save locally first (never depend on network availability for saving)
- Upload to your backend asynchronously
- On game start, compare local and cloud timestamps
- If cloud is newer, download and replace local
- If local is newer, upload to cloud
- If timestamps match, do nothing
- If both are newer than last sync (played offline on two devices), show conflict UI
Keep your save files small to minimize upload/download time. Compress before uploading. Use JSON or MessagePack, not engine-specific binary formats, for maximum compatibility.
Common Mistakes
These are the save system bugs we see most often, in order of frequency.
Saving Too Much Data
The most common mistake. Developers serialize their entire game state when they only need a fraction of it. This leads to large files, slow saves, and brittle migration paths. A save file for a 20-hour RPG should typically be 50-500 KB. If yours is megabytes, you're probably saving too much.
Every field in your save file is a field you have to migrate when your format changes. Every field is a potential source of bugs when the game state doesn't match the save data. Fewer fields means fewer bugs.
Not Handling Missing Fields on Load
When you add a new field in version 1.2 and a player loads a version 1.1 save, that field won't exist. If your load code assumes every field exists, it crashes. Always provide defaults for every field, and always check for field existence before reading.
Breaking Saves on Updates
If you restructure your save format without a migration path, every existing save becomes unloadable. Players will find your game's Steam reviews page and tell you about it in detail.
The fix is version numbers and sequential migrations, as described above. Write migration code before you restructure save data, not after. Test with real save files from every previous version.
Synchronous Saves Causing Hitches
Auto-save every 5 minutes is great UX. Auto-save that freezes the game for 200ms every 5 minutes is terrible UX. Use async saves. Test save performance with endgame save files (which are always larger than early-game saves).
Not Testing on Target Platform
Save systems that work perfectly on your development SSD can break on a mechanical hard drive, a Switch SD card, or a Steam Deck. Test save/load on every platform you're shipping on, with realistic save file sizes.
Trusting Save Data
Player-accessible save files will be edited. Save editors will exist. Speedrunners will manipulate saves. Your load code needs to validate every value: clamp health to valid ranges, verify item IDs exist in your database, check that quest states are consistent. Treat save data like untrusted input from the network.
Choosing Your Approach
Here's a decision framework:
Use Godot Resources if you're building in Godot, your save data is simple to moderate, and you don't need save file interop outside the engine. It's the fastest path to a working system.
Use Unreal SaveGame if you're building in Unreal and want the path of least resistance. The built-in async support, platform-aware save paths, and Blueprint integration are valuable. For a Blueprint-first workflow, the Blueprint Template Library extends this with pre-built save infrastructure covering health, inventory, quest progress, and custom key-value data with slot management — so you can focus on what to save rather than how to save it.
Use JSON if you need human-readable saves, cross-engine compatibility, modding support, or cloud save integration with a custom backend.
Use MessagePack if you'd use JSON but your saves are too large or too slow to parse.
Use SQLite if you're building a simulation, sandbox, or strategy game with very large world state and need partial loading.
Most indie games should start with their engine's built-in system and only switch to custom serialization if they hit a specific limitation. The built-in systems handle platform differences, provide async operations, and require minimal boilerplate. That's worth a lot.
A Complete Save System Checklist
Before you ship, verify:
- Save and load work on a fresh install (no leftover dev data)
- Save version number is embedded in every save file
- Missing fields default gracefully on load
- Migration code exists for every version transition
- Save files from previous versions still load correctly
- Corrupted save files don't crash the game
- Backup saves exist (write-rename pattern)
- Auto-save doesn't cause frame hitches (async)
- Save files are tested on every target platform
- Save data values are validated on load (clamped, range-checked)
- Cloud saves handle conflict resolution
- Players can manage save slots (create, load, delete, copy)
- Save metadata (timestamp, play time, chapter) is stored for UI display
- Settings are saved separately from game progress
Get the save system right early. It's not glamorous work, but it's the difference between a game players trust with their time and a game they're afraid to close.