48 Core Systems – Persistence Contracts

13 min read

The small save contract that lives in Core: an interface a system implements to make its state saveable, and a static registry that the Persistence module reads, so any module can take part in a save without referencing it.

Summary

The persistence contracts are the part of saving that lives in Core. A system that owns game state implements IScyllaPersistable, which asks for four things: a stable partition ID, the schema version of its state, a way to capture that state as a plain object, and a way to restore it. The system then registers itself with ScyllaPersistenceRegistry, a static list that anything in the process can read. That is the whole surface. Core does not write files, pick a format or decide when a save happens.

The Scylla Persistence module is the consumer. It reads the registry, captures each registered system into its own partition of a save file, and hands the state back through RestoreState after a load. Because the contract and the registry live in Core, a module can make its state saveable while referencing nothing but Core. Persistence sits in Tier 1, so a direct reference from another Tier 1 module, such as Data or Audio, would break the rule that modules in the same tier never reference each other. Registering with the Core registry keeps both sides independent: neither module knows the other exists, and a project without the Persistence module still compiles and runs.

Most implementations use the typed sibling, IScyllaPersistable<TState>. It declares a strongly typed CaptureState and RestoreState pair and answers StateType from its type argument, so an implementation writes only the typed members.

Use the persistence contracts for:

  • Module state that should survive a save and load, such as a weather system’s current front, a quest log, or a music player’s position.
  • Framework modules and shared libraries that must not take a dependency on the Persistence module.
  • Any system that wants to be saved by whichever persistence layer the project uses, as long as that layer reads ScyllaPersistenceRegistry.

Game code that already references the Persistence module can use that module’s own provider interface instead, which adds capture and restore contexts and a per-partition policy. See Providers in the Persistence documentation.

Features

  • IScyllaPersistable. The non-generic contract: PartitionID, SchemaVersion, StateType, CaptureState() and RestoreState(object, int).
  • IScyllaPersistable<TState>. The typed contract. Default interface implementations bridge the object-typed members onto the typed CaptureState() and RestoreState(TState, int), and StateType returns typeof(TState).
  • IScyllaPersistable.Empty. A shared no-op implementation for places that need a default value instead of a null reference.
  • ScyllaPersistenceRegistry. A static registry with Register, Unregister, GetRegistered and IsRegistered, plus Registered and Unregistered events.
  • Duplicate rejection. A second registration under a partition ID that another instance already claims is refused and logged as an error. Registering the same instance twice is refused without a log entry.
  • Thread-safe registry. A lock guards the list, and GetRegistered returns a copy, so a stray call from another thread does not corrupt it.
  • ScyllaPartitionID. A struct reserved for the framework’s own partition ID constants, mirroring how ScyllaModuleID reserves module IDs. It declares no constants yet.

Concepts

A partition is the slice of a save that belongs to one system. Its partition ID is the key that routes the stored payload back to the system that wrote it, so it must be unique among registered systems and must never change between versions of a game. The schema version is an integer that describes the shape of the captured state. When the state class changes shape, the version goes up, and a save written under an older version can be recognized and migrated. The state is a plain object, usually a small class or struct with public fields, that holds a copy of the values worth saving.

ConceptTypeNotes
IScyllaPersistableinterfaceThe contract a saveable system implements. PartitionID (string), SchemaVersion (int), StateType (Type), CaptureState() returns the state object, RestoreState(object state, int savedSchemaVersion) applies it.
IScyllaPersistable<TState>generic interfaceExtends IScyllaPersistable. Declares TState CaptureState() and RestoreState(TState state, int savedSchemaVersion). Implements StateType and the object-typed members for you.
IScyllaPersistable.Emptystatic readonly fieldNo-op implementation. PartitionID is empty, SchemaVersion is 0, StateType is object, CaptureState returns null, and RestoreState does nothing.
ScyllaPersistenceRegistrystatic classThe list of registered systems, in registration order. Register and Unregister return whether they changed the list. GetRegistered returns a snapshot array. IsRegistered(partitionID) checks a partition ID.
Registered / UnregisteredAction<IScyllaPersistable> eventsRaised after a registration succeeds or a registered system is removed. A save system subscribes to these to follow the list.
ScyllaPartitionIDstructPlaceholder for the framework’s reserved partition ID constants. A project keeps its own IDs in an equivalent struct in its own assembly.

The contract describes what a save system can expect, but it does not enforce timing. The Persistence module calls CaptureState on the main thread within a single frame and calls RestoreState on the main thread after the payload has been decoded and, where needed, migrated to the current schema version. Another save system that reads the registry is expected to follow the same rules.

Recipes

Each recipe stands on its own. If you are new to the contracts, start with “Make a module’s state saveable”, which covers the common case from start to finish.

Make a module’s state saveable

Problem. A module holds state that a player expects to find again after loading a save. The module must not reference the Persistence module, either because it sits in the same tier or because it should also work in projects that do not use Persistence.

Solution. Define a plain state class, implement IScyllaPersistable<TState> on the module, and register the module with ScyllaPersistenceRegistry in OnInitialize. Unregister it in OnShutdown, so a module that has been shut down never contributes a stale partition to a later save. Capture copies the values the module needs into a new state object; restore writes them back.

using System;
using Scylla.Core.Modules;
using Scylla.Core.Persistence;


[Serializable]
public sealed class WeatherState
{
	public string FrontID;
	public float Intensity;
	public float HoursRemaining;
}


public sealed class WeatherModule : ScyllaModule, IScyllaPersistable<WeatherState>
{
	public const string MODULE_ID = "Weather";
	public const string PARTITION_ID = "Game.Weather";

	private string _frontID = "Clear";
	private float _intensity;
	private float _hoursRemaining;

	public override ScyllaModuleInfo Info => new ScyllaModuleInfo(MODULE_ID, "Weather", "1.0.0",
		"Drives the weather fronts.", null, 150);

	/* The key a save uses to route this partition back to this module. Never change it. */
	public string PartitionID => PARTITION_ID;

	/* Raise this whenever WeatherState changes shape. */
	public int SchemaVersion => 1;


	public WeatherState CaptureState()
	{
		/* Copy values; do no expensive work here. */
		return new WeatherState
		{
			FrontID = _frontID,
			Intensity = _intensity,
			HoursRemaining = _hoursRemaining
		};
	}


	public void RestoreState(WeatherState state, int savedSchemaVersion)
	{
		_frontID = state.FrontID;
		_intensity = state.Intensity;
		_hoursRemaining = state.HoursRemaining;
	}


	protected override void OnInitialize()
	{
		ScyllaPersistenceRegistry.Register(this);
	}


	protected override void OnShutdown()
	{
		ScyllaPersistenceRegistry.Unregister(this);
	}
}

The module never mentions the Persistence module. When Persistence is present, it picks the registration up and saves the partition. When it is absent, the registration is simply an entry in a list that nobody reads.

Implement the non-generic contract

Problem. The state type is only known at run time, for example because a system saves one of several state classes depending on how it was set up, so the typed interface does not fit.

Solution. Implement IScyllaPersistable directly and report the concrete type through StateType. CaptureState returns an object of that type, and RestoreState receives an object of that type after deserialization.

using System;
using Scylla.Core.Persistence;


public sealed class DynamicStatePersistable : IScyllaPersistable
{
	private readonly Func<object> _capture;
	private readonly Action<object, int> _restore;


	public DynamicStatePersistable(string partitionID, int schemaVersion, Type stateType,
		Func<object> capture, Action<object, int> restore)
	{
		PartitionID = partitionID;
		SchemaVersion = schemaVersion;
		StateType = stateType;
		_capture = capture;
		_restore = restore;
	}


	public string PartitionID { get; }
	public int SchemaVersion { get; }
	public Type StateType { get; }


	public object CaptureState()
	{
		/* Must return an object of StateType, never null. */
		return _capture();
	}


	public void RestoreState(object state, int savedSchemaVersion)
	{
		_restore(state, savedSchemaVersion);
	}
}

Check and inspect the registry

Problem. Tooling, a debug overlay or a test needs to know which systems are currently registered, or whether a partition ID is already taken before registering under it.

Solution. GetRegistered returns a snapshot array in registration order. Changing the registry afterwards does not change the array, so it is safe to iterate while systems register and unregister. IsRegistered checks a partition ID without allocating. Register and Unregister return false when they did not change the list.

using Scylla.Core;
using Scylla.Core.Persistence;


/* Snapshot of everything currently registered. */
var registered = ScyllaPersistenceRegistry.GetRegistered();
for (var i = 0; i < registered.Length; i++)
{
	var persistable = registered[i];
	Log.Info($"{persistable.PartitionID} v{persistable.SchemaVersion} ({persistable.StateType.Name})", LogCategory.Persistence);
}

/* Is a partition ID already claimed? */
if (!ScyllaPersistenceRegistry.IsRegistered(WeatherModule.PARTITION_ID))
{
	/* Register returns false for a claimed ID or an instance that is already registered. */
	var added = ScyllaPersistenceRegistry.Register(weatherModule);
}

Follow registrations from a save system

Problem. A save system, or an editor window that lists saveable systems, needs to keep its own view of the registry in step with systems that come and go while the game runs.

Solution. Read GetRegistered once to pick up systems that registered before you attached, then subscribe to Registered and Unregistered for everything that follows. Both events are raised after the change, outside the registry’s lock, on the thread that made the change. Unsubscribe when the listener goes away; the registry is static and keeps its subscribers until they remove themselves.

using Scylla.Core.Persistence;


public sealed class SaveableSystemsView
{
	public void Attach()
	{
		var existing = ScyllaPersistenceRegistry.GetRegistered();
		for (var i = 0; i < existing.Length; i++)
		{
			OnPersistableRegistered(existing[i]);
		}

		ScyllaPersistenceRegistry.Registered += OnPersistableRegistered;
		ScyllaPersistenceRegistry.Unregistered += OnPersistableUnregistered;
	}


	public void Detach()
	{
		ScyllaPersistenceRegistry.Registered -= OnPersistableRegistered;
		ScyllaPersistenceRegistry.Unregistered -= OnPersistableUnregistered;
	}


	private void OnPersistableRegistered(IScyllaPersistable persistable)
	{
		/* Add a row, create an adapter, and so on. */
	}


	private void OnPersistableUnregistered(IScyllaPersistable persistable)
	{
		/* Remove the row or adapter. */
	}
}

This is how the Persistence module itself works: it mirrors the Core registry into its own provider registry and wraps each Core implementation in an adapter.

Use the empty implementation as a default

Problem. A field or parameter of type IScyllaPersistable needs a value before the real system is known, and null checks at every call site would clutter the code.

Solution. Use IScyllaPersistable.Empty. It is a shared instance whose members do nothing: the partition ID is empty, CaptureState returns null, and RestoreState ignores its input.

using Scylla.Core.Persistence;


public sealed class PersistableSlot
{
	private IScyllaPersistable _target = IScyllaPersistable.Empty;


	public void Bind(IScyllaPersistable target)
	{
		_target = target ?? IScyllaPersistable.Empty;
	}
}

API Sketch

namespace Scylla.Core.Persistence
{
	public interface IScyllaPersistable
	{
		string PartitionID { get; }
		int SchemaVersion { get; }
		Type StateType { get; }

		object CaptureState();
		void RestoreState(object state, int savedSchemaVersion);

		/* No-op fallback instance. */
		public static readonly IScyllaPersistable Empty;
	}


	public interface IScyllaPersistable<TState> : IScyllaPersistable
	{
		new TState CaptureState();
		void RestoreState(TState state, int savedSchemaVersion);

		/* Implemented by the interface: StateType returns typeof(TState), and the
		 * object-typed CaptureState and RestoreState forward to the typed pair. */
	}


	public static class ScyllaPersistenceRegistry
	{
		public static event Action<IScyllaPersistable> Registered;
		public static event Action<IScyllaPersistable> Unregistered;

		public static bool Register(IScyllaPersistable persistable);
		public static bool Unregister(IScyllaPersistable persistable);
		public static IScyllaPersistable[] GetRegistered();
		public static bool IsRegistered(string partitionID);
	}


	/* Reserved for the framework's own partition ID constants. Currently empty. */
	public struct ScyllaPartitionID
	{
	}
}

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

Best Practices

  • Treat the partition ID as permanent. It is the key a save file uses to find the system that owns a payload. Renaming it orphans every existing save’s data for that system. Keep the IDs in public const string fields, ideally in one struct per project, the way ScyllaPartitionID is meant to hold the framework’s own.
  • Pick IDs that cannot collide. A prefix such as Game. or the studio name keeps a project’s partitions apart from those of the framework and of third-party code.
  • Register in OnInitialize, unregister in OnShutdown. The registry is static and outlives the framework, so a system that never unregisters stays in the list after its module has shut down.
  • Keep capture cheap. CaptureState runs on the main thread inside one frame. Copy values into a fresh state object and leave serialization, compression and file access to the save system.
  • Return a copy, not live data. A state object that shares collections with the running system can change while the save system is still encoding it. Copy lists and arrays into the state object.
  • Raise SchemaVersion with every change to the state’s shape. The saved version is passed back to RestoreState, and the save system uses it to decide whether a migration has to run first.
  • Keep the state type plain. Public fields or simple properties of serializable types, and no references back into the running system. Anything the save system’s serializer cannot write does not survive the trip.

Pitfalls