Serialization (Experimental)
Serialization is the process of converting a model's state into a format that can be saved to disk and loaded back later. This allows checkpointing long-running simulations, creating save files, or transferring models between systems.
Current limitations
Model state saving currently does not include pending events (events that have been scheduled but not yet executed). This means you can save the structure and data of your simulation, but not the exact event timeline. Full state saving including events is planned for a future release.
Future additions
Model assets, scheduled events, and essential runtimes will be added to the .ersmodel and folder formats in a future release.
Little will change to the serializer itself: the same serialization methods will likely continue to work as documented here.
Compatibility requirements
Everything in this guide serves to make models compatible with saving and loading. Implementing serialization properly for components and using only supported types is required for models to be saved. Following these patterns ensures models work with the serialization system.
How it works
The serializer provides a unified way to save and load data.
The same code handles both operations - checking IsWriting() determines whether saving or loading is occurring:
- When saving:
IsWriting()returnstrue. Code writes values to the serializer. - When loading:
IsWriting()returnsfalse. Code reads values from the serializer.
This design means the serialization logic only needs to be written once, and it automatically works bidirectionally.
Storage formats
Models can be saved in two formats:
- Folder: Separate JSON files for each simulator. Easy to inspect and debug.
- Archive: Compressed single file. Smaller, and easily sharable.
#include "Ers/IO/Serializer.h"
#include "Ers/Model/ModelContainer.h"
Ers::ModelContainer model = Ers::ModelContainer::Create();
// ... build the model ...
// Save to folder
Ers::Serializer::SaveToFolder(model, "path/to/save/folder");
// Load from folder
Ers::ModelContainer loadedModel = Ers::Serializer::LoadFromFolder("path/to/save/folder");
using Ers;
ModelContainer model = ModelContainer.Create();
// ... build the model ...
// Save to folder
Serializer.SaveToFolder(model, "path/to/save/folder");
// Load from folder
ModelContainer loadedModel = Serializer.LoadFromFolder("path/to/save/folder");
Compressed archives
Archives are compressed using zstd.
The compression level can be adjusted (1-22, default is 3). Higher compression levels reduce file size but take longer to save.
// Save with default compression (level 3)
Ers::Serializer::SaveToArchive(model, "model.ersmodel");
// Save with higher compression
Ers::Serializer::SaveToArchive(model, "model.ersmodel", 10);
// Load from archive
Ers::ModelContainer loaded = Ers::Serializer::LoadFromArchive("model.ersmodel");
// Load with caching (faster when loading same model multiple times)
Ers::ModelContainer loaded = Ers::Serializer::LoadFromArchive("model.ersmodel", true);
// Clear cache when done
Ers::Serializer::ClearArchiveCache("model.ersmodel");
// Save with default compression (level 3)
Serializer.SaveToArchive(model, "model.ersmodel");
// Save with higher compression
Serializer.SaveToArchive(model, "model.ersmodel", 10);
// Load from archive
ModelContainer loaded = Serializer.LoadFromArchive("model.ersmodel");
// Load with caching (faster when loading same model multiple times)
ModelContainer loaded = Serializer.LoadFromArchive("model.ersmodel", true);
// Clear cache when done
Serializer.ClearArchiveCache("model.ersmodel");
Serializing components
Components need to define how they save and load their data. Both data components and script behaviors can be serialized.
Script behaviors
Script behaviors override the Serialization method. The same code runs for both saving and loading - check IsWriting() to determine which operation is happening.
#include "Ers/SubModel/ScriptBehaviorComponent.h"
class MyComponent : public Ers::ScriptBehaviorComponent
{
public:
int Count;
float Temperature;
std::string Name;
void Serialization(Ers::Serializer serializer) override
{
serializer.Serialize("count", Count);
serializer.Serialize("temperature", Temperature);
serializer.Serialize("name", Name);
}
};
using Ers;
public class MyComponent : ScriptBehaviorComponent
{
public int Count;
public float Temperature;
public string Name;
public override void Serialization(Serializer serializer)
{
serializer.Serialize("count", ref Count);
serializer.Serialize("temperature", ref Temperature);
serializer.Serialize("name", ref Name);
}
}
Data components
Data components can be serialized in two ways: using the type system or a custom serialization method. If both are present, the custom serialization method takes precedence.
Using the type system
The type system tells ERS which fields to serialize automatically. This works well for simple data structures with only primitive types.
#include "Ers/SubModel/DataComponent.h"
#include "Ers/SubModel/TypeInfo.h"
class InventoryData : public Ers::DataComponent
{
public:
int ItemCount;
float Weight;
bool IsFull;
static const Ers::TypeInfo* GetTypeInfo()
{
static Ers::TypeInfo* typeInfo = nullptr;
if (typeInfo == nullptr)
{
typeInfo = Ers::TypeRegistry::RegisterStruct("InventoryData");
typeInfo->AddField("item_count", Ers::BuiltinType::Int32,
offsetof(InventoryData, ItemCount));
typeInfo->AddField("weight", Ers::BuiltinType::Float32,
offsetof(InventoryData, Weight));
typeInfo->AddField("is_full", Ers::BuiltinType::Bool,
offsetof(InventoryData, IsFull));
}
return typeInfo;
}
};
using System.Runtime.InteropServices;
using Ers;
[TypeInfo("InventoryData")]
[StructLayout(LayoutKind.Sequential)]
public struct InventoryData : IDataComponent
{
[FieldInfo("item_count")]
public int ItemCount;
[FieldInfo("weight")]
public float Weight;
[FieldInfo("is_full")]
public bool IsFull;
}
Entity references in TypeInfo
When adding entity reference fields to TypeInfo, the field type must be explicitly set to BuiltinType::Entity in C++ or BuiltinType.Entity in C#.
class MyData : public Ers::DataComponent
{
public:
Ers::EntityID TargetEntity;
static const Ers::TypeInfo* GetTypeInfo()
{
static Ers::TypeInfo* typeInfo = nullptr;
if (typeInfo == nullptr)
{
typeInfo = Ers::TypeRegistry::RegisterStruct("MyData");
typeInfo->AddField("target", Ers::BuiltinType::Entity,
offsetof(MyData, TargetEntity));
}
return typeInfo;
}
};
[TypeInfo("MyData")]
[StructLayout(LayoutKind.Sequential)]
public struct MyData : IDataComponent
{
[FieldInfo("target", BuiltinType.Entity)]
public Entity TargetEntity;
}
Data component limitations
Data components cannot contain any heap-allocated data. This means no std::vector, std::string, List<T>, or any collection types in both C++ and C#.
Data components must only contain primitive types (int, float, bool, etc.) and entity references.
TypeInfo only supports these primitive types. If you need to save collections, strings, or complex data structures, use a script behavior instead.
Supported types
The following types can be serialized directly:
Primitive types
| Type | C++ | C# |
|---|---|---|
| 32-bit integer | int32_t |
int |
| 32-bit unsigned | uint32_t |
uint |
| 64-bit integer | int64_t |
long |
| 64-bit unsigned | uint64_t |
ulong |
| 32-bit float (Single precision) | float |
float |
| 64-bit float (Double precision) | double |
double |
| Boolean | bool |
bool |
| String | std::string |
string |
Collections
| Type | C++ | C# |
|---|---|---|
| List | std::vector<T> |
List<T> |
| Queue | std::queue<T> |
Queue<T> |
| Map | std::map<K,V> |
Dictionary<K,V> |
| Unordered Map | std::unordered_map<K,V> |
❌ |
Collections support nesting to any depth (e.g., List<Queue<Dictionary<int, float>>>).
Custom types
Any type with a Serialization method can be saved.
struct ItemData
{
int ID;
std::string Name;
float Value;
void Serialization(Ers::Serializer& serializer)
{
serializer.Serialize("id", ID);
serializer.Serialize("name", Name);
serializer.Serialize("value", Value);
}
};
// Use in components or other custom types
class MyComponent : public Ers::ScriptBehaviorComponent
{
public:
std::vector<ItemData> Items;
void Serialization(Ers::Serializer serializer) override
{
serializer.Serialize("items", Items);
}
};
public struct ItemData
{
public int ID;
public string Name;
public float Value;
public void Serialization(Serializer serializer)
{
serializer.Serialize("id", ref ID);
serializer.Serialize("name", ref Name);
serializer.Serialize("value", ref Value);
}
}
// Use in components or other custom types
public class MyComponent : ScriptBehaviorComponent
{
public List<ItemData> Items;
public override void Serialization(Serializer serializer)
{
serializer.Serialize("items", ref Items);
}
}
Advanced usage
Conditional serialization
Different data can be saved depending on whether saving or loading is occurring.
void Serialization(Ers::Serializer serializer) override
{
if (serializer.IsWriting())
{
// Prepare data before saving
CachedValue = CalculateExpensiveValue();
}
serializer.Serialize("cached_value", CachedValue);
if (!serializer.IsWriting())
{
// Restore state after loading
RebuildInternalStructures();
}
}
public override void Serialization(Serializer serializer)
{
if (serializer.IsWriting())
{
// Prepare data before saving
CachedValue = CalculateExpensiveValue();
}
serializer.Serialize("cached_value", ref CachedValue);
if (!serializer.IsWriting())
{
// Restore state after loading
RebuildInternalStructures();
}
}
Manual array control
For types not natively supported (like custom collections or third-party types), serialization can be manually controlled using the low-level push/pop API.
Understanding push and pop:
PushArray(key, size)- Opens an array context. When saving, pass the current size. When loading, the size is updated to match what was saved.Serialize(index, value)- Stores a value at a specific array index.PushObject(key)- Opens an object context for grouping related data.SetObject(index)- Opens an object context for a specific array element.PopArray()/PopObject()- Closes the current context.
Push and pop calls must always be balanced. Think of them as opening and closing brackets in JSON.
Direct array indexing
For simple arrays of primitive types, use direct indexing.
void Serialization(Ers::Serializer serializer) override
{
size_t size = Values.size();
serializer.PushArray("values", size);
if (!serializer.IsWriting())
{
Values.clear();
Values.reserve(size);
}
for (size_t i = 0; i < size; i++)
{
if (serializer.IsWriting())
{
serializer.Serialize(i, Values[i]); // Store directly at index i
}
else
{
float val;
serializer.Serialize(i, val);
Values.push_back(val);
}
}
serializer.PopArray();
}
public override void Serialization(Serializer serializer)
{
int size = serializer.IsWriting() ? Values.Count : 0;
serializer.PushArray("values", ref size);
if (!serializer.IsWriting())
{
Values = new List<float>();
}
for (int i = 0; i < size; i++)
{
if (serializer.IsWriting())
{
float val = Values[i];
serializer.Serialize(i, ref val); // Store directly at index i
}
else
{
float val = 0;
serializer.Serialize(i, ref val);
Values.Add(val);
}
}
serializer.PopArray();
}
Array of objects
For arrays where each element has multiple fields, use SetObject to create an object at each index.
void Serialization(Ers::Serializer serializer) override
{
size_t size = Items.size();
serializer.PushArray("items", size);
if (!serializer.IsWriting())
{
Items.clear();
Items.reserve(size);
}
for (size_t i = 0; i < size; i++)
{
serializer.SetObject(i); // Opens object context for array element i
if (serializer.IsWriting())
{
serializer.Serialize("id", Items[i].ID);
serializer.Serialize("name", Items[i].Name);
serializer.Serialize("quantity", Items[i].Quantity);
}
else
{
ItemData item;
serializer.Serialize("id", item.ID);
serializer.Serialize("name", item.Name);
serializer.Serialize("quantity", item.Quantity);
Items.push_back(item);
}
serializer.PopObject(); // Closes object context opened by SetObject
}
serializer.PopArray();
}
public override void Serialization(Serializer serializer)
{
int size = serializer.IsWriting() ? Items.Count : 0;
serializer.PushArray("items", ref size);
if (!serializer.IsWriting())
{
Items = new List<ItemData>();
}
for (int i = 0; i < size; i++)
{
serializer.SetObject(i); // Opens object context for array element i
if (serializer.IsWriting())
{
var item = Items[i];
serializer.Serialize("id", ref item.ID);
serializer.Serialize("name", ref item.Name);
serializer.Serialize("quantity", ref item.Quantity);
}
else
{
var item = new ItemData();
serializer.Serialize("id", ref item.ID);
serializer.Serialize("name", ref item.Name);
serializer.Serialize("quantity", ref item.Quantity);
Items.Add(item);
}
serializer.PopObject(); // Closes object context opened by SetObject
}
serializer.PopArray();
}
Nested objects
Data can be organized into logical groups.
void Serialization(Ers::Serializer serializer) override
{
serializer.PushObject("player_data");
serializer.Serialize("health", PlayerHealth);
serializer.Serialize("score", PlayerScore);
serializer.PopObject();
serializer.PushObject("inventory");
serializer.Serialize("capacity", InventoryCapacity);
serializer.Serialize("items", Items);
serializer.PopObject();
}
public override void Serialization(Serializer serializer)
{
serializer.PushObject("player_data");
serializer.Serialize("health", ref PlayerHealth);
serializer.Serialize("score", ref PlayerScore);
serializer.PopObject();
serializer.PushObject("inventory");
serializer.Serialize("capacity", ref InventoryCapacity);
serializer.Serialize("items", ref Items);
serializer.PopObject();
}
Saving entity references
Entity references can be serialized directly: they're just primitive ID values.
However, ScriptBehaviorComponent references (pointers/references to other components) cannot be serialized directly and must be resolved after loading.
Entity references
Entities can be saved and referenced by their ID.
class MyBehavior : public Ers::ScriptBehaviorComponent
{
public:
EntityID TargetEntity;
void Serialization(Ers::Serializer serializer) override
{
// Entity IDs serialize directly
serializer.Serialize("target_entity", TargetEntity);
}
void OnAwake() override
{
// Access the target entity after loading
auto& subModel = Ers::Get();
if (TargetEntity != Ers::Entity::InvalidEntity)
{
auto* targetComponent = subModel.GetComponent<SomeComponent>(TargetEntity);
// Use targetComponent...
}
}
};
public class MyBehavior : ScriptBehaviorComponent
{
public Entity TargetEntity;
public override void Serialization(Serializer serializer)
{
// Entity IDs serialize directly
serializer.Serialize("target_entity", ref TargetEntity);
}
public override void OnAwake()
{
// Access the target entity after loading
SubModel subModel = SubModel.Get();
if (TargetEntity != 0)
{
var targetComponent = subModel.GetComponent<SomeComponent>(TargetEntity);
// Use targetComponent...
}
}
}
Script behavior references
Pointers or references to other script behaviors cannot be serialized directly.
Instead, save the entity ID and resolve the component reference in OnAwake() or OnStart().
For script behavior pointers or references to other script behaviors on the ConnectedEntity you do not need the Serialization() function, but still have to re-acquire in OnAwake().
class MyBehavior : public Ers::ScriptBehaviorComponent
{
public:
EntityID ReferencedEntityId;
OtherBehavior* ReferencedBehavior; // Cannot serialize this pointer!
void Serialization(Ers::Serializer serializer) override
{
// Save the entity ID, NOT the pointer
serializer.Serialize("referenced_entity_id", ReferencedEntityId);
// ReferencedBehavior is NOT serialized
}
void OnAwake() override
{
// Re-acquire the component reference after loading
auto& subModel = Ers::Get();
if (ReferencedEntityId != Ers::Entity::InvalidEntity)
{
ReferencedBehavior = subModel.GetComponent<OtherBehavior>(ReferencedEntityId);
}
}
void OnStart() override
{
// ReferencedBehavior can now be safely used
if (ReferencedBehavior != nullptr)
{
ReferencedBehavior->DoSomething();
}
}
};
public class MyBehavior : ScriptBehaviorComponent
{
public Entity ReferencedEntityId;
public OtherBehavior ReferencedBehavior; // Cannot serialize this reference!
public override void Serialization(Serializer serializer)
{
// Save the entity ID, NOT the reference
serializer.Serialize("referenced_entity_id", ref ReferencedEntityId);
// ReferencedBehavior is NOT serialized
}
public override void OnAwake()
{
// Re-acquire the component reference after loading
SubModel subModel = SubModel.Get();
if (ReferencedEntityId != 0)
{
ReferencedBehavior = subModel.GetComponent<OtherBehavior>(ReferencedEntityId);
}
}
public override void OnStart()
{
// ReferencedBehavior can now be safely used
if (ReferencedBehavior != null)
{
ReferencedBehavior.DoSomething();
}
}
}
Why this pattern?
Component pointers/references point to memory addresses that are only valid during the current session.
After loading, components are in different memory locations, so the old pointers would be invalid.
By saving the entity ID and re-acquiring the component in OnAwake(), the reference is ensured to always be valid.
What gets serialized
When saving a model, ERS saves:
- All simulators and their configuration.
- All entities and their hierarchy (parent/child relationships).
- All components attached to entities.
- Component data (as defined by their Serialization methods or TypeInfo).
ERS does not save:
- Pending events (events scheduled but not yet executed) - planned for future release.
- File handles or external resources.
- Callback functions or delegates.
Testing serialization
Since event serialization is not yet supported, the recommended way to test serialization is to save a freshly built model and load it back before running. See Running replications for how to use this pattern to run many parallel simulations efficiently.