Callbacks & Custom Serialization
TinySave provides two interfaces for advanced save/load control: ISaveCallbacks for lifecycle hooks and ISaveCustom for complete serialization control.
ISaveCallbacks
Get notified when saves and loads occur.
Implement ISaveCallbacks to execute code before saves and after loads. This is perfect for preparing data, updating UI, or initializing systems.
Interface Definition
public interface ISaveCallbacks
{
void OnBeforeSave();
void OnAfterLoad();
}Basic Usage
using UnityEngine;
using TinySave.Runtime;
public class PlayerStats : MonoBehaviour, ISaveCallbacks
{
[SaveField]
private int health;
[SaveField]
private Vector3 savedPosition;
public void OnBeforeSave()
{
// Called before saving - prepare data
savedPosition = transform.position;
Debug.Log("Preparing player data for save");
}
public void OnAfterLoad()
{
// Called after loading - restore state
transform.position = savedPosition;
Debug.Log("Player data loaded");
}
}Example: Saving Transform Data
Since Transform is a built-in Unity component, you can’t mark it with [SaveField]. Use ISaveCallbacks instead:
public class SaveTransform : MonoBehaviour, ISaveCallbacks
{
[SaveField] private Vector3 savedPosition;
[SaveField] private Quaternion savedRotation;
[SaveField] private Vector3 savedScale;
public void OnBeforeSave()
{
savedPosition = transform.position;
savedRotation = transform.rotation;
savedScale = transform.localScale;
}
public void OnAfterLoad()
{
transform.position = savedPosition;
transform.rotation = savedRotation;
transform.localScale = savedScale;
}
}Example: Updating UI
public class HealthBar : MonoBehaviour, ISaveCallbacks
{
[SaveField]
private float currentHealth;
[SerializeField]
private Slider healthSlider;
public void OnBeforeSave()
{
// Store current health value
Debug.Log($"Saving health: {currentHealth}");
}
public void OnAfterLoad()
{
// Update UI to match loaded data
healthSlider.value = currentHealth;
Debug.Log($"Loaded health: {currentHealth}");
}
}Example: Reinitializing Systems
public class EnemySpawner : MonoBehaviour, ISaveCallbacks
{
[SaveField]
private List<string> spawnedEnemyIds;
[SaveField]
private int waveNumber;
public void OnBeforeSave()
{
// Collect data about spawned enemies
spawnedEnemyIds = new List<string>();
foreach (var enemy in FindObjectsByType<Enemy>(FindObjectsSortMode.None))
{
spawnedEnemyIds.Add(enemy.GetComponent<SaveID>().ID);
}
}
public void OnAfterLoad()
{
// Reinitialize spawner based on loaded data
Debug.Log($"Resumed at wave {waveNumber}");
Debug.Log($"Restored {spawnedEnemyIds.Count} enemies");
// You might need to recreate enemy references or validate state
ValidateSpawnedEnemies();
}
private void ValidateSpawnedEnemies()
{
// Custom logic to ensure consistency
}
}Execution Order
Callbacks are called in this order during save/load:
During SaveAsync():
SaveManager.BeforeSaveevent firesISaveCallbacks.OnBeforeSave()called on all components- Capture save data
- Serialize and write to disk
During LoadAsync():
- Read and deserialize save file
- Restore data to components
ISaveCallbacks.OnAfterLoad()called on all componentsSaveManager.AfterLoadevent fires
ISaveCustom
Take full control of serialization for complex data structures.
When [SaveField] isn’t flexible enough, implement ISaveCustom to manually serialize and deserialize your data. This gives you complete control over what gets saved and how.
Interface Definition
public interface ISaveCustom
{
object CaptureState();
void RestoreState(object state);
}Basic Usage
using UnityEngine;
using TinySave.Runtime;
using System.Collections.Generic;
public class CustomData : MonoBehaviour, ISaveCustom
{
public int value1;
public string value2;
private float secretValue; // Won't be saved by [SaveField]
public object CaptureState()
{
// Return any serializable object
return new Dictionary<string, object>
{
{ "value1", value1 },
{ "value2", value2 },
{ "secret", secretValue }
};
}
public void RestoreState(object state)
{
// Cast and restore data
var dict = state as Dictionary<string, object>;
value1 = (int)dict["value1"];
value2 = (string)dict["value2"];
secretValue = (float)dict["secret"];
}
}Example: Chest with Custom State
using UnityEngine;
using TinySave.Runtime;
using System.Collections.Generic;
public class Chest : MonoBehaviour, ISaveCustom
{
public bool isOpen;
public List<string> contents;
public float openTime;
public object CaptureState()
{
return new Dictionary<string, object>
{
{ "isOpen", isOpen },
{ "contents", contents },
{ "openTime", Time.time }
};
}
public void RestoreState(object state)
{
var dict = state as Dictionary<string, object>;
isOpen = (bool)dict["isOpen"];
contents = dict["contents"] as List<string>;
openTime = (float)dict["openTime"];
// Update visual state
UpdateChestVisual();
}
private void UpdateChestVisual()
{
// Animate chest lid based on isOpen
}
}Example: Quest System
public class QuestManager : MonoBehaviour, ISaveCustom
{
private Dictionary<string, QuestData> activeQuests;
private List<string> completedQuests;
[System.Serializable]
public class QuestData
{
public string questId;
public int progress;
public bool isComplete;
public List<string> objectives;
}
public object CaptureState()
{
var questList = new List<QuestData>(activeQuests.Values);
return new Dictionary<string, object>
{
{ "active", questList },
{ "completed", completedQuests }
};
}
public void RestoreState(object state)
{
var dict = state as Dictionary<string, object>;
var questList = dict["active"] as List<QuestData>;
activeQuests = new Dictionary<string, QuestData>();
foreach (var quest in questList)
{
activeQuests[quest.questId] = quest;
}
completedQuests = dict["completed"] as List<string>;
// Notify UI
RefreshQuestUI();
}
private void RefreshQuestUI() { }
}Example: Procedural Generation State
public class ProceduralLevel : MonoBehaviour, ISaveCustom
{
private int seed;
private List<RoomData> generatedRooms;
private List<string> unlockedDoors;
[System.Serializable]
public class RoomData
{
public int roomId;
public Vector3 position;
public List<int> connectedRooms;
public bool isCleared;
}
public object CaptureState()
{
return new Dictionary<string, object>
{
{ "seed", seed },
{ "rooms", generatedRooms },
{ "doors", unlockedDoors }
};
}
public void RestoreState(object state)
{
var dict = state as Dictionary<string, object>;
seed = (int)dict["seed"];
generatedRooms = dict["rooms"] as List<RoomData>;
unlockedDoors = dict["doors"] as List<string>;
// Regenerate level from saved state
RegenerateLevelFromSave();
}
private void RegenerateLevelFromSave()
{
// Recreate level geometry based on saved room data
}
}Example: Complex Inventory
public class AdvancedInventory : MonoBehaviour, ISaveCustom
{
private Dictionary<string, ItemStack> itemStacks;
private int gold;
private List<EquipmentSlot> equipment;
[System.Serializable]
public class ItemStack
{
public string itemId;
public int quantity;
public Dictionary<string, float> modifiers;
}
[System.Serializable]
public class EquipmentSlot
{
public string slotType; // "weapon", "armor", "accessory"
public ItemStack item;
}
public object CaptureState()
{
// Convert dictionary to list for serialization
var itemList = new List<ItemStack>(itemStacks.Values);
return new Dictionary<string, object>
{
{ "items", itemList },
{ "gold", gold },
{ "equipment", equipment }
};
}
public void RestoreState(object state)
{
var dict = state as Dictionary<string, object>;
// Rebuild dictionary from list
var itemList = dict["items"] as List<ItemStack>;
itemStacks = new Dictionary<string, ItemStack>();
foreach (var item in itemList)
{
itemStacks[item.itemId] = item;
}
gold = (int)dict["gold"];
equipment = dict["equipment"] as List<EquipmentSlot>;
UpdateInventoryUI();
}
private void UpdateInventoryUI() { }
}Mixing ISaveCallbacks and ISaveCustom
You can implement both interfaces on the same component:
public class ComplexSystem : MonoBehaviour, ISaveCallbacks, ISaveCustom
{
private Dictionary<string, object> runtimeData;
// ISaveCustom
public object CaptureState()
{
return new Dictionary<string, object>(runtimeData);
}
public void RestoreState(object state)
{
runtimeData = state as Dictionary<string, object>;
}
// ISaveCallbacks
public void OnBeforeSave()
{
Debug.Log("Preparing complex system data");
// Pre-save validation or cleanup
}
public void OnAfterLoad()
{
Debug.Log("Complex system data loaded");
// Post-load initialization
InitializeFromLoadedData();
}
private void InitializeFromLoadedData() { }
}When to Use Each Interface
Use ISaveCallbacks when:
- You need to prepare data before saving (copy Transform, calculate values)
- You need to reinitialize systems after loading (update UI, restart coroutines)
- You’re using
[SaveField]but need lifecycle hooks - You want to perform validation or cleanup
Use ISaveCustom when:
- Your data structure is too complex for
[SaveField] - You need to save dictionaries, hash sets, or other collections
- You want to control exactly what gets serialized
- You need to transform data before saving (compression, encryption, filtering)
- You’re working with runtime-generated or procedural content
Use Both when:
- You need custom serialization AND lifecycle hooks
- You want initialization logic separate from serialization logic
Best Practices
- Keep CaptureState() fast - It runs on the main thread
- Return serializable types - Use Dictionary, List, primitives, Unity structs
- Validate in RestoreState() - Handle missing or corrupt data gracefully
- Use meaningful keys - Dictionary keys should be descriptive
- Document your state format - Leave comments explaining the structure
- Test with missing data - Ensure RestoreState() handles incomplete data
- Avoid circular references - Keep data structures tree-like
- Version your state - Include a version number for future migrations
Common Patterns
Pattern: Conditional Saving
public class ConditionalSave : MonoBehaviour, ISaveCallbacks
{
[SaveField] private int importantValue;
private bool hasUnsavedChanges;
public void OnBeforeSave()
{
if (hasUnsavedChanges)
{
Debug.Log("Saving changes");
hasUnsavedChanges = false;
}
}
public void OnAfterLoad()
{
hasUnsavedChanges = false;
}
}Pattern: State Compression
public class CompressedState : MonoBehaviour, ISaveCustom
{
public List<int> largeDataset;
public object CaptureState()
{
// Only save non-zero values
var compressed = new Dictionary<int, int>();
for (int i = 0; i < largeDataset.Count; i++)
{
if (largeDataset[i] != 0)
compressed[i] = largeDataset[i];
}
return compressed;
}
public void RestoreState(object state)
{
var compressed = state as Dictionary<int, int>;
largeDataset = new List<int>(new int[1000]); // Initialize with zeros
foreach (var kvp in compressed)
{
largeDataset[kvp.Key] = kvp.Value;
}
}
}Pattern: Version Migration
public class VersionedState : MonoBehaviour, ISaveCustom
{
private const int CurrentVersion = 2;
public object CaptureState()
{
return new Dictionary<string, object>
{
{ "version", CurrentVersion },
{ "data", GetCurrentData() }
};
}
public void RestoreState(object state)
{
var dict = state as Dictionary<string, object>;
int version = dict.ContainsKey("version") ? (int)dict["version"] : 1;
if (version == 1)
{
MigrateFromV1(dict);
}
else if (version == 2)
{
RestoreV2Data(dict["data"]);
}
}
private object GetCurrentData() { return null; }
private void MigrateFromV1(Dictionary<string, object> data) { }
private void RestoreV2Data(object data) { }
}