We have adopted the same loading screen that we made in Academia: School Simulator to our new game City Hall Simulator. The cool thing about this loading screen is that it shows a progress bar which I think is a good UX feature so the player knows how much further along the game is going to load stuff.

The Setup
Our persistence (save/load) framework operates on a list of serializers. A serializer is just an instance that implements the ISerializer interface:
public interface ISerializer {
string Name { get; }
void Write(XmlWriter writer);
void Read(SimpleXmlNode node);
// Used for ECS stuff
void Read(SimpleXmlNode node, ref NativeHashMap<uint, Entity> worldEntityMap);
}
This interface is very straightforward. A serializer can write stuff to the passed XmlWriter or read stuff from the passed XML node. We use XML for our save files. I won’t go in depth why but the main reason is that we already have the existing code for it and I also think that it’s good for the players to be able to explore the save file either out of curiosity or so that he/she can change stuff.
We then make a bunch of serializers that the game needs. Since we use ECS, most of our serializers are systems. Here’s an example that saves the seed used in ground generation:
[DisableAutoCreation]
public class GroundSerializerSystem : SystemBase, ISerializer {
private EventDispatcherSystem? eventDispatcherSystem;
protected override void OnCreate() {
this.eventDispatcherSystem = this.GetOrCreateSystem<EventDispatcherSystem>();
// Should not update
this.Enabled = false;
}
public string Name {
get {
return "Ground";
}
}
private const string SEED = "seed";
public void Write(XmlWriter writer) {
if (TryGetSingleton(out Ground ground)) {
writer.WriteAttributeString(SEED, ground.seed.ToString(CultureInfo.InvariantCulture));
}
}
public void Read(SimpleXmlNode node) {
if (this.eventDispatcherSystem == null) {
throw new CantBeNullException(nameof(this.eventDispatcherSystem));
}
Option<float> seed = node.GetFloatAttributeAsOption(SEED);
GenerateNewGround generateNewGround = seed.IsSome ? new GenerateNewGround(seed.ValueOr(default)) :
new GenerateNewGround();
this.eventDispatcherSystem.Dispatch(generateNewGround);
}
public void Read(SimpleXmlNode node, ref NativeHashMap<uint, Entity> worldEntityMap) {
}
protected override void OnUpdate() {
}
}
We have made a bunch of base serializer systems so that writing serializers would be easier. For example, we made a generic serializer that serializes a certain component type such that these serializer systems are made with just deriving from the base system:
// Individual here is an IComponentData
public class IndividualComponentsSerializerSystem : ComponentSerializerSystem<Individual> {
}
There are also non-system serializers like this:
public class CameraSerializer : ISerializer {
private readonly Camera? worldCamera;
public string Name {
get {
return "Camera";
}
}
public CameraSerializer() {
this.worldCamera = UnityUtils.GetRequiredComponent<Camera>("WorldCamera");
}
private const string WORLD_CAMERA_POSITION = "WorldPosition";
private const string WORLD_ORTHO_SIZE = "WorldOrthoSize";
public void Write(XmlWriter writer) {
if (this.worldCamera == null) {
return;
}
writer.WriteAttributeString(WORLD_CAMERA_POSITION, SerializeUtils.ToSerializedString(this.worldCamera.transform.position));
writer.WriteAttributeString(WORLD_ORTHO_SIZE, this.worldCamera.orthographicSize.ToString(CultureInfo.InvariantCulture));
}
public void Read(SimpleXmlNode node) {
if (this.worldCamera == null) {
throw new CantBeNullException(nameof(this.worldCamera));
}
SerializeUtils.SetLoadingTextByTermId(this.Name);
this.worldCamera.transform.position = SerializeUtils.ToFloat3(node.GetAttribute(WORLD_CAMERA_POSITION));
float worldCameraOrtho = node.GetAttributeAsFloat(WORLD_ORTHO_SIZE);
this.worldCamera.orthographicSize = worldCameraOrtho;
}
public void Read(SimpleXmlNode node, ref NativeHashMap<uint, Entity> worldEntityMap) {
}
}
We then maintain a script that manages the list of all ISerializer instances. We call it PersistenceManager. It’s also the one that executes the reads (loading) and writes (saving) of all serializers. Part of it’s Awake() looks like this:
World world = World.DefaultGameObjectInjectionWorld;
// Common components
AddComponentSerializer(world.GetOrCreateSystem<TranslationSerializerSystem>());
// Agent components
AddComponentSerializer(world.GetOrCreateSystem<AgentSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<TaskDoerSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<AgentViewSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<InventorySerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<NeedsComponentSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<UrbaniteComponentSerializerSystem>());
// Kitchen and Cafeteria components
AddComponentSerializer(world.GetOrCreateSystem<FoodCratesSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<RefrigeratorSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<RawFoodHaulerAgentsSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<FoodCounterSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<KitchenSinkSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<PlateCounterSerializerSystem>());
AddComponentSerializer(new FoodTruckSerializer());
// Components for tasks
AddComponentSerializer(world.GetOrCreateSystem<RootTaskSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<ChildTaskCountSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<ChildTaskSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<WorkerTaskSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<WorkerChildTasksSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<TaskPositionSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<RemovalTaskSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<HaulRawFoodTasksSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<HaulRawFoodChildTasksSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<HaulFoodCratesFromTruckTasksSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<HaulFoodCratesFromTruckChildTasksSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<ZonesSerializerSystem>());
// Documents
AddComponentSerializer(world.GetOrCreateSystem<DocumentNeedSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<DocumentApplicationSerializerSystem>());
// Background City components
AddComponentSerializer(world.GetOrCreateSystem<ShelterComponentsSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<HouseholdComponentsSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<HouseholdMemberSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<BusinessComponentsSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<SchoolComponentsSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<IndividualComponentsSerializerSystem>());
AddComponentSerializer(world.GetOrCreateSystem<EducationInfoComponentsSerializerSystem>());
AddComponentSerializer(new CameraSerializer());
The Trick
Now that you know the setup, how do we actually load the game? You might think that it’s just a for loop and you would be partially right:
private void LoadFromFile(string filePath) {
// ... Load XML from specified file
// Call Read() from all serializers
for(int i = 0; i < this.serializers.Count; ++i) {
ISerializer serializer = this.serializers[i];
SimpleXmlNode node = rootNode.FindNodeInChildren(serializer.Name);
serializer.Read(node);
}
}
The problem with this code it may take a long time to execute if you have lots of serializers and it’s trying to load a huge save file. What will happen is that the game will look like it’s hanging. What do you think the player does when this happens? What do you do when a game you play seems like it’s hanging? You start to button mash, don’t you. You click the mouse multiple times, you press some random keys. When you do this, it’s possible that the OS (usually Windows) will show a prompt that the program is not responding. Now, it doesn’t just look like it’s hanging. It also looks like it has crashed. Even though that’s not the case, it will look that way to the player.
The solution is simple. Just run the serializers in multiple frames. You can execute one serializer per frame. This can be easily done by using a coroutine:
private IEnumerator LoadFromFile(string filePath) {
// ... Load XML from specified file
// Call Read() from all serializers
for(int i = 0; i < this.serializers.Count; ++i) {
ISerializer serializer = this.serializers[i];
SimpleXmlNode node = rootNode.FindNodeInChildren(serializer.Name);
serializer.Read(node);
yield return null;
}
}
Our actual code is a lot more complicated than this but this is the jist. What we’re doing is we run a single serializer per frame which should be lighter/faster than executing all of them at once. We are showing the illusion that the game is not hogging the resources due to some large computation. This prevents the game from looking like it’s hanging. The fun thing is that it also has the side effect of giving back control to other parts of the game. This means that you can update some UI or update some animation or whatever. The sky is the limit. A simple one is to show a loading progress.
Loading Progress
After running deserialization (loading) in multiple frames, the rest is easy. Let’s just say that you’re using multiple scenes in your game. You can create or setup a separate UI scene that shows the loading progress. It can really be just a Canvas with a Slider like this:
We can use signals to update the loading progress. The script that handles the loading screen can look like this:
public class LoadingScreenHandler : MonoBehaviour {
[SerializeField]
private Slider progressBar;
private void Awake() {
Assertion.NotNull(this.progressBar);
GameSignals.SET_LOADING_SCREEN_PROGRESS.AddListener(SetProgress);
}
private void SetProgress(float progress) {
this.progressBar.value = progress;
}
}
Back to LoadFromFile() method, we can easily update the progress bar like this:
private IEnumerator LoadFromFile(string filePath) {
// ... Load XML from specified file
// Call Read() from all serializers
for(int i = 0; i < this.serializers.Count; ++i) {
ISerializer serializer = this.serializers[i];
SimpleXmlNode node = rootNode.FindNodeInChildren(serializer.Name);
serializer.Read(node);
// Update progress
float progress = (i + 1) / (float) this.serializers.Count;
GameSignals.SET_LOADING_SCREEN_PROGRESS.Dispatch(progress);
yield return null;
}
}
This is just one way to juice up your loading screen. You can let your imagination run wild from here. You can show some fun text for every serializer like how we did. You can show a sprite animation, like say, your main character running. You can do anything you want.
That’s all for now. If you like my posts, subscribe to my mailing list. I’ll send you a free game if you do. 🙂