Every game needs to load things from disk — textures, audio clips, item data, whole save files — and turn them into real objects your code can use, then eventually let go of them again. So far in this curriculum almost everything you built lived entirely in memory while the program ran: physics state, animation poses, network messages. This section covers three tightly connected jobs that sit underneath almost everything else in an engine. A resource system decides when a file actually gets read from disk and when it gets thrown away again. Serialization turns objects into bytes or text (and back) so they can be saved, loaded, or sent somewhere else. Reflection is the mechanism that lets an engine's own tools — the Inspector window, the serializer itself — work on any class you write without their authors ever having seen your code. Unity leans on all three constantly, so this section ends with the concrete Unity pieces — ScriptableObject, [SerializeField], and .meta files — that beginners run into first and misunderstand most.
Reading a file from disk is slow compared to almost anything else your code does — slow compared to a function call, slow compared to a loop over an array in memory. If your UI code and your level-loading code both need the same rock texture, and both just call "load this file" whenever they need it, you pay that slow disk read twice for the exact same bytes, and you end up with two separate copies of the same image sitting in memory.
A resource system (also called an asset system) fixes this with a lookup table: the first time something asks for a path, it actually reads the file and remembers the result under that path. Every request after that, for the same path, just hands back the object it already has.
using System;
using System.Collections.Generic;
class ResourceCache<T> where T : class
{
Dictionary<string, T> cache = new Dictionary<string, T>();
public T Load(string path, Func<string, T> loader)
{
if (cache.TryGetValue(path, out T existing))
{
Console.WriteLine($"[cache hit] {path}");
return existing;
}
Console.WriteLine($"[cache miss] {path} -- reading from disk");
T loaded = loader(path);
cache[path] = loaded;
return loaded;
}
}
class Texture
{
public string Path;
public Texture(string path) { Path = path; }
}
class Program
{
static void Main()
{
var cache = new ResourceCache<Texture>();
Texture uiIcon = cache.Load("art/rock.png", p => new Texture(p));
Texture levelRock = cache.Load("art/rock.png", p => new Texture(p));
Console.WriteLine(ReferenceEquals(uiIcon, levelRock) ? "same object" : "different objects");
}
}
Real expected output:
[cache miss] art/rock.png -- reading from disk
[cache hit] art/rock.png
same object
The loader function (the thing that actually reads bytes off disk and builds a Texture) only runs once, on the first call. The second call finds "art/rock.png" already sitting in the dictionary and returns that exact same object. ReferenceEquals confirms it — both variables point at the one Texture in memory, not two separate copies.
Every engine you will ever work with has some version of this: Unity's asset loading, Unreal's asset registry, a custom engine's own texture/mesh managers. The name changes; the idea (a table from a stable key to an already-built object) does not.
The cache from Section 1 has a real problem: it never forgets anything. Every texture ever loaded stays in memory for the rest of the program, whether or not anything still needs it. In a real game, levels get unloaded and reloaded, players open and close inventories full of icons, and holding onto everything forever eventually runs out of memory.
The fix is reference counting: instead of just remembering "is this loaded," the cache remembers how many things currently need it. Every borrower calls Acquire to get the object (which increases the count) and Release when it is done with it (which decreases the count). When the count reaches zero, nobody needs it anymore, so it is safe to actually free the memory.
using System;
using System.Collections.Generic;
class ResourceCache<T> where T : class
{
Dictionary<string, T> cache = new Dictionary<string, T>();
Dictionary<string, int> refCount = new Dictionary<string, int>();
public T Acquire(string path, Func<string, T> loader)
{
if (cache.TryGetValue(path, out T existing))
{
refCount[path]++;
Console.WriteLine($"[acquire] {path} refs={refCount[path]} (reused)");
return existing;
}
T loaded = loader(path); // actually reads the file from disk
cache[path] = loaded;
refCount[path] = 1;
Console.WriteLine($"[acquire] {path} refs=1 (loaded from disk)");
return loaded;
}
public void Release(string path)
{
if (!refCount.ContainsKey(path)) return;
refCount[path]--;
Console.WriteLine($"[release] {path} refs={refCount[path]}");
if (refCount[path] <= 0)
{
cache.Remove(path);
refCount.Remove(path);
Console.WriteLine($"[unload] {path} no users left, memory freed");
}
}
}
class Program
{
static void Main()
{
var textures = new ResourceCache<Texture>();
textures.Acquire("art/rock.png", p => new Texture(p)); // UI system needs it
textures.Acquire("art/rock.png", p => new Texture(p)); // Level system needs it too
textures.Release("art/rock.png"); // UI system is done
textures.Release("art/rock.png"); // Level system is done -- now it unloads
}
}
[acquire] art/rock.png refs=1 (loaded from disk)
[acquire] art/rock.png refs=2 (reused)
[release] art/rock.png refs=1
[release] art/rock.png refs=0
[unload] art/rock.png no users left, memory freed
malloc/free from the C chapters, or new/delete in C++, just counted instead of single-shot: every Acquire must be matched by exactly one Release. Forget a Release and the count never reaches zero — a real memory leak, just one hiding behind a resource cache instead of a raw pointer. Call Release one time too many and the count can go negative, unloading something a different part of the game still thinks it owns.It is tempting to identify an asset by its file path — "Art/Player/hero.png" is a perfectly good, human-readable key. The problem shows up the moment someone renames a folder or moves a file, which happens constantly on a real team: an artist reorganizes the Art folder, a designer renames hero.png to hero_v2.png. Every other asset that stored that literal path string as a reference — a prefab, a scene, a save file — now points at a file that no longer exists there. That is a broken reference, and on a team with thousands of assets, renaming anything becomes terrifying.
The fix used by every serious engine is a GUID (Globally Unique Identifier) — a fixed, effectively-random ID assigned to an asset exactly once, when it is first imported. Other assets store a reference to the GUID, never the raw path. A separate lookup table, maintained by the engine, maps each GUID to that asset's current path. Renaming or moving a file only updates one row of that table; every GUID reference elsewhere in the project keeps resolving correctly, without anyone having to go find and fix it.
using System;
using System.Collections.Generic;
class AssetDatabase
{
// GUID -> current file path -- this table IS the .meta file system, simplified
Dictionary<Guid, string> guidToPath = new Dictionary<Guid, string>();
public Guid Register(string path)
{
Guid id = Guid.NewGuid(); // assigned ONCE, when the asset is first imported
guidToPath[id] = path;
return id;
}
public void Rename(Guid id, string newPath)
{
guidToPath[id] = newPath; // GUID stays the same, only the path column changes
}
public string Resolve(Guid id) => guidToPath[id];
}
class Program
{
static void Main()
{
var db = new AssetDatabase();
Guid heroTextureId = db.Register("Art/Player/hero.png");
// a prefab "remembers" the asset by storing this GUID, not the path
Guid prefabReference = heroTextureId;
Console.WriteLine("Before rename: " + db.Resolve(prefabReference));
db.Rename(heroTextureId, "Art/Characters/hero_v2.png");
Console.WriteLine("After rename: " + db.Resolve(prefabReference));
}
}
Before rename: Art/Player/hero.png
After rename: Art/Characters/hero_v2.png
prefabReference never changes — it is the same GUID the whole time. Looking it up through db.Resolve gives the correct, current path both before and after the rename, because the rename only touched the path column of the table, not the GUID itself.
hero.png's pixels does not touch its GUID at all — only deleting the file (or losing its metadata, covered in Section 10) does.Serialization is the process of converting an object sitting in memory into a sequence of bytes or characters that can be written to a file, sent over a network, or stored in a database. Deserialization is the reverse: reading those bytes or characters back and reconstructing an equivalent object.
using System;
using System.Text.Json;
class PlayerSave
{
public string Name { get; set; }
public int Level { get; set; }
public float[] Position { get; set; }
}
class Program
{
static void Main()
{
var save = new PlayerSave { Name = "Aran", Level = 7, Position = new float[] { 12.5f, 0f, -3.2f } };
string json = JsonSerializer.Serialize(save);
Console.WriteLine(json);
PlayerSave loaded = JsonSerializer.Deserialize<PlayerSave>(json);
Console.WriteLine($"{loaded.Name} is level {loaded.Level}");
}
}
{"Name":"Aran","Level":7,"Position":[12.5,0,-3.2]}
Aran is level 7
Serialize walks over save's properties and writes each one's value in JSON text form. Deserialize parses that text and builds a brand-new PlayerSave object with the same values — a different object in memory than save was, but holding equivalent data.
That last line matters more than it looks. Serialized data can never contain a raw pointer — a memory address from Chapter 2's pointer material. An address is only meaningful inside the one running program that owns that memory; save it to disk and reload it in a new process (or on a different machine entirely) and that number is either garbage or, worse, points at unrelated memory that happens to exist there now. This is exactly why Section 3's GUID system exists: instead of saving a raw address to "the hero texture," a serializer saves a stable ID (a GUID, a path, an index) and looks the real object back up through a resource cache after loading, the same way AssetDatabase.Resolve did.
Serialized data comes in two broad families. Text formats (JSON, YAML, XML) store data as human-readable characters — you can open the file in any text editor and read it. Binary formats store data as raw bytes laid out to match how the values sit in memory or some other compact encoding — you generally need a program (or the right documentation) to make sense of it.
using System;
using System.IO;
using System.Text.Json;
struct SaveDataBinary
{
public int Level { get; set; }
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
}
class Program
{
static byte[] WriteBinary(SaveDataBinary data)
{
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
writer.Write(data.Level); // 4 bytes, no field name stored anywhere
writer.Write(data.X); // 4 bytes
writer.Write(data.Y); // 4 bytes
writer.Write(data.Z); // 4 bytes
return stream.ToArray();
}
static SaveDataBinary ReadBinary(byte[] bytes)
{
using var stream = new MemoryStream(bytes);
using var reader = new BinaryReader(stream);
// fields must be read back in the EXACT same order they were written
return new SaveDataBinary
{
Level = reader.ReadInt32(),
X = reader.ReadSingle(),
Y = reader.ReadSingle(),
Z = reader.ReadSingle()
};
}
static void Main()
{
var data = new SaveDataBinary { Level = 7, X = 12.5f, Y = 0f, Z = -3.2f };
byte[] binary = WriteBinary(data);
string json = JsonSerializer.Serialize(data);
Console.WriteLine($"binary size: {binary.Length} bytes");
Console.WriteLine($"json size: {json.Length} chars -- \"{json}\"");
SaveDataBinary back = ReadBinary(binary);
Console.WriteLine($"read back: Level={back.Level} pos=({back.X},{back.Y},{back.Z})");
}
}
binary size: 16 bytes
json size: 35 chars -- "{"Level":7,"X":12.5,"Y":0,"Z":-3.2}"
read back: Level=7 pos=(12.5,0,-3.2)
Sixteen bytes for the binary form (four fields, four bytes each, no names stored anywhere — the reader just knows the order). Thirty-five characters for the JSON form, because it spells out "Level", "X", "Y", "Z" as literal text every single time, plus punctuation. Notice that ReadBinary has no idea what a "Level" or an "X" even is — it just trusts that whatever wrote the bytes wrote them in the same order it is about to read them in. That fragility is the actual price of binary's speed and size, and it is exactly what Section 6 has to solve for.
Here is the scenario that breaks more shipped games than almost anything else in this whole area: patch 1.0 ships with a save format. Patch 1.1 adds one new field to that format. Every player who saved a game under 1.0 now has a file on disk that is missing that field entirely — and the game needs to keep loading it correctly forever, because you cannot go back in time and re-save everyone's old files.
Say v1.0 shipped SaveData with Name and Level. Patch 1.1 adds Stamina. If the game just calls JsonSerializer.Deserialize<SaveData>(oldJson) on a 1.0-era file, the JSON deserializer does not crash — it quietly leaves any field missing from the file at that type's default value. For an int, that default is 0. So every returning player silently has Stamina = 0 the moment they load, whether or not that is a sensible value — here it means every existing player is now permanently exhausted, with no error, no crash, and no obvious cause in a bug report. This is the single most common shipping bug in this entire area: not a crash, a silent, wrong default that looks like a gameplay bug until someone remembers the save format changed.
The fix is to store a Version number inside the save data itself, and, on load, check it: if the file's version is older than the game's current version, run a small function that fills in correct values for whatever changed, then mark the data as the current version before anything else touches it.
using System;
using System.Text.Json;
class SaveData
{
public int Version { get; set; }
public string PlayerName { get; set; }
public int Level { get; set; }
public int Stamina { get; set; } // added in version 2 -- default should NOT be 0
}
class SaveLoader
{
const int CurrentVersion = 2;
public static SaveData Load(string json)
{
SaveData data = JsonSerializer.Deserialize<SaveData>(json);
if (data.Version < 2)
data = MigrateV1ToV2(data);
// future patches add more steps here, e.g.:
// if (data.Version < 3) data = MigrateV2ToV3(data);
data.Version = CurrentVersion;
return data;
}
static SaveData MigrateV1ToV2(SaveData data)
{
Console.WriteLine("migrating save from v1 to v2...");
data.Stamina = 100; // v1 saves had no Stamina -- give a sensible starting value
return data;
}
}
class Program
{
static void Main()
{
string oldSaveFromDisk = "{\"Version\":1,\"PlayerName\":\"Aran\",\"Level\":7}";
SaveData loaded = SaveLoader.Load(oldSaveFromDisk);
Console.WriteLine($"{loaded.PlayerName}: level {loaded.Level}, stamina {loaded.Stamina}, version {loaded.Version}");
}
}
migrating save from v1 to v2...
Aran: level 7, stamina 100, version 2
The important part is that MigrateV1ToV2 chooses 100 on purpose — a value someone decided made sense for a returning player — instead of silently accepting whatever default the deserializer happened to pick. As the save format keeps growing across patches, each new field gets its own migration step, chained in order, so a save file from three years and ten patches ago still walks correctly, one step at a time, up to whatever the current version expects.
Reflection is a program's ability to look at its own types — their fields, methods, and attributes — while it is running, instead of everything being fixed and named at compile time. Ordinary code has to know a field's name to use it: stats.Health only compiles if Health is a real field on stats's type. Reflection code instead asks the type itself, as data, "what fields do you have?" and can then read or write any of them by name, discovered at runtime.
using System;
using System.Reflection;
class EnemyStats
{
public string Name = "Goblin";
public int Health = 30;
public float MoveSpeed = 3.5f;
}
class Program
{
static void Main()
{
EnemyStats stats = new EnemyStats();
Type type = stats.GetType(); // ask the OBJECT what type it is, at runtime
Console.WriteLine($"Type name: {type.Name}");
foreach (FieldInfo field in type.GetFields())
{
object value = field.GetValue(stats); // read a field WITHOUT knowing its name at compile time
Console.WriteLine($" {field.Name} ({field.FieldType.Name}) = {value}");
}
}
}
Type name: EnemyStats
Name (String) = Goblin
Health (Int32) = 30
MoveSpeed (Single) = 3.5
type.GetFields() returns a list of FieldInfo objects — one per field on EnemyStats — discovered by looking at the class itself, not by anyone writing "Name", "Health", "MoveSpeed" as literal strings anywhere. field.GetValue(stats) then reads that field's actual value off the specific stats object. Nothing in Program needed to know EnemyStats's shape ahead of time — this exact loop works unchanged on any class at all.
Section 7's loop — "get every field, read its name and value" — is not just a curiosity. It is the one mechanism underneath three things every engine needs, all of which would otherwise require hand-written code for every single class you ever create:
int, a checkbox for every bool, a text box for every string, for any class, works by reflecting over the class's fields and picking a widget per field's Type — not by someone writing custom UI code for every class in the game.JsonSerializer.Serialize is doing exactly this internally.FieldInfo.SetValue.Reflection alone would show every field, though, including ones that should stay purely internal. Engines solve that with attributes — small pieces of metadata attached to code that other code can discover through reflection. Unity's own [SerializeField] (Section 10) is exactly this kind of attribute. Here is a small version of the same idea:
using System;
using System.Reflection;
[AttributeUsage(AttributeTargets.Field)]
class ShowInInspectorAttribute : Attribute { }
class EnemySpawner
{
public int MaxEnemies = 10; // public -- shown automatically
[ShowInInspector]
private float spawnRadius = 5.0f; // private, but explicitly marked -- shown too
private string internalCacheKey = "x1"; // private, not marked -- hidden
}
class MiniInspector
{
public static void Draw(object target)
{
Type type = target.GetType();
Console.WriteLine($"--- Inspector: {type.Name} ---");
foreach (FieldInfo field in type.GetFields(BindingFlags.Public | BindingFlags.Instance))
Console.WriteLine($"{field.Name} = {field.GetValue(target)}");
foreach (FieldInfo field in type.GetFields(BindingFlags.NonPublic | BindingFlags.Instance))
if (field.GetCustomAttribute<ShowInInspectorAttribute>() != null)
Console.WriteLine($"{field.Name} = {field.GetValue(target)} (private, marked)");
}
}
class Program
{
static void Main() => MiniInspector.Draw(new EnemySpawner());
}
--- Inspector: EnemySpawner ---
MaxEnemies = 10
spawnRadius = 5 (private, marked)
internalCacheKey never appears — it is private and carries no [ShowInInspector] attribute, so both loops skip it. This tiny rule, "public fields, plus private fields explicitly marked with an attribute," is not a coincidence: it is precisely the rule Unity's real Inspector and serializer use, which Section 10 covers directly.
A MonoBehaviour lives on a GameObject in a scene — it needs a scene to exist at all. A lot of game data does not belong to any one scene: an item's name and stats, an enemy type's base stats, global game settings. Unity's ScriptableObject is a base class for data that lives as its own asset file on disk, independent of any scene, that any number of scenes and objects can reference.
using UnityEngine;
[CreateAssetMenu(fileName = "NewItem", menuName = "Game/Item Definition")]
public class ItemDefinition : ScriptableObject
{
public string ItemName;
public int MaxStack = 99;
public Sprite Icon;
}
[CreateAssetMenu] adds a "Create > Game > Item Definition" entry to Unity's editor menu, letting a designer create new .asset files — Sword.asset, HealthPotion.asset — entirely by hand, with no code, each one a separately saved set of values for this class's fields.
using UnityEngine;
public class ItemPickup : MonoBehaviour
{
public ItemDefinition Item; // drag an ItemDefinition asset here in the Inspector
void OnTriggerEnter(Collider other)
{
Debug.Log($"Picked up {Item.ItemName} (max stack {Item.MaxStack})");
}
}
Many different ItemPickup objects, scattered across many different scenes, can all point at the exact same Sword.asset. This connects directly back to Sections 1 through 3: Unity loads Sword.asset once, every reference shares that one loaded object (Section 1 and 2's resource cache, effectively, built into the engine), and each reference is tracked by GUID (Section 3), so renaming Sword.asset to IronSword.asset does not break a single ItemPickup that references it.
Unity's own serializer is, underneath, Section 8's reflect-over-fields loop with one specific rule for which fields count. A field gets serialized (saved into the scene or asset file, and shown in the default Inspector) only if all of these hold:
public, or it is private/protected and marked with [SerializeField];static, not const, and not readonly;Unity.Object references (like ItemDefinition or Sprite), enums, arrays/List<T> of a serializable type, or a plain class/struct marked [System.Serializable].using UnityEngine;
using System.Collections.Generic;
public class EnemyStatsComponent : MonoBehaviour
{
public int Health = 30; // public -- serialized, shown
[SerializeField]
private float moveSpeed = 3.5f; // private + [SerializeField] -- serialized, shown
private int internalFrameCounter; // private, no attribute -- NOT serialized, NOT shown
public static int EnemyCount; // static -- NEVER serialized
public int Damage { get; set; } = 5; // a PROPERTY -- NOT serialized, NOT shown
public Dictionary<string, int> Resistances; // Dictionary -- NOT serialized (no built-in support)
}
Two of these lines catch almost every beginner at least once. Damage looks exactly like a field from the outside — enemy.Damage = 10; compiles and works fine — but it is a property (a pair of hidden get/set methods), and Unity's serializer only walks real fields, not properties. This is the opposite of Section 4's System.Text.Json, which by default reads properties, not fields — two different reflection-based systems, two different rules for "what counts." Resistances compiles fine too, but Unity has no built-in serializer for Dictionary<TKey,TValue>, so it silently shows up empty in the Inspector and does not save. The common workaround is a list of a small serializable struct instead:
[System.Serializable]
public struct StatResistance
{
public string StatName;
public int Amount;
}
public class EnemyStatsComponent : MonoBehaviour
{
public List<StatResistance> Resistances; // works: a List of a [Serializable] struct
}
[SerializeField], non-static, non-const, non-readonly, and a supported type — not "any field I declared." A property with identical outside behavior will not appear, which is exactly the gap between "compiles" and "Unity actually serializes it" that trips up developers moving from general C# style toward Unity's field-based model.Section 3 described a GUID lookup table without saying where Unity actually keeps it: in a meta file. Every asset in a Unity project — a texture, a script, a folder, a ScriptableObject asset — gets a matching file next to it with .meta appended to the name, generated and maintained by Unity itself.
.gitignore-ing .meta files, or renaming/moving assets outside Unity's own Project window (in Finder or File Explorer directly). Both regenerate GUIDs and silently break every reference to that asset throughout the project. Always commit .meta files to source control, and always rename/move assets from inside Unity.While a game runs in the editor (or a development build with asset watching turned on), editing a source asset — touching up a texture in an image editor and saving, or tweaking numbers on a ScriptableObject in the Inspector — should update the running game's in-memory copy without a full restart. This is hot reloading, and it matters purely for iteration speed: an artist wants to see a texture change in seconds, not by relaunching the whole game and re-navigating back to the same spot.
The key idea only works because of Section 1 and 2's design: the resource cache hands out a shared reference, not a private copy, to every caller. That means hot reloading only has to overwrite the contents of the one cached object in place — every holder of that reference automatically sees the new data, because they were never holding their own copy to begin with.
using System.Reflection;
partial class ResourceCache<T> where T : class
{
public void Reload(string path, T freshData)
{
if (!cache.TryGetValue(path, out T existing))
return; // nothing cached yet, nothing to reload
CopyInto(existing, freshData); // overwrite the EXISTING object's contents in place
System.Console.WriteLine($"[hot reload] {path} updated -- {refCount[path]} holder(s) see new data instantly");
}
static void CopyInto(object existing, object fresh)
{
// the same reflect-over-fields loop from Section 7 and 8, used to copy generically
foreach (FieldInfo field in existing.GetType().GetFields())
field.SetValue(existing, field.GetValue(fresh));
}
}
CopyInto needs no per-type code at all: it reflects over whatever fields existing's type actually has and copies each one across from fresh, the same generic trick Section 7's field-printing loop and Section 8's MiniInspector both used.
System.Type in C#) that describes a class: its name, fields, methods, and attributes.[SerializeField]) that other code can discover through reflection at runtime.GameObject.ResourceCache<Texture> (Section 2), does some work with it, and releases it. Find the path through this function that never calls Release, explain what actually happens to the refcount because of it, and rewrite the function so every possible path releases exactly once, even if DoWork throws an exception.
void UseTextureTemporarily(ResourceCache<Texture> cache, string path, bool skipWork)
{
Texture tex = cache.Acquire(path, Loader.LoadTexture);
if (skipWork)
return; // uh oh
DoWork(tex);
cache.Release(path);
}
When skipWork is true, the function returns immediately after Acquire, before ever reaching cache.Release(path). That call path increments the refcount and never decrements it. Every time this happens, the count is permanently one higher than the true number of active users — the asset can never reach zero and unload, even long after nothing in the game actually needs it anymore. This is a resource leak with exactly the same shape as forgetting a matching free() after a malloc() in the C chapters: an early return that skips the cleanup code below it.
The fix is a try/finally block, which guarantees the finally section runs on every exit path out of the try — a normal return, an early return, or an exception thrown by DoWork:
void UseTextureTemporarily(ResourceCache<Texture> cache, string path, bool skipWork)
{
Texture tex = cache.Acquire(path, Loader.LoadTexture);
try
{
if (skipWork)
return;
DoWork(tex);
}
finally
{
cache.Release(path); // runs no matter which path we took, or even if DoWork throws
}
}
Now there is exactly one Acquire and exactly one Release on every possible route through the function, which is the invariant a reference-counted cache depends on to ever unload anything.
Stamina) to v2 by adding Stamina with a default of 100. The game now needs a v3: a new Difficulty field (int, 0 = Normal, 1 = Hard), which should default to 1 for any character already at Level 20 or higher when they are first migrated, and 0 otherwise. Add Difficulty to SaveData, write MigrateV2ToV3, update SaveLoader.Load to run both migration steps in order, and trace what happens when a v1 file for a level-25 character is loaded.
string oldSaveFromDisk = "{\"Version\":1,\"PlayerName\":\"Rin\",\"Level\":25}";
using System;
using System.Text.Json;
class SaveData
{
public int Version { get; set; }
public string PlayerName { get; set; }
public int Level { get; set; }
public int Stamina { get; set; } // added in version 2
public int Difficulty { get; set; } // added in version 3
}
class SaveLoader
{
const int CurrentVersion = 3;
public static SaveData Load(string json)
{
SaveData data = JsonSerializer.Deserialize<SaveData>(json);
if (data.Version < 2)
data = MigrateV1ToV2(data);
if (data.Version < 3)
data = MigrateV2ToV3(data);
data.Version = CurrentVersion;
return data;
}
static SaveData MigrateV1ToV2(SaveData data)
{
Console.WriteLine("migrating save from v1 to v2...");
data.Stamina = 100;
return data;
}
static SaveData MigrateV2ToV3(SaveData data)
{
Console.WriteLine("migrating save from v2 to v3...");
data.Difficulty = data.Level >= 20 ? 1 : 0; // Hard for already-strong characters
return data;
}
}
class Program
{
static void Main()
{
string oldSaveFromDisk = "{\"Version\":1,\"PlayerName\":\"Rin\",\"Level\":25}";
SaveData loaded = SaveLoader.Load(oldSaveFromDisk);
Console.WriteLine($"{loaded.PlayerName}: level {loaded.Level}, stamina {loaded.Stamina}, difficulty {loaded.Difficulty}, version {loaded.Version}");
}
}
migrating save from v1 to v2...
migrating save from v2 to v3...
Rin: level 25, stamina 100, difficulty 1, version 3
The file's Version field starts at 1, so both migration checks fire in order: MigrateV1ToV2 runs first and fills in Stamina, then MigrateV2ToV3 runs against that already-updated data and sets Difficulty based on Level (which was 25, so Difficulty becomes 1, Hard). A save that started at v2 instead would skip straight to the second check only, and a save already at v3 would skip both — each step only runs for the versions that actually need it.
type.GetFields()), write a function string ToKeyValueText(object obj) that converts any object's public fields into text, one Name=Value pair per line — for example, an EnemyStats object with Name="Goblin", Health=30 should produce the text Name=Goblin followed by Health=30 on the next line. You do not need to handle nested objects or arrays. Then, in one or two sentences, explain why this function needs zero changes if someone adds a brand-new field to EnemyStats next month, and why that same fact is also true of Unity's Inspector and of JsonSerializer.Serialize.using System;
using System.Reflection;
using System.Text;
static class MiniSerializer
{
public static string ToKeyValueText(object obj)
{
var sb = new StringBuilder();
foreach (FieldInfo field in obj.GetType().GetFields())
sb.Append($"{field.Name}={field.GetValue(obj)}\n");
return sb.ToString();
}
}
class EnemyStats
{
public string Name = "Goblin";
public int Health = 30;
}
class Program
{
static void Main()
{
Console.Write(MiniSerializer.ToKeyValueText(new EnemyStats()));
}
}
Name=Goblin
Health=30
ToKeyValueText never names Name or Health in its own source code — it asks obj.GetType() for whatever fields exist right now and loops over that list. If someone adds a MoveSpeed field to EnemyStats next month, the very next call to ToKeyValueText includes a MoveSpeed=... line automatically, with no line of MiniSerializer ever needing to change. That is exactly why Unity's Inspector shows a new [SerializeField] field the moment it is added, and why JsonSerializer.Serialize handles any class at all without per-class code: all three are the same reflect-over-fields loop from Section 7, wearing different clothes.