Skip to Content
DocsTinysaveCallbacks & Custom Serialization

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():

  1. SaveManager.BeforeSave event fires
  2. ISaveCallbacks.OnBeforeSave() called on all components
  3. Capture save data
  4. Serialize and write to disk

During LoadAsync():

  1. Read and deserialize save file
  2. Restore data to components
  3. ISaveCallbacks.OnAfterLoad() called on all components
  4. SaveManager.AfterLoad event 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

  1. Keep CaptureState() fast - It runs on the main thread
  2. Return serializable types - Use Dictionary, List, primitives, Unity structs
  3. Validate in RestoreState() - Handle missing or corrupt data gracefully
  4. Use meaningful keys - Dictionary keys should be descriptive
  5. Document your state format - Leave comments explaining the structure
  6. Test with missing data - Ensure RestoreState() handles incomplete data
  7. Avoid circular references - Keep data structures tree-like
  8. 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) { } }

Next Steps

Last updated on