24 Core Utilities – Serialization

50 min read

JSON and tagged binary serialization with custom serializers, reference tracking, and schema migration – the layer that turns runtime game state into save files that survive format changes between patches.

Summary

Scylla’s serialization utilities turn game state into serialized data you can store on disk or send over a network, and back again. Hand them an object and they produce either JSON, which is human-readable and easy to inspect and diff, or a compact tagged binary stream that is smaller and faster to parse and is meant to be compressed and encrypted rather than read by hand. Feed that result back in later, and the original object comes back rebuilt. Unity’s own value types, nested objects, collections, and project-specific classes all travel through the same path.

The part that earns its keep is what happens between patches. A save file written by version 1.0 of a game has to keep loading after the type it describes has gained fields, lost others, and renamed the rest. Each serializable type carries a schema version, and small upgrade methods carry old data forward, either one version at a time or in a single jump, so a save from three patches ago loads into today’s types without the player noticing. The names used on disk are decoupled from the C# identifiers, so renaming a field in code does not invalidate existing saves. Object graphs where several owners share one instance, or where objects point back at each other in cycles, are written once and come back with their identity intact. Any type whose default treatment is wrong can be given a dedicated serializer that controls its exact shape.

Two things make it something a project sets up once and stops thinking about. Failures are returned rather than thrown, so a corrupt or outdated save is a branch in the calling code rather than a crash that reaches the player. And the system avoids the runtime code generation that breaks ahead-of-time compilation, so the same call produces the same result on every Unity target, from desktop down to IL2CPP mobile. The binary format is little-endian on every platform, so bytes written on one machine read back correctly on another.

Use the serialization utilities for:

  • Save game files and per-session snapshots, especially when combined with Compression and Crypto for opaque save data.
  • Configuration files that round-trip cleanly between a JSON representation on disk and a typed structure in memory (see Config Files for the higher-level config layer).
  • Inter-process or networked data exchange where both ends need typed deserialization.
  • Schema-versioned content that must keep loading correctly as the type structure evolves over patches.
  • Object graphs with shared references and back-references, such as entity trees where children know their parent.
  • Custom on-disk formats, via direct low-level reader and writer access for both JSON and binary.

Features

  • ScyllaSerialization facade. One static class with ToJSON<T>, FromJSON<T>, ToBytes<T>, and FromBytes<T> covers every typical entry point. Settings-less convenience overloads for the simple cases; settings-driven overloads for everything else.
  • SerializationResult<T> no-throw contract. Every operation returns a result struct with IsSuccess/IsFailure, an ErrorMessage, and an optional Exception. Reading .Value on a failure throws, so you choose the strict-vs-defensive shape. TryGetValue(out T) and GetValueOrDefault(d) cover the rest.
  • Three settings presets plus fluent builder. SerializationSettings.Default, ReadableJSON (indented JSON), CompactBinary (binary format, null and default values omitted); CreateBuilder() and ToBuilder() for custom instances. Settings are immutable after Build.
  • Five attribute-driven configuration markers. [ScyllaSerializable(version)], [ScyllaProperty(Name, Order, Required)], [ScyllaIgnore], [ScyllaInclude], [ScyllaMigration(from, to)]. Cover every type-level customization without per-property serializer code.
  • Schema versioning with migration paths. [ScyllaSerializable(version: N)] writes a $version field; [ScyllaMigration(fromVersion, toVersion)] marks static migration methods. On load the engine finds the shortest chain of migrations from the stored version to the current one, which can mix single steps (v1 -> v2) with direct jumps (v1 -> v3). A version gap that no chain bridges fails the load with an error instead of loading unmigrated data.
  • Reference tracking for cycles and shared subgraphs. ReferenceHandling.Preserve, the default, writes each shared object once with an explicit $id and points every later occurrence at it with $ref. Because the ID is explicit, a $ref resolves correctly whatever order the reader visits properties in. EmitReferenceIDs turns the $id marker off for consumers that expect the older shape.
  • Type and version markers. A value whose runtime type differs from its declared type (a Weapon in an Item field, anything in an object or interface field) always carries a $type marker, and the properties of its runtime type are written. With IncludeTypeInfo on (the default), every other reflection-serialized object carries the marker too. The marker is the type’s TypeAlias when it declares one, otherwise its assembly-qualified name. The marker names are public constants (REF_ID_PROPERTY_NAME, REF_PROPERTY_NAME) so external tools can recognize $id and $ref without hardcoding the strings.
  • Custom type serializers. ITypeSerializer<T> / TypeSerializerBase<T> give full control over how a specific type round-trips. Register globally via ScyllaSerialization.RegisterSerializer<T> or per-call via the settings builder.
  • Two output formats. JSON for human-readable on-disk and over-the-wire use; a tagged binary stream (SerializationFormat.Binary) for save files and packets. Every binary value is preceded by a one-byte BinaryTypeTag, multi-byte values are little-endian on every platform, and NaN and the infinities are stored as their native bit patterns. Same call shape; pick the format through SerializationSettings.Format.
  • NullHandling and DefaultValueHandling. Include and Ignore modes let you decide whether null values and default values are written. Smaller output for “ignore” cases; exact round trips for “include everything” cases.
  • Pre-warmable metadata cache. TypeMetadataCache.PreWarm(types) removes the first-use reflection cost from a hot path. Save game writes can warm the cache during a loading screen; subsequent saves never pay the per-type reflection.
  • Low-level readers and writers. IScyllaReader / IScyllaWriter expose a token-stream model with two implementations each: ScyllaJSONReader / ScyllaJSONWriter and ScyllaBinaryReader / ScyllaBinaryWriter. Reach for these inside custom serializers or when your project needs a hand-rolled format.
  • IL2CPP-friendly. No System.Reflection.Emit. The reflection serializer uses straightforward reflection that AOT compilers preserve. Works on every Unity build target without link.xml gymnastics.

Components

The public surface splits across a single facade, two result struct types, one settings class, three subsystem layers (serializers, readers/writers, metadata), and five attributes. Knowing what each one is for keeps the recipe sections short and predictable.

A handful of terms come up repeatedly in this section. Schema version is an integer on [ScyllaSerializable] that the serializer writes into the output as a $version field. When the version on a stored blob is older than the type’s current version, the engine runs migration methods to upgrade the data. Reference tracking is the bookkeeping the engine does to detect cycles and shared subgraphs; without it, two parents pointing at the same child would serialize the child twice, and a parent pointing at itself would recurse forever. Type marker is the $type field that names the type an object was written as: the type alias when one is declared, otherwise the full assembly-qualified name. Type alias is a short name set through [ScyllaSerializable(TypeAlias = "...")]; it is written as the type marker and TypeMetadataCache.ResolveAlias turns it back into a type on load.

ComponentRoleNotes
ScyllaSerializationStatic facade for the four core operationsToJSON<T>, FromJSON<T>, ToBytes<T>, FromBytes<T>. Each has a settings-less convenience overload and a settings-driven overload. RegisterSerializer<T> plugs into the global serializer registry. RegisterMigration<T> and GetMigration store and look up runtime migrations, but deserialization does not consult them.
SerializationResult / SerializationResult<T>No-throw result of every operationIsSuccess / IsFailure, Value (only on success), ErrorMessage, Exception. GetValueOrDefault(default) and TryGetValue(out T) for safe access. Reading Value on a failed result throws InvalidOperationException.
SerializationSettingsImmutable configuration object built via a fluent BuilderThree static presets (Default, ReadableJSON, CompactBinary); CreateBuilder() for custom instances; ToBuilder() to derive a variant from an existing settings instance. Properties are read-only after Build(). Also holds the public $id and $ref name constants.
[ScyllaSerializable]Type-level marker with schema version and optional type aliasConstructor version = 1 defaults to v1. TypeAlias (settable property) is written as the $type marker instead of the assembly-qualified name, and resolved back through TypeMetadataCache.ResolveAlias. Required for migration; not required for plain serialization (reflection picks up unmarked types).
[ScyllaProperty]Property/field marker for name, order, and required-nessName overrides the serialized key; Order controls serialization order (default int.MaxValue); Required = true causes deserialization to fail when the field is absent in the input.
[ScyllaIgnore]Skip this member during serializationUseful for cached values, computed properties, and runtime-only state. Marked members are not written and are not read.
[ScyllaInclude]Opt a non-public member into serializationBy default the reflection serializer picks up only public properties and fields; this attribute lets a private member participate without changing its access level.
[ScyllaMigration]Static method marker for one migration stepConstructor takes fromVersion and toVersion, which may be more than one version apart. The method must be static (public or private), take one parameter of the declaring type, and return the declaring type. Methods with any other signature are skipped.
ITypeSerializer<T> / TypeSerializerBase<T>Interface and base class for custom serializersOverride Write and Read to take full control of a type’s shape in either format. The reflection serializer is the default; a registered custom serializer replaces it. Register globally or per SerializationSettings.
IScyllaReader / IScyllaWriterTyped primitive read/write surfaceOne method per primitive type plus WritePropertyName, WriteStartObject/Array, ReadStartObject/Array, PeekToken, Skip. Use directly inside custom serializers to control the exact output shape.
ScyllaJSONReader / ScyllaJSONWriterJSON reader over a string and JSON writer into a StringBuilderJSON-specific concerns (escaping, whitespace, NaN and infinity as string sentinels) are handled internally. Configure(settings) applies pretty printing and the indent string to a writer. The facade pools and drives these for normal calls.
ScyllaBinaryReader / ScyllaBinaryWriterTagged binary reader and writerEvery value is preceded by a BinaryTypeTag byte, and the reader checks the tag on every read, so reading a value with the wrong Read* method throws SerializationException. Little-endian on every platform. Reset(data, offset, count) reads a window of a larger buffer without copying it.
BinaryTypeTagOne-byte tag enum for the binary formatIdentifies the value or delimiter that follows: Null, the numeric widths, Char, String, Bytes, and the object, property name, and array delimiters.
TypeMetadataCacheStatic cache of reflection results, plus type alias registryAn alias that is not registered yet is found by a one-time scan of the loaded assemblies. First call against a type runs Type.GetProperties/GetFields/GetCustomAttributes once and caches the result. PreWarm(types) removes the first-use cost from a hot path. Clear() is useful in test setups.
TypeMetadataCached description of one typeVersion, alias, properties, constructor, and migration methods. TryBuildMigrationPath returns the shortest list of migration methods between two versions; results, including “no path”, are cached per version pair.
ReferenceTrackerPer-call bookkeeping for cycle and shared-subgraph handlingOwned internally by SerializationContext; constructed with a ReferenceHandling value (Error, Preserve, Ignore). TryTrackWithID registers an object under an ID read from a $id marker. Direct construction is rarely needed.
SerializationExceptionThrown by readers and writers on malformed input or invalid stateSurfaces from the low-level reader/writer layer; the facade methods catch it and return a failed SerializationResult. Direct reader/writer usage bubbles it up to the caller.

Recipes

The recipes below cover every public method on the facade, every attribute, every settings option, every result type, and every subsystem entry point. Each one stands on its own, so scan the titles for the problem you’re solving and copy the solution straight across. If you haven’t used the API yet, start with the first recipe anyway: it lays out the SerializationResult<T> pattern and the round-trip shape that every other recipe assumes.

Serialize and deserialize game state

Problem. You want to save your player’s progress to disk and load it back. This is the most common path: turn a C# object into JSON, write that JSON somewhere, read it back later, and reconstruct the object. You need a pattern that does not crash on a failed load and gives you a useful error when something goes wrong.

Solution. Every operation flows through ScyllaSerialization and returns a SerializationResult<T>. The result never throws on failure; check IsSuccess (or IsFailure) and read Value only when the call succeeded. GetValueOrDefault and TryGetValue are the safe alternatives when you want to handle failure inline.

using Scylla.Core.Util.Serialization;

/* The simplest serializable shape: a plain class with public properties.
 * The reflection serializer picks up public properties and fields automatically;
 * no attribute is strictly required for vanilla types. */
public sealed class PlayerSave
{
    public string Name  { get; set; }
    public int    Level { get; set; }
    public float  HP    { get; set; }
}

var player = new PlayerSave { Name = "Hero", Level = 12, HP = 75.5f };

/* Default serialization: returns SerializationResult<string>. With the default
 * settings the output also carries "$id" and "$type" markers, because reference
 * preservation and type info are both on by default. */
SerializationResult<string> jsonResult = ScyllaSerialization.ToJSON(player);

if (jsonResult.IsSuccess)
{
    string json = jsonResult.Value;
    Log.Info(json, LogCategory.Core);
}
else
{
    Log.Warning($"Serialization failed: {jsonResult.ErrorMessage}", LogCategory.Core);
}

/* Round-trip: serialize then deserialize. */
SerializationResult<PlayerSave> loadResult = ScyllaSerialization.FromJSON<PlayerSave>(jsonResult.Value);

if (loadResult.TryGetValue(out PlayerSave loaded))
{
    Log.Info($"Loaded {loaded.Name} at level {loaded.Level}", LogCategory.Core);
}

/* GetValueOrDefault: returns a fallback when the result failed. Useful when
 * the absence of valid data has a sensible default (first-run config, etc.). */
PlayerSave safe = loadResult.GetValueOrDefault(new PlayerSave { Name = "Player", Level = 1 });

/* IsFailure is the logical complement of IsSuccess. Either form is acceptable. */
if (loadResult.IsFailure)
{
    /* result.Exception may carry an inner exception when the failure originated
     * from a thrown exception inside the engine. */
    Exception inner = loadResult.Exception;
}

/* Convert a SerializationResult<T> to a non-generic SerializationResult by
 * discarding the value. Useful when only the success/failure status matters. */
SerializationResult bareResult = loadResult.ToResult();

Lock a save format with attributes

Problem. Shipping a save system eventually means wanting explicit control over field names, ignored properties, private members, and schema versions. Your PlayerSave class works fine today, but by patch 1.1 you’ve renamed a field, added private state you want persisted, and need to skip a computed cache property. Without locked keys, a rename silently breaks every existing save.

Solution. Five attributes cover the common cases. None of them are required for trivial types; all of them become important as soon as schema stability matters. The attributes live in the same Scylla.Core.Util.Serialization namespace as the facade.

using Scylla.Core.Util.Serialization;

/* Mark the type with a schema version. Required for migration; recommended
 * from day one even when the version is 1 because retrofitting a version
 * scheme after the fact is harder than starting with one. The optional
 * TypeAlias is a short name registered with TypeMetadataCache. */
[ScyllaSerializable(version: 2, TypeAlias = "PlayerV2")]
public sealed class PlayerSave
{
    /* Override the serialized property name. Use this when the C# identifier
     * is verbose or follows a different convention than the stored shape. */
    [ScyllaProperty(Name = "name", Order = 1)]
    public string DisplayName { get; set; }

    /* Order controls write/read order. Default is int.MaxValue, so properties
     * without an explicit order appear after those with one. */
    [ScyllaProperty(Name = "level", Order = 2)]
    public int Level { get; set; }

    /* Required = true causes deserialization to fail when the field is missing
     * in the input. Useful for "must exist" fields where a default would be
     * dangerous (encryption keys, version sentinels). */
    [ScyllaProperty(Name = "hp", Order = 3, Required = true)]
    public float HP { get; set; }

    /* Skip this property entirely. The serializer does not write it, the
     * deserializer does not look for it. Use for cached or computed state
     * that should be recomputed on load. */
    [ScyllaIgnore]
    public float MaxHPCached { get; set; }

    /* Opt a private member into serialization. By default the reflection
     * serializer picks up only public properties and public fields; this
     * attribute lets a private member participate without changing the
     * access modifier. */
    [ScyllaInclude]
    private int _secretInventorySize;

    /* Migration method. The engine looks for static methods marked with
     * [ScyllaMigration] when the stored version is older than the current
     * version. Signature: one parameter of the declaring type, returning the
     * declaring type. Public and private methods both work. */
    [ScyllaMigration(fromVersion: 1, toVersion: 2)]
    private static PlayerSave MigrateV1ToV2(PlayerSave old)
    {
        /* In v1 the field was missing; in v2 it must be at least 1. */
        old.Level = Math.Max(1, old.Level);
        return old;
    }
}

Produce a compact binary save file

Problem. Your save file ships compressed and encrypted, so it does not need to be human-readable, and you want every byte trimmed. JSON is fine for configuration that someone might inspect by hand, but for a binary blob going through Compression and Crypto the text overhead buys nothing.

Solution. ToBytes / FromBytes with SerializationFormat.Binary (or the CompactBinary preset) write a tagged binary stream through ScyllaBinaryWriter and read it back through ScyllaBinaryReader. Each value is preceded by a one-byte BinaryTypeTag, numbers are stored in their native width, little-endian, and floats keep NaN and the infinities exactly. Without settings, ToBytes and FromBytes use the default JSON format and produce UTF-8 JSON bytes. The format used to read must match the format used to write; the two are not interchangeable.

/* Default ToBytes: the UTF-8 encoding of the default JSON output. */
SerializationResult<byte[]> bytes = ScyllaSerialization.ToBytes(player);
SerializationResult<PlayerSave> back = ScyllaSerialization.FromBytes<PlayerSave>(bytes.Value);

/* Binary format: tagged values, no text, no whitespace. CompactBinary also
 * omits null and default values. Read it back with the same settings. */
SerializationSettings binarySettings = SerializationSettings.CompactBinary;
SerializationResult<byte[]> packed = ScyllaSerialization.ToBytes(player, binarySettings);
SerializationResult<PlayerSave> unpacked = ScyllaSerialization.FromBytes<PlayerSave>(packed.Value, binarySettings);

/* A custom binary settings instance keeps nulls and defaults instead. */
SerializationSettings fullBinary = SerializationSettings.CreateBuilder()
    .WithFormat(SerializationFormat.Binary)
    .Build();

/* Compress the bytes and write them with ScyllaFileUtil. WriteBytesAtomic swaps
 * the new file in only once it is fully on disk, so a crash mid-save leaves the
 * previous save intact. */
byte[] payload    = ScyllaSerialization.ToBytes(saveState, SerializationSettings.CompactBinary).Value;
byte[] compressed = ScyllaCompression.Compress(payload);
ScyllaFileUtil.WriteBytesAtomic(savePath, compressed);

Choose the right settings for your use case

Problem. You need fine-grained control over the output: maybe you want indented JSON for a config file a designer will hand-edit, or you need to strip null values from a network payload to save bandwidth, or you want to derive a custom settings variant from one of the built-in presets rather than constructing everything from scratch.

Solution. Settings come in two shapes: either pick one of the named presets and move on, or construct a custom instance via the fluent builder when something specific needs changing. SerializationSettings is the immutable configuration carrier; every field is read-only after Build(), so you can safely share the same instance across threads and across calls.

/* Three presets. */
SerializationSettings def     = SerializationSettings.Default;       /* JSON, no pretty print, references preserved */
SerializationSettings pretty  = SerializationSettings.ReadableJSON;  /* JSON with tabs and newlines */
SerializationSettings compact = SerializationSettings.CompactBinary; /* Binary, no null/default values */

/* Custom settings via the fluent builder. */
SerializationSettings custom = SerializationSettings.CreateBuilder()
    .WithFormat(SerializationFormat.JSON)
    .WithNullHandling(NullHandling.Ignore)                 /* omit properties whose value is null */
    .WithDefaultValueHandling(DefaultValueHandling.Ignore) /* omit properties at their default value */
    .WithReferenceHandling(ReferenceHandling.Preserve)     /* shared objects and cycles via $id/$ref (the default) */
    .WithEmitReferenceIDs(true)                            /* write $id on first occurrence (the default) */
    .WithMaxDepth(128)                                     /* deeper than default 64 */
    .WithTypeInfo(false)                                   /* omit $type markers (on by default) */
    .WithVersionInfo(true)                                 /* emit $version for migration */
    .WithPrettyPrint(true)                                 /* indent for readability */
    .Build();

/* Derive a variant from an existing settings instance. */
SerializationSettings tweaked = SerializationSettings.ReadableJSON
    .ToBuilder()
    .WithNullHandling(NullHandling.Ignore)
    .Build();

/* Pass the settings to any facade method. */
SerializationResult<string> json = ScyllaSerialization.ToJSON(player, custom);

The four enums used by SerializationSettings are listed in the Settings reference section below.

Serialize object graphs with back-references

Problem. Object graphs in real game state are rarely trees. Two entities can hold references to the same inventory, a parent can hold a list of children that each hold a back-reference to the parent, and a graph data structure can have arbitrary cycles. You need the serializer to write each object once and rebuild the same sharing on load.

Solution. ReferenceHandling picks the engine’s policy. Preserve, the default, writes the first occurrence of each object with an $id marker and every later occurrence as a {"$ref": id} object, so the graph round-trips exactly, including identity. Error fails as soon as any object is reached a second time, whether through a cycle or through plain sharing. Ignore writes every repeated occurrence as null.

/* A graph with a cycle: a parent holds children, each child knows its parent. */
public sealed class Entity
{
    public string Name { get; set; }
    public Entity Parent { get; set; }
    public List<Entity> Children { get; set; } = new();
}

var root = new Entity { Name = "Root" };
var child = new Entity { Name = "Child", Parent = root };
root.Children.Add(child);

/* Default behavior (ReferenceHandling.Preserve): the first occurrence carries
 * "$id", the child's Parent is written as {"$ref": 1}. */
SerializationResult<string> preserved = ScyllaSerialization.ToJSON(root);
SerializationResult<Entity> loaded    = ScyllaSerialization.FromJSON<Entity>(preserved.Value);
/* loaded.Value.Children[0].Parent is the same instance as loaded.Value. */

/* Error: any object reached twice fails the call. Use for data that must be
 * a strict tree. */
SerializationSettings strict = SerializationSettings.CreateBuilder()
    .WithReferenceHandling(ReferenceHandling.Error)
    .Build();

SerializationResult<string> errored = ScyllaSerialization.ToJSON(root, strict);
/* errored.IsFailure == true, errored.ErrorMessage reports a cyclic reference. */

/* Ignore: the repeated occurrence is written as null. The structure
 * round-trips but the child's Parent property is null after loading. */
SerializationSettings ignore = SerializationSettings.CreateBuilder()
    .WithReferenceHandling(ReferenceHandling.Ignore)
    .Build();

SerializationResult<string> ignored = ScyllaSerialization.ToJSON(root, ignore);

Read references in any order

Problem. A $ref only works if the reader already knows which object the ID belongs to. If IDs are assigned by counting objects in the order they are read, anything that changes that order breaks the link: a property skipped because its value is null or default, a $type that no longer resolves, or a JSON file edited by hand. The back-reference then points at the wrong object or at nothing.

Solution. With EmitReferenceIDs on (the default), the first occurrence of every tracked object carries an explicit $id, and the reader registers the object under that ID with ReferenceTracker.TryTrackWithID. A $ref then resolves to the right object regardless of traversal order. Documents without $id markers still load, because the reader falls back to counting objects as before. Turn the marker off only when an external consumer cannot handle it.

/* Default: $id is written on every newly tracked object. A typical object
 * starts with "$id", then "$type", then "$version", then its properties. */
SerializationResult<string> withIDs = ScyllaSerialization.ToJSON(root);

/* Older shape, without $id markers, for a tool that does not expect them. */
SerializationSettings noIDs = SerializationSettings.CreateBuilder()
    .WithEmitReferenceIDs(false)
    .Build();
SerializationResult<string> withoutIDs = ScyllaSerialization.ToJSON(root, noIDs);

/* External tooling can recognize the markers through the public constants
 * instead of hardcoding the strings. */
string idMarker  = SerializationSettings.REF_ID_PROPERTY_NAME; /* "$id"  */
string refMarker = SerializationSettings.REF_PROPERTY_NAME;    /* "$ref" */

Serialize polymorphic values with $type and type aliases

Problem. You are serializing a List<Item> where the elements are actually Weapon or Potion at runtime, and you want each element to come back as its own subclass, with the fields of that subclass.

Solution. The engine still picks a serializer by the declared type of each member or collection element, but the reflection serializer compares the declared type with the runtime type of the value. When they differ, it writes the value with the properties of its runtime type and a $type marker, whatever IncludeTypeInfo is set to. On load, the marker is resolved to a type, checked against the declared type, and that type is instantiated. This covers a derived class in a base-typed field, an abstract base, an interface-typed field, and the elements of lists and dictionaries.

The marker is the type’s TypeAlias when the type declares one, and its assembly-qualified name otherwise. An alias is short and survives renames, namespace moves and assembly moves, so polymorphic types that end up in saves should declare one.

public abstract class Item
{
    public string Name { get; set; }
}

public sealed class Inventory
{
    public List<Item> Items { get; set; } = new();
}

[ScyllaSerializable(version: 1, TypeAlias = "Weapon")]
public sealed class Weapon : Item
{
    public int Damage { get; set; }
}

[ScyllaSerializable(version: 1, TypeAlias = "Potion")]
public sealed class Potion : Item
{
    public int HealAmount { get; set; }
}

var inventory = new Inventory();
inventory.Items.Add(new Weapon { Name = "Sword", Damage = 10 });
inventory.Items.Add(new Potion { Name = "Health", HealAmount = 25 });

SerializationResult<string> json = ScyllaSerialization.ToJSON(inventory);
SerializationResult<Inventory> back = ScyllaSerialization.FromJSON<Inventory>(json.Value);
/* back.Value.Items[0] is a Weapon; back.Value.Items[1] is a Potion. The JSON
 * for the first element looks like:
 * {"$id":2,"$type":"Weapon","$version":1,"Damage":10,"Name":"Sword"} */

/* Aliases can also be registered by hand, for example for a type the runtime
 * may strip, or to keep an old alias loading. */
TypeMetadataCache.RegisterAlias("LegacySword", typeof(Weapon));
Type resolved = TypeMetadataCache.ResolveAlias("Weapon");

A value that is not serialized as an object with properties, such as an int in an object field or a List<int> behind an IList<int> field, is written as an envelope, {"$type": ..., "$value": ...}, where $value holds the payload written by the serializer of the runtime type. A Dictionary<string, object> with mixed values round-trips this way.

How a marker is handled on load:

  • Alias discovery. An alias that has not been registered yet is looked up in two steps. Type.GetType is tried first, for assembly-qualified names. If that misses, the loaded assemblies are scanned once for types that declare the alias, so a save can be loaded in a fresh session before any type has been serialized. A duplicate alias logs a warning and the first registration is kept. Under IL2CPP, managed code stripping can remove a type that nothing else references, and the scan cannot find it; keep such a type with [Preserve] or register its alias with RegisterAlias.
  • Assignability check. The resolved type must be assignable to the declared type, otherwise the load fails with an error naming both types. This also stops a save file from making the engine instantiate an arbitrary type.
  • Unresolvable marker. If the declared type is concrete, the value loads as the declared type. If it is abstract or an interface, the load fails with an error that names the marker, since there is nothing to fall back to.
  • IncludeTypeInfo. With it off, only the marker of objects whose runtime type equals the declared type is left out. Polymorphic values keep theirs, so turning it off never loses data.
  • Versions. The $version and the migration methods are those of the runtime type, so a derived type migrates on its own schedule.

Migrate save data across version bumps

Problem. There’s a moment in every shipped game’s life when the save format from yesterday no longer matches the type definition from today. You renamed a field between v1.0 and v1.1, added a new required stat in v1.2, and now an existing player’s save file from v1.0 needs to load cleanly into the v1.2 type. Without a migration system, you’re either stuck with the v1.0 shape forever or you’re telling players their save is gone.

Solution. [ScyllaSerializable(version)] writes the version into the output as a $version field. When the version on a loaded blob is older than the type’s current version, the engine collects the [ScyllaMigration(fromVersion, toVersion)] methods on the type and finds the shortest path between the two versions. A v1 blob loaded into a v3 type runs v1->v2 then v2->v3, or a single v1->v3 method when one exists. If no combination of methods connects the stored version to the current one, the load fails with an error naming the type and both versions.

/* The type has evolved across three versions. */
[ScyllaSerializable(version: 3)]
public sealed class CharacterSave
{
    public string Name       { get; set; }
    public int    Level      { get; set; }
    public int    Stamina    { get; set; }
    public int    MaxStamina { get; set; }

    /* v1 had no Stamina field. v2 added Stamina; v3 added MaxStamina. */
    [ScyllaMigration(fromVersion: 1, toVersion: 2)]
    private static CharacterSave MigrateV1ToV2(CharacterSave old)
    {
        old.Stamina = 100;              /* default value for the new field */
        return old;
    }

    [ScyllaMigration(fromVersion: 2, toVersion: 3)]
    private static CharacterSave MigrateV2ToV3(CharacterSave old)
    {
        old.MaxStamina = old.Stamina;   /* derive from the existing Stamina */
        return old;
    }

    /* A direct jump. When it exists, a v1 save takes this one method instead of
     * the two-step chain, because the engine prefers the shortest path. */
    [ScyllaMigration(fromVersion: 1, toVersion: 3)]
    private static CharacterSave MigrateV1ToV3(CharacterSave old)
    {
        old.Stamina    = 100;
        old.MaxStamina = 100;
        return old;
    }
}

/* Loading a v1 blob migrates it to v3. */
string v1Blob = "{\"$version\":1,\"Name\":\"Hero\",\"Level\":5}";
SerializationResult<CharacterSave> upgraded = ScyllaSerialization.FromJSON<CharacterSave>(v1Blob);
/* upgraded.Value.Stamina == 100; upgraded.Value.MaxStamina == 100. */

/* Check a path ahead of time, for example in a test or a save-slot browser. */
TypeMetadata meta = TypeMetadataCache.GetMetadata<CharacterSave>();
if (meta.TryBuildMigrationPath(1, 3, out IReadOnlyList<MethodInfo> path))
{
    Log.Info($"v1 -> v3 takes {path.Count} migration step(s).", LogCategory.Core);
}

Write a custom type serializer

Problem. Sometimes the reflection serializer’s output is not what you need: a different shape, a compact encoding, a third-party type without an accessible constructor, a value type that wants to round-trip as a single integer. The default treatment of UnityEngine.Matrix4x4 would write 16 named properties (m00, m01, …); you want a flat array.

Solution. Implement ITypeSerializer<T> or extend TypeSerializerBase<T> to take full control. The base class handles the object-typed WriteObject / ReadObject adapters automatically; you only override Write and Read. Because the serializer talks to IScyllaWriter and IScyllaReader, the same class works for both JSON and binary output. Register globally for project-wide use, or per-settings to keep the effect scoped to a specific call.

/* A custom serializer for UnityEngine.Matrix4x4 that writes the 16 floats
 * as a flat array. */
using UnityEngine;
using Scylla.Core.Util.Serialization;

public sealed class Matrix4x4Serializer : TypeSerializerBase<Matrix4x4>
{
    public override void Write(Matrix4x4 value, IScyllaWriter writer, ISerializationContext context)
    {
        writer.WriteStartArray();
        for (int i = 0; i < 16; i++)
        {
            writer.WriteFloat(value[i / 4, i % 4]);
        }
        writer.WriteEndArray();
    }

    public override Matrix4x4 Read(IScyllaReader reader, ISerializationContext context)
    {
        var m = new Matrix4x4();
        reader.ReadStartArray();
        for (int i = 0; i < 16; i++)
        {
            m[i / 4, i % 4] = reader.ReadFloat();
        }
        reader.ReadEndArray();
        return m;
    }
}

/* Register globally: every subsequent call for Matrix4x4 uses this serializer. */
ScyllaSerialization.RegisterSerializer(new Matrix4x4Serializer());

/* Register per-call via the Builder: the custom serializer applies only to
 * calls that use this settings instance. */
SerializationSettings withCustom = SerializationSettings.CreateBuilder()
    .RegisterSerializer(new Matrix4x4Serializer())
    .Build();

SerializationResult<string> json = ScyllaSerialization.ToJSON(transformData, withCustom);

Hand-roll output with the low-level reader and writer

Problem. You need ultra-low-level access: parsing a format from a legacy save, writing a JSON shape that does not map to any reasonable C# type, or interoperating with an external schema that has its own opinions about field ordering. The facade does not give you enough control over the token stream.

Solution. Construct a reader or writer directly. ScyllaJSONWriter writes into a StringBuilder and ScyllaJSONReader reads a string; ScyllaBinaryWriter writes into an internal buffer and ScyllaBinaryReader reads a byte array. All four implement IScyllaReader / IScyllaWriter, which expose typed primitive reads and writes plus a token-stream model via PeekToken and SerializationToken, so code written against the interfaces works with either format.

using Scylla.Core.Util.Serialization;
using System.Text;

/* Writing JSON: construct a writer over a StringBuilder, apply settings, write tokens. */
var sb = new StringBuilder();
using (var writer = new ScyllaJSONWriter(sb))
{
    writer.Configure(SerializationSettings.ReadableJSON); /* pretty print and indent */
    WriteHero(writer);
}
string json = sb.ToString();

/* Writing binary: the same calls produce a tagged byte stream. */
byte[] blob;
using (var binaryWriter = new ScyllaBinaryWriter())
{
    WriteHero(binaryWriter);
    blob = binaryWriter.GetByteOutput();
}

static void WriteHero(IScyllaWriter writer)
{
    writer.WriteStartObject();
        writer.WritePropertyName("name");
        writer.WriteString("Hero");
        writer.WritePropertyName("stats");
        writer.WriteStartObject();
            writer.WritePropertyName("hp");
            writer.WriteInt(75);
            writer.WritePropertyName("mp");
            writer.WriteInt(30);
        writer.WriteEndObject();
        writer.WritePropertyName("inventory");
        writer.WriteStartArray();
            writer.WriteString("sword");
            writer.WriteString("potion");
        writer.WriteEndArray();
    writer.WriteEndObject();
}

/* Reading: token-stream model. PeekToken inspects the next token without
 * consuming it; Read* methods consume and return the typed value. The same
 * method reads both formats. */
using (var reader = new ScyllaJSONReader(json))
{
    ReadHero(reader);
}

using (var binaryReader = new ScyllaBinaryReader(blob))
{
    ReadHero(binaryReader);
}

static void ReadHero(IScyllaReader reader)
{
    reader.ReadStartObject();
    while (reader.PeekToken() != SerializationToken.EndObject)
    {
        string propertyName = reader.ReadPropertyName();
        switch (propertyName)
        {
            case "name":
                string name = reader.ReadString();
                /* ... */
                break;
            case "stats":
                reader.ReadStartObject();
                while (reader.PeekToken() != SerializationToken.EndObject)
                {
                    string statName = reader.ReadPropertyName();
                    int value = reader.ReadInt();
                    /* ... */
                }
                reader.ReadEndObject();
                break;
            case "inventory":
                reader.ReadStartArray();
                while (reader.PeekToken() != SerializationToken.EndArray)
                {
                    string itemName = reader.ReadString();
                    /* ... */
                }
                reader.ReadEndArray();
                break;
            default:
                reader.Skip(); /* skip values this code does not understand */
                break;
        }
    }
    reader.ReadEndObject();
}

/* Twelve SerializationToken values cover every shape: None, Null, Boolean,
 * Integer, Float, String, StartObject, EndObject, PropertyName, StartArray,
 * EndArray, Bytes. PeekToken returns None at end of input. In the binary
 * format every integer width reports Integer, and float, double and decimal
 * report Float. */

/* A binary reader can read a window of a larger buffer without copying it out. */
var windowReader = new ScyllaBinaryReader();
windowReader.Reset(containerBuffer, offset: 128, count: 512);

Pre-warm the metadata cache before a hot save path

Problem. Your game saves frequently and you are seeing a stall on the first save call per session. The reflection serializer walks every type’s properties, reads attribute values, and locates default constructors on first use; for a save object graph with twenty types, that is twenty separate reflection passes happening at the worst possible moment, when the player is mid-combat or mid-dialogue and least expects a hitch.

Solution. TypeMetadataCache.PreWarm(types) runs those reflection passes up front during your loading screen, so every subsequent save call is a dictionary lookup. The ReferenceTracker is owned internally by SerializationContext and rarely constructed directly, but custom serializers that need to track shared references can access it through the context.

using Scylla.Core.Util.Serialization;

/* Pre-warm the metadata cache at application startup. The first call against
 * a type runs reflection; subsequent calls are O(1) dictionary lookups. */
TypeMetadataCache.PreWarm(typeof(PlayerSave), typeof(Inventory), typeof(Weapon));

/* Or pre-warm a sequence (e.g., every save-game type discovered via assembly
 * scanning). */
IEnumerable<Type> saveTypes = new[] { typeof(PlayerSave), typeof(CharacterSave) };
TypeMetadataCache.PreWarm(saveTypes);

/* Look up cached metadata. Useful for introspection (editor tooling, debugging,
 * a custom serializer that decides on a different strategy based on the
 * declared properties). */
TypeMetadata meta = TypeMetadataCache.GetMetadata<PlayerSave>();

Log.Info($"Type: {meta.Type.Name}, Version: {meta.Version}, Properties: {meta.Properties.Count}", LogCategory.Core);

foreach (PropertyMetadata prop in meta.Properties)
{
    Log.Info($"  {prop.Name} (member: {prop.MemberName})", LogCategory.Core);
}

/* Direct property lookup by serialized name. */
PropertyMetadata levelProp = meta.GetProperty("level");

/* Migration lookups. GetMigration finds one method by its exact version pair;
 * TryBuildMigrationPath and HasMigrationPath search for a chain. */
MethodInfo migrate = meta.GetMigration(fromVersion: 1, toVersion: 2);
bool hasPath = meta.HasMigrationPath(fromVersion: 1, toVersion: 3);
bool found   = meta.TryBuildMigrationPath(1, 3, out IReadOnlyList<MethodInfo> steps);

/* Cache management. Useful in tests; rare in production. */
int cachedCount = TypeMetadataCache.Count;
bool isCached   = TypeMetadataCache.IsCached(typeof(PlayerSave));
TypeMetadataCache.Clear();

/* ReferenceTracker: constructed automatically by the engine; exposed for
 * custom serializers that need shared-reference behavior. */
var tracker = new ReferenceTracker(ReferenceHandling.Preserve);

/* TryTrack returns true when the object was already tracked (a repeat), and
 * false when it was tracked just now under a new ID. Either way refID holds
 * the object's ID. */
bool seenBefore = tracker.TryTrack(someObject, out int refID);

if (tracker.TryGetID(someObject, out int knownID))
{
    /* Write a $ref to knownID. */
}

if (tracker.TryResolve(refID, out object resolved))
{
    /* Use the resolved object. */
}

/* Register an object under an ID read from a $id marker. Returns false when
 * the ID was already registered, which usually means a corrupt or hand-edited
 * document. Register does the same without the check. */
bool fresh = tracker.TryTrackWithID(deserializedObject, referenceID: 5);
tracker.Register(referenceID: 6, obj: somethingPreloaded);

bool already = tracker.IsTracked(someObject);
tracker.Clear();

API Sketch

The public surface is split across one facade, two result types, one settings class plus its builder, five attributes, three interface families (serializer, context, reader/writer), two reader/writer pairs, one base class for custom serializers, one reference tracker, three metadata types, five enums, and one exception. Most user code touches only the facade and the attributes. Everything lives in the Scylla.Core.Util.Serialization namespace.

namespace Scylla.Core.Util.Serialization
{
    /* Facade. */
    public static class ScyllaSerialization
    {
        public static SerializationResult<string> ToJSON<T>(T value);
        public static SerializationResult<string> ToJSON<T>(T value, SerializationSettings settings);
        public static SerializationResult<byte[]> ToBytes<T>(T value);
        public static SerializationResult<byte[]> ToBytes<T>(T value, SerializationSettings settings);
        public static SerializationResult<T>      FromJSON<T>(string json);
        public static SerializationResult<T>      FromJSON<T>(string json, SerializationSettings settings);
        public static SerializationResult<T>      FromBytes<T>(byte[] bytes);
        public static SerializationResult<T>      FromBytes<T>(byte[] bytes, SerializationSettings settings);

        public static void RegisterSerializer<T>(ITypeSerializer<T> serializer);
        public static void RegisterMigration<T>(int fromVersion, int toVersion, Func<T, T> migrator); /* not consulted by deserialization */
        public static Func<object, object> GetMigration(Type type, int fromVersion, int toVersion);
    }

    /* Results. */
    public readonly struct SerializationResult
    {
        public bool      IsSuccess     { get; }
        public bool      IsFailure     { get; }
        public string    ErrorMessage  { get; }
        public Exception Exception     { get; }
        public static SerializationResult Success();
        public static SerializationResult Failure(string errorMessage, Exception exception = null);
    }

    public readonly struct SerializationResult<T>
    {
        public bool      IsSuccess     { get; }
        public bool      IsFailure     { get; }
        public T         Value         { get; } /* throws InvalidOperationException on failed result */
        public string    ErrorMessage  { get; }
        public Exception Exception     { get; }
        public static SerializationResult<T> Success(T value);
        public static SerializationResult<T> Failure(string errorMessage, Exception exception = null);
        public T    GetValueOrDefault(T defaultValue = default);
        public bool TryGetValue(out T value);
        public SerializationResult ToResult();
    }

    /* Settings (immutable; build via Builder). */
    public sealed class SerializationSettings
    {
        public const int    DEFAULT_MAX_DEPTH    = 64;
        public const string DEFAULT_INDENT       = "\t";
        public const string REF_ID_PROPERTY_NAME = "$id";
        public const string REF_PROPERTY_NAME    = "$ref";

        public static SerializationSettings Default       { get; }
        public static SerializationSettings ReadableJSON  { get; }
        public static SerializationSettings CompactBinary { get; }

        public SerializationFormat   Format                { get; }
        public NullHandling          NullHandling          { get; }
        public DefaultValueHandling  DefaultValueHandling  { get; }
        public ReferenceHandling     ReferenceHandling     { get; }
        public bool                  EmitReferenceIDs      { get; }
        public int                   MaxDepth              { get; }
        public bool                  IncludeTypeInfo       { get; }
        public bool                  IncludeVersionInfo    { get; }
        public bool                  PrettyPrint           { get; }
        public string                Indent                { get; }
        public bool                  IncludePrivateFields  { get; }
        public IReadOnlyDictionary<Type, ITypeSerializer> CustomSerializers { get; }

        public static Builder CreateBuilder();
        public Builder ToBuilder();

        public sealed class Builder
        {
            public Builder WithFormat              (SerializationFormat format);
            public Builder WithNullHandling        (NullHandling handling);
            public Builder WithDefaultValueHandling(DefaultValueHandling handling);
            public Builder WithReferenceHandling   (ReferenceHandling handling);
            public Builder WithEmitReferenceIDs    (bool enabled = true);
            public Builder WithMaxDepth            (int maxDepth);
            public Builder WithTypeInfo            (bool include = true);
            public Builder WithVersionInfo         (bool include = true);
            public Builder WithPrettyPrint         (bool enabled = true);
            public Builder WithIndent              (string indent);
            public Builder WithPrivateFields       (bool include = true);
            public Builder RegisterSerializer<T>   (ITypeSerializer<T> serializer);
            public SerializationSettings Build();
        }
    }

    /* Enums. */
    public enum SerializationFormat   { JSON = 0, Binary = 1 }
    public enum NullHandling          { Include = 0, Ignore = 1 }
    public enum DefaultValueHandling  { Include = 0, Ignore = 1 }
    public enum ReferenceHandling     { Error = 0, Preserve = 1, Ignore = 2 }
    public enum SerializationToken    { None = 0, Null, Boolean, Integer, Float, String,
                                        StartObject, EndObject, PropertyName,
                                        StartArray, EndArray, Bytes }
    public enum BinaryTypeTag : byte  { Null = 0, Bool, Byte, SByte, Short, UShort, Int, UInt,
                                        Long, ULong, Float, Double, Decimal, Char, String, Bytes,
                                        StartObject, EndObject, PropertyName, StartArray, EndArray }

    /* Type serializer layer. */
    public interface ITypeSerializer
    {
        Type   TargetType { get; }
        void   WriteObject(object value, IScyllaWriter writer, ISerializationContext context);
        object ReadObject (IScyllaReader reader, ISerializationContext context);
    }
    public interface ITypeSerializer<T>
    {
        void Write(T value, IScyllaWriter writer, ISerializationContext context);
        T    Read (IScyllaReader reader, ISerializationContext context);
    }
    public abstract class TypeSerializerBase<T> : ITypeSerializer<T>, ITypeSerializer
    {
        public Type TargetType => typeof(T);
        public abstract void Write(T value, IScyllaWriter writer, ISerializationContext context);
        public abstract T    Read (IScyllaReader reader, ISerializationContext context);
        public void   WriteObject(object value, IScyllaWriter writer, ISerializationContext context);
        public object ReadObject (IScyllaReader reader, ISerializationContext context);
    }
    public sealed class TypeSerializerAdapter<T> : ITypeSerializer
    {
        public TypeSerializerAdapter(ITypeSerializer<T> inner);
    }

    /* Context. */
    public interface ISerializationContext
    {
        SerializationSettings Settings           { get; }
        int                   CurrentDepth       { get; }
        bool                  IsMaxDepthReached  { get; }
        ReferenceTracker      ReferenceTracker   { get; }
        void                  PushDepth();
        void                  PopDepth();
        ITypeSerializer       GetSerializer(Type type);
        ITypeSerializer<T>    GetSerializer<T>();
    }
    public sealed class SerializationContext : ISerializationContext { /* ... */ }

    /* Reader / writer. */
    public interface IScyllaWriter : IDisposable
    {
        void WriteNull();
        void WriteBool(bool value);
        void WriteByte(byte value);    void WriteSByte (sbyte value);
        void WriteShort(short value);  void WriteUShort(ushort value);
        void WriteInt(int value);      void WriteUInt  (uint value);
        void WriteLong(long value);    void WriteULong (ulong value);
        void WriteFloat(float value);  void WriteDouble(double value);
        void WriteDecimal(decimal value);
        void WriteChar(char value);
        void WriteString(string value);
        void WriteBytes(byte[] bytes);
        void WriteRawValue(string rawValue);
        void WriteStartObject();   void WriteEndObject();
        void WritePropertyName(string name);
        void WriteStartArray();    void WriteEndArray();
        string GetStringOutput();
        byte[] GetByteOutput();
        void   Reset();
    }
    public interface IScyllaReader : IDisposable
    {
        bool HasMore { get; }
        SerializationToken PeekToken();
        bool   ReadNull();
        bool   ReadBool();
        byte   ReadByte();   sbyte  ReadSByte();
        short  ReadShort();  ushort ReadUShort();
        int    ReadInt();    uint   ReadUInt();
        long   ReadLong();   ulong  ReadULong();
        float  ReadFloat();  double ReadDouble();
        decimal ReadDecimal();
        char   ReadChar();
        string ReadString();
        byte[] ReadBytes();
        string ReadNumberLexeme();
        void   ReadStartObject();  void ReadEndObject();
        string ReadPropertyName();
        void   ReadStartArray();   void ReadEndArray();
        int    GetArrayLength();
        void   Skip();
        void   Reset();
        void   Reset(string data);
        void   Reset(byte[] data);
    }
    public sealed class ScyllaJSONWriter : IScyllaWriter
    {
        public ScyllaJSONWriter();
        public ScyllaJSONWriter(StringBuilder output);
        public void Configure(SerializationSettings settings);
        public void SetOutput(StringBuilder output);
        public void SetPrettyPrint(bool prettyPrint);
    }
    public sealed class ScyllaJSONReader : IScyllaReader
    {
        public ScyllaJSONReader();
        public ScyllaJSONReader(string json);
        public void SetInput(string json);
    }
    public sealed class ScyllaBinaryWriter : IScyllaWriter
    {
        public ScyllaBinaryWriter();
    }
    public sealed class ScyllaBinaryReader : IScyllaReader
    {
        public ScyllaBinaryReader();
        public ScyllaBinaryReader(byte[] data);
        public void Reset(byte[] data, int offset, int count);
    }

    /* Reference tracking. */
    public sealed class ReferenceTracker
    {
        public ReferenceTracker(ReferenceHandling handling);
        public ReferenceHandling Handling { get; }
        public int               Count    { get; }
        public bool TryTrack      (object obj, out int referenceID); /* true when already tracked */
        public bool TryTrackWithID(object obj, int referenceID);      /* false when the ID was taken */
        public bool TryGetID      (object obj, out int referenceID);
        public bool TryResolve    (int referenceID, out object obj);
        public void Register      (int referenceID, object obj);
        public bool IsTracked     (object obj);
        public void Clear();
    }

    /* Metadata cache. */
    public static class TypeMetadataCache
    {
        public static int             Count { get; }
        public static TypeMetadata    GetMetadata(Type type);
        public static TypeMetadata    GetMetadata<T>();
        public static Type            ResolveAlias(string alias);
        public static void            RegisterAlias(string alias, Type type);
        public static void            Clear();
        public static void            PreWarm(IEnumerable<Type> types);
        public static void            PreWarm(params Type[] types);
        public static bool            IsCached(Type type);
    }
    public sealed class TypeMetadata
    {
        public Type Type { get; }
        public int  Version { get; }
        public string TypeAlias { get; } /* the type's full name when no alias is set; only an index key then */
        public bool   HasExplicitAlias { get; }
        public string TypeMarkerName { get; } /* what is written as $type: the alias, else the assembly-qualified name */
        public bool   IsExplicitlySerializable { get; }
        public bool   IsValueType { get; }
        public bool   IsPrimitive { get; }
        public bool   IsNullable { get; }
        public Type   UnderlyingNullableType { get; }
        public IReadOnlyList<PropertyMetadata>           Properties     { get; }
        public IReadOnlyDictionary<string, PropertyMetadata> PropertyLookup { get; }
        public bool HasDefaultConstructor { get; }
        public object           CreateInstance();
        public PropertyMetadata GetProperty(string name);
        public MethodInfo       GetMigration(int fromVersion, int toVersion);
        public bool             HasMigrationPath(int fromVersion, int toVersion);
        public bool             TryBuildMigrationPath(int fromVersion, int toVersion, out IReadOnlyList<MethodInfo> path);
    }
    public sealed class PropertyMetadata
    {
        public string Name       { get; }
        public string MemberName { get; }
        /* + Type, Order, IsRequired, CanRead, CanWrite, DefaultValue, MemberInfo,
           GetValue, SetValue, IsDefaultValue */
    }

    /* Exception. */
    public class SerializationException : Exception
    {
        public SerializationException();
        public SerializationException(string message);
        public SerializationException(string message, Exception innerException);
    }

    /* Attributes. */
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)]
    public sealed class ScyllaSerializableAttribute : Attribute
    {
        public ScyllaSerializableAttribute(int version = 1);
        public int    Version   { get; }
        public string TypeAlias { get; set; }
    }
    [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
    public sealed class ScyllaPropertyAttribute : Attribute
    {
        public string Name     { get; set; }
        public int    Order    { get; set; } /* default int.MaxValue */
        public bool   Required { get; set; } /* default false */
    }
    [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
    public sealed class ScyllaIgnoreAttribute  : Attribute { }
    [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
    public sealed class ScyllaIncludeAttribute : Attribute { }
    [AttributeUsage(AttributeTargets.Method, Inherited = false)]
    public sealed class ScyllaMigrationAttribute : Attribute
    {
        public ScyllaMigrationAttribute(int fromVersion, int toVersion);
        public int FromVersion { get; }
        public int ToVersion   { get; }
    }
}

See the API reference for full signatures, remarks, and exception contracts on every member.

Settings reference

SerializationSettings is the immutable configuration carrier. Build a custom instance via CreateBuilder() and the fluent With* methods; pass the result to any facade call. The four enums below cover every behavioral switch.

Properties

PropertyTypeDefaultEffect
FormatSerializationFormatJSONJSON for human-readable UTF-8; Binary for the tagged binary stream written by ScyllaBinaryWriter. Read with the same format that wrote the data.
NullHandlingNullHandlingIncludeInclude writes properties whose value is null; Ignore omits them from the output.
DefaultValueHandlingDefaultValueHandlingIncludeInclude writes properties at their default value (0, false, null, etc.); Ignore omits them.
ReferenceHandlingReferenceHandlingPreservePreserve writes $id / $ref to round-trip shared objects and cycles exactly; Error fails when any object is reached a second time; Ignore writes repeated occurrences as null.
EmitReferenceIDsbooltrueWith Preserve, writes an explicit $id on the first occurrence of each tracked object so $ref resolves in any read order. false writes the older shape without $id. Has no effect with other modes.
MaxDepthint64Hard limit on nested object depth. Exceeding the limit produces a failed result with a depth error message. Raise for deeply nested data; lower as a guard against malicious input.
IncludeTypeInfobooltrueWhen true, the reflection serializer writes a $type marker (the type alias, or the assembly-qualified name) for every object. When false, the marker is left out for objects whose runtime type equals the declared type. A polymorphic value always carries its marker.
IncludeVersionInfobooltrueWhen true, the engine writes a $version field for every [ScyllaSerializable]-marked type. Required for migration; safe to keep on by default.
PrettyPrintboolfalseWhen true, the JSON output is indented with newlines for readability. Adds significant whitespace; disable for production save files.
Indentstring"\t" (one tab)Indentation string for pretty-printed JSON. Applied to pretty-printed JSON by the facade, and by a ScyllaJSONWriter configured with Configure(settings).
IncludePrivateFieldsboolfalseWhen true, the reflection serializer also reads and writes every non-public field and every non-public property that has both a getter and a setter, including those declared on base classes. false limits it to public members and members marked [ScyllaInclude].
CustomSerializersIReadOnlyDictionary<Type, ITypeSerializer>emptyPer-settings serializer overrides, checked before the global registry. Add via Builder.RegisterSerializer<T>(serializer); preferred over the global ScyllaSerialization.RegisterSerializer<T> for code that should not mutate shared state.

Presets

PresetFormatDistinguishing propertiesUse case
SerializationSettings.DefaultJSONAll defaultsMost cases. Compact UTF-8 JSON, references preserved with $id / $ref, $type and $version markers, max depth 64.
SerializationSettings.ReadableJSONJSONPrettyPrint = trueConfiguration files, debug output, anything inspected by hand.
SerializationSettings.CompactBinaryBinaryNullHandling = Ignore, DefaultValueHandling = IgnoreSave files, network packets, anything where size or speed matters more than human inspectability.

Attribute summary

AttributeTargetEffect
[ScyllaSerializable(version = 1)]class, structMarks a type as participating in versioning. TypeAlias (property) sets a short name registered with TypeMetadataCache.
[ScyllaProperty(Name, Order, Required)]property, fieldOverrides the serialized property name, sets the sort order in output, or marks the property as required on deserialization.
[ScyllaIgnore]property, fieldSkips this member from serialization. Use for cached, computed, or runtime-only state.
[ScyllaInclude]property, fieldOpts a non-public member into serialization without changing its access level.
[ScyllaMigration(fromVersion, toVersion)]static methodMarks a static method as a migration from one schema version to a later one, which may skip versions. Signature: static T Migrate(T oldValue) where T is the declaring type.

Best Practices

  • Use the facade for the vast majority of work. ScyllaSerialization.ToJSON<T> / FromJSON<T> handles every common case correctly. Reach for direct reader/writer usage only when the standard shape is wrong for a specific scenario.
  • Always check IsSuccess before reading Value. The result types follow the no-throw pattern; reading Value on a failed result throws InvalidOperationException with the error message baked in. Use TryGetValue / GetValueOrDefault for safe access.
  • Mark every persistable type with [ScyllaSerializable(version: 1)] from day one. Adding versioning later requires careful migration of existing stored data. Starting with a version field costs nothing and unlocks the migration system for free.
  • Use [ScyllaProperty(Name = "...")] to lock the stored keys. C# identifiers tend to drift across refactors; explicit names survive renames. Once a save file is written with a given name, that name is part of the on-disk contract.
  • Pick the right ReferenceHandling per call. Preserve (the default) is right for genuine graphs (entity trees with back-references, multi-parent state). Use Error for data that must be a strict tree, so an accidental shared reference fails loudly. Avoid Ignore unless dropping repeated references is explicitly desired.
  • Leave EmitReferenceIDs on. The explicit $id is what keeps $ref correct when null or default values are skipped or a document is edited by hand. Turn it off only for an external consumer that cannot handle the marker.
  • Use [ScyllaIgnore] for cached or derived state. A computed property that depends on other fields, a cached lookup table, a runtime-only handle: every one of these should be skipped on serialize and recomputed on load.
  • Set [ScyllaProperty(Required = true)] sparingly. A missing field on an old save fails the load; the surface should be small enough to migrate cleanly. Use it for “must exist” fields like format-version sentinels and cryptographic salts.
  • Use SerializationSettings.CompactBinary for save files and network packets. Smaller and faster to parse, with no text, no whitespace, and no null/default values. Read the data back with the same settings. Pair with ScyllaFileUtil.SaveSecureObject for opaque save data, or with ScyllaFileUtil.WriteBytesAtomic when a crash must never leave a half-written save.
  • Use SerializationSettings.ReadableJSON for configuration files. The pretty-printed output is easy to inspect, easy to hand-edit, and easy to diff between revisions.
  • Pre-warm the metadata cache for hot paths. TypeMetadataCache.PreWarm(types) runs the reflection pass for a set of types at startup. Subsequent serialization calls become dictionary lookups. For a save with twenty types, pre-warming converts twenty first-call stalls into one startup pass.
  • Register custom serializers per-settings, not globally, in libraries. Per-settings registration keeps the framework’s global state untouched and avoids surprise interactions when two libraries register competing serializers. Use global registration at the application entry point.
  • Test migration paths end-to-end. A v1->v2->v3 chain that works on each step in isolation can still fail on the combined path if any intermediate step depends on context that disappears. Round-trip a v1 fixture through the current type and assert the resulting object is equivalent to a freshly-constructed current-version object. TypeMetadata.TryBuildMigrationPath is a cheap way to assert in a test that every old version still has a path.
  • Treat stored type names as a contract. The default $type marker is an assembly-qualified name, so a rename or a namespace move changes it. Give types that appear in polymorphic positions of a save a TypeAlias, keep it unique, and never change it once a save has been written with it.
  • Wrap direct reader/writer usage in try/catch over SerializationException. The reader/writer layer surfaces malformed input as a thrown exception; only the facade catches it. Custom serializers that want to expose a result-pattern API must do the wrapping themselves.

Pitfalls