Serialization¶
Note
Serialization types live in the com.trecs.serialization package, which must be installed separately from com.trecs.core.
Trecs ships an optional binary serialization framework for full ECS world state. It is the foundation for Recording & Playback and for save/load systems.
Overview¶
Compose just the pieces you need:
SerializerRegistry— maps types to their serializers.WorldStateSerializer— reads/writes the entire ECS world (components, sets, heaps, entity handles).BookmarkSerializer— captures and restores full state snapshots.RecordingHandler/PlaybackHandler— capture and replay simulation history (covered in the next page).
The static helper TrecsSerialization provides preset registrations so the common path is one line. Users that only need partial setup (e.g. save-game-only projects that skip the recording machinery) can call individual Register*Serializers helpers instead.
Quick start¶
// 1. Build a registry pre-populated with all Trecs serializers.
var registry = TrecsSerialization.CreateSerializerRegistry();
// 2. Register any custom serializers your game needs (see below).
// All blittable component types are auto-handled — no registration needed.
// registry.RegisterSerializer<MyCustomSerializer>();
// 3. Compose only the handlers you actually use.
var worldStateSerializer = new WorldStateSerializer(world);
var bookmarks = new BookmarkSerializer(worldStateSerializer, registry, world);
For a save-game flow you stop here and use BookmarkSerializer.SaveBookmark/LoadBookmark. For deterministic record/replay also construct RecordingHandler and PlaybackHandler (see the Recording & Playback page).
Granular registration¶
If you do not want everything, use the building-block helpers:
var registry = new SerializerRegistry();
TrecsSerialization.RegisterCoreSerializers(registry); // primitives, math types
TrecsSerialization.RegisterTrecsSerializers(registry); // ECS internals
// (skip RegisterRecordingSerializers — save-game-only project)
SerializerRegistry itself contains only generic RegisterBlit<T>, RegisterEnum<T>, RegisterSerializer<TSerializer> etc. — it has no knowledge of Trecs ECS types and can be used standalone.
Authoring a custom serializer¶
Most components are unmanaged structs and serialize automatically via the built-in blit serializer — no extra code required. You only need a custom ISerializer<T> for types that hold managed references (lists, dictionaries, strings) or where you want a non-default encoding.
public sealed class HighScoreTableSerializer : ISerializer<HighScoreTable>
{
public void Serialize(in HighScoreTable value, ISerializationWriter writer)
{
writer.Write("count", value.Entries.Count);
foreach (var entry in value.Entries)
{
writer.Write("name", entry.Name); // managed string
writer.Write("score", entry.Score);
}
}
public void Deserialize(ref HighScoreTable value, ISerializationReader reader)
{
value ??= new HighScoreTable();
var count = reader.Read<int>("count");
value.Entries.Clear();
for (int i = 0; i < count; i++)
{
value.Entries.Add(new HighScoreEntry
{
Name = reader.Read<string>("name"),
Score = reader.Read<int>("score"),
});
}
}
}
// Register before constructing any handler:
registry.RegisterSerializer<HighScoreTableSerializer>();
Field names are discarded
The name arguments passed to writer.Write / reader.Read are not persisted in the binary stream — they exist only for debug memory tracking (GetMemoryReport) and as self-documentation. The binary format is purely positional: reads must occur in the exact same order as writes. Renaming a field is a no-op on disk; reordering reads or adding/removing one without bumping the format version will silently corrupt deserialization.
Blit registrations¶
Unmanaged value types can be registered as a fast raw-byte copy:
registry.RegisterBlit<MyStruct>(); // serialize only
registry.RegisterBlit<MyStruct>(includeDelta: true); // also enable delta encoding
registry.RegisterEnum<MyEnum>(); // for enum types
Delta-capable types must implement IEquatable<T>.
Extending world state with non-ECS data¶
If your game has state that lives outside the ECS world (scripting VMs, external caches), subclass WorldStateSerializer to write/read your additional chunks:
public sealed class MyGameStateSerializer : WorldStateSerializer
{
readonly LuaInterpreter _lua;
public MyGameStateSerializer(World world, LuaInterpreter lua) : base(world)
{
_lua = lua;
}
public override void SerializeState(ISerializationWriter writer)
{
base.SerializeState(writer);
writer.Write("luaState", _lua.CaptureSnapshot());
}
public override void DeserializeState(ISerializationReader reader)
{
base.DeserializeState(reader);
_lua.RestoreSnapshot(reader.Read<string>("luaState"));
}
}
// Pass the subclass to BookmarkSerializer / RecordingHandler / PlaybackHandler.
var worldStateSer = new MyGameStateSerializer(world, lua);
var bookmarks = new BookmarkSerializer(worldStateSer, registry, world);
Buffer reuse¶
SerializationBuffer is the shared write/read buffer used internally by every handler. Most users never touch it directly — BookmarkSerializer.SaveBookmark(stream) and friends manage it for you. Power users who need to drive the binary reader/writer themselves can construct one explicitly:
using var buffer = new SerializationBuffer(registry);
buffer.WriteAll(value, version: 1, includeTypeChecks: true);
buffer.ResetMemoryPosition();
var roundTripped = buffer.ReadAll<MyType>();
Determinism notes¶
- Always use
World.Rng/World.FixedRngin fixed update — neverUnityEngine.RandomorSystem.Random. - Set
RequireDeterministicSubmission = trueinWorldSettingsif you intend to record and replay.
WorldSettings.AssertNoTimeInFixedPhase¶
For deterministic-lockstep workloads (e.g. RTS netcode) where the simulation must produce bit-identical results across machines, set AssertNoTimeInFixedPhase = true:
var settings = new WorldSettings
{
RequireDeterministicSubmission = true,
AssertNoTimeInFixedPhase = true,
};
Trecs guarantees deterministic scheduling, iteration, and entity ordering, but it cannot guarantee deterministic floating-point math across hardware. Reading continuous time values (DeltaTime, ElapsedTime, FixedDeltaTime, FixedElapsedTime) during the fixed-update phase is a common source of drift, because accumulated floating-point error diverges across machines.
With the flag enabled:
- Accessing any of those four properties on
WorldAccessorduring fixed update throws. - In Burst jobs (where exceptions are unavailable),
NativeWorldAccessor.DeltaTime/NativeWorldAccessor.ElapsedTimeare populated withfloat.NaNso any arithmetic that uses them produces visibly broken output instead of silent desync.
Use World.FixedFrame — a discrete tick counter — as your time source in fixed update instead. Variable-update systems are unaffected.
See Recording & Playback for the determinism-sensitive lifecycle and desync-detection workflow.
Threading¶
All BookmarkSerializer, RecordingHandler, and PlaybackHandler methods
are main-thread only. The underlying SerializationBuffer and the
blit fast-path use a shared static byte buffer, and every read/write
path asserts UnityThreadUtil.IsMainThread. Do not call save/load from
a background thread.
Binary format stability¶
The binary layout is version-sensitive and not forward-compatible:
- Adding, removing, or reordering fields on a blittable component changes the byte layout, invalidating every previously saved bookmark or recording that used the old shape.
- The
versioninteger you pass toSaveBookmark(version, …)/StartRecording(version, …)is stored in the file header and exposed onBookmarkMetadata.Version/RecordingMetadata.Version. Trecs does not interpret it — bumpingversionon a breaking change is a convention you own. - Use
BookmarkSerializer.PeekMetadata(path)(or(stream)) to inspect the saved version before committing to a fullLoadBookmark, and surface a user-facing error for incompatible saves.
Versioned custom serializers¶
The version integer you pass to SaveBookmark / StartRecording is stamped into the file header and exposed to custom serializers via ISerializationWriter.Version (during write) and ISerializationReader.Version (during read). Because the header records the version the file was written with, the deserializer can recognize older saves and read them with the layout they were written in. As long as you bump the version every time you change the on-disk layout, a single serializer can keep handling all prior versions:
public sealed class HighScoreTableSerializer : ISerializer<HighScoreTable>
{
public void Serialize(in HighScoreTable value, ISerializationWriter writer)
{
// The writer always emits the current (latest) layout. The version stored
// in the file header records *which* layout this is, so old files saved by
// earlier code are still readable below.
writer.Write("count", value.Entries.Count);
foreach (var entry in value.Entries)
{
writer.Write("name", entry.Name);
writer.Write("score", entry.Score);
writer.Write("timestamp", entry.Timestamp); // added in v2
}
}
public void Deserialize(ref HighScoreTable value, ISerializationReader reader)
{
value ??= new HighScoreTable();
var count = reader.Read<int>("count");
value.Entries.Clear();
for (int i = 0; i < count; i++)
{
var entry = new HighScoreEntry
{
Name = reader.Read<string>("name"),
Score = reader.Read<int>("score"),
// v1 saves don't contain timestamp; default it instead of reading.
Timestamp = reader.Version >= 2
? reader.Read<long>("timestamp")
: 0,
};
value.Entries.Add(entry);
}
}
}
The example only adds a field, but Version lets you handle removals and reorderings too — branch the deserializer on reader.Version and read the appropriate layout for each historical version. The constraint is that you have to keep the read-side code for every old version you still want to support, and bump version every time the layout changes.
If your game needs long-term save compatibility across deeper schema changes (renamed types, restructured aggregates, splitting one component into two), wrap the Trecs bookmark inside your own versioned format and perform migrations before calling LoadBookmark.