Saving levels in an in-game level editor

Hey. I’m working on a simple level-builder for my game and I’m having trouble saving/loading the level’s data. The relevant information replicate a level is as follows:

-Walls: Uniform tile blocks. Only has position. Easy.
-Borders: Tile objects with a limited variety and no function. This too is not complicated. (Position, rotation and variety)
-Objects: All the interactable objects including the character. The main problem is that they will sometimes have different starting states and most importantly some references to eachother (ex: linked portals, button and a door). This means using prefab references will not be enough as I must also save the interlinked references.

The ideal solution would be to save the whole level as a prefab directly, but it will be used in runtime and as far as I understand that’s not possible. Any ideas?

Effectively you need to be able to represent your level state entirely as plain C# data. What particular prefab it is, their positions and orientations, what other objects they’re referencing, etc etc, all contained within an overarching data-structure independant of any direct references to Unity objects.

Important to remember that:

  • You cannot serialise out Unity object ‘as is’ (aside from the limited JsonUtility)

  • You cannot serialise out reference to other Unity objects

So a lot of it will involve indirect references through some kind of unique identifier, and ways to locate the object that identifier represents. Though your plain C# objects should be able to reference one another so long as they are all serialised in the same overarching data object, and so long as your serialiser supports this.

If this sounds complicated, that’s because it kind of is.

3 Likes

Thank you for your response! I see how this could be done with unique identifiers, but what really complicates it for me is how I will generalize the serialization process for each object. They all have different monobehaviours on them. Should I write a custom serializer for every different class or object type? This will make introducing new objects significantly harder I presume.

Well, as mentioned, you can’t serialise out Unity objects. It’s just not something that is possible. A custom serialiser will not solve this.

You’re going to need an overarching data object that wraps about everything that lives inside a level. Then for various objects that live in a level, you have some kind of pure C# object to represent them, which references their respective prefab indirectly (via the ID), and any extra information about it (such as position).

Then when you load the actual level, you use this data to instantiate the correct prefab at the right position, potentially passing these objects to the actual instances to initialise themselves.

Effectively the state of the level is represented as pure data. Use this data to build the visual (scene) representation. When you modify the level, you’re modifying the data, and the visual representation just updates in response to the data. Keep the representation (generally) one way.

So your game objects, prefabs, their components, etc, are all part of the visual representation. All they do is reflect what the data is saying. The only part of them that gets involved in the pure data is their ID, and other basic data you can get off them and need to record (such as position).

This is what I’m doing in my current project that I detailed here: Custom Tilemap System - Each Tile is an Object

2 Likes

If you are going to be able to use the level editor from within the built application, then you will need to convert those objects that represent the serializable data into bytes written out to a file for saving, and read those files back into C# objects for loading. You won’t be able to save prefabs or ScriptableObjects at run time. You’ll have to recreate a serialization system of your own.

1 Like

Two advices.

Keep your code modular. Just because your objects have some unique scripts and runtime behavior, it doesn’t mean that the data you need to serialize is different. For example you might have a bunch of different button like objects: a button on a wall, pressure button on floor, switch requiring a key. They might each have different scripts attached for handling the activation of them (using keyboard, stepping on, using interact button while having a key). But they all have a common part that the activation signal can be connected to activation receiver (door or some other mechanism). The part which needs to be serialized is the same. So instead of each type of button having a completely unique script, duplicating the connection logic, you split them into two MonoBehaviors where the part describing connection and serialization of it is shared, alternatively you can use inheritance for sharing the common part. Structuring your code this way will not only help with serializing, but can also simplify the rest of runtime logic (like the UI code for connecting a button like object to a door).

Use interfaces. If the amount different kind of serializable data is still large, create an interface for serializing/deserializing the object specific properties, and implement it in each script with unique serializable data. That way top level level saving/loading code doesn’t need to know what exact properties each object have. When you create new type of object, you only need implement this interface for the new object, without touching the rest of level saving/loading process.

As for references, you might have to split the loading process into two stages: first create all the objects, then apply the object specific properties (including reference to other objects). That way once you try to restore object reference, the target object is already created.

Alternative approach is to use lazy references (within final game objects), which only get resolved when you need to use them. This way you don’t need multistage loading process. Downside is that you need to modify the gameplay logic, and there is also some performance penalty of doing the reference lookup each time you interact with a thing. Depending on the type of game this might be a none issue. For example in a turn based puzzle game this is probably not a problem, even in an action game if it’s something which happens once every few seconds cost of resolving integer/string based reference probably won’t matter. One more downside to lazy reference resolving is that if level is broken, and stuff is referencing to objects that don’t exist, you will only notice it when interacting with specific objects. With the previous approach, you would detect all the bad references immediately at load time or possibly even when saving the level. With lazy references you might have to play through whole level, even worse there is a high chance of not noticing the problem (before it gets to player) as the level author might not fully retest every possible interaction with optional objects which are not necessary for completing the level. With all those drawbacks why would you ever choose lazy reference resolving? Two stage loading is fine for flat structures, but if you have a complex hierarchical structure of subobjects referencing each other, then separating object creation from property initialization and deserialization might be challenging. One benefit of having weak references, is that it can make it easier to directly use of the shelf serializing interfaces without having to invent your own thing, or introducing additional layer of indirection serialized_data ↔ deserialized structure ↔ actual game object.

There is a tough choice is between detecting problems early or doing it lazily and having half broken levels. On one hand you don’t want players to be downloading broken levels created by other players and wasting time just to find out after finishing half of it that it’s broken and impossible to finish. On the other hand it’s equally annoying when large amount of user content is “broken” and unplayable after a game update even though the change was mostly cosmetic and the levels would be otherwise fully playable.

4 Likes

This is a very detailed and thorough answer. Thank you so much for taking the time. After all your advice and others’, I decided to create an interface that will handle packing and unpacking of the relevant data for each monobehaviour. That seems like the most expandable approach. But still, I must find a way to generalize data for every class. I suppose a custom struct with a list of every primitive data type must be enough since I will have to implement the serialization/deserialization logic for each class anyway. Let me know if you think there would be a simpler way.

As for the reference problem, I think it can be handled by a GetID function in the interface. Maybe generated by the custom data class since only using the prefab reference and position will be enough to avoid any collisions. References can be connected after all objects are instantiated using a dictionary.

I will update this thread once I finished with the solution. Thank you all for your advice.

Okay. Finally got it working. Here’s what I did:
First made the data object. It stores all the relevant information to recreate an object. Apart from primary data like transform fields, name, ID and parentID, it also holds any additional data using dictionaries. Used exactly like PlayerPrefs, but for every single monobehaviour.

/// <summary>
/// Used to store data of LevelObject derived classes.
/// </summary>
[System.Serializable]
public class LevelObjectData
{
    public string name;
    public int id;
    public int? parentId;

    [JsonProperty("Vectors")]
    Dictionary<string, float[]> vectors;
    [JsonProperty("Ints")]
    Dictionary<string, int> ints;
    [JsonProperty("Floats")]
    Dictionary<string, float> floats;
    [JsonProperty("Bools")]
    Dictionary<string, bool> bools;
    [JsonProperty("Strings")]
    Dictionary<string, string> strings;

    public LevelObjectData(){}

    public LevelObjectData(LevelObject levelObject)
    {
        vectors = new();
        ints = new();
        floats = new();
        bools = new();
        strings = new();

        name = levelObject.name.Replace("(Clone)", "").Trim();
        SetVector("position", levelObject.transform.position);
        SetVector("eulerAngles", levelObject.transform.eulerAngles);
        SetVector("scale", levelObject.transform.localScale);
        id = levelObject.GetID();
        parentId = levelObject.transform.parent?.GetComponent<LevelObject>()?.GetID();
    }

    public void SetInt(string key, int value)
    {
        ints[key] = value;
    }

    public void SetFloat(string key, float value)
    {
        floats[key] = value;
    }

    public void SetVector(string key, Vector3 value)
    {
        vectors[key] = new float[]{value.x, value.y, value.z};
    }

    public void SetBool(string key, bool value)
    {
        bools[key] = value;
    }

    public void SetString(string key, string value)
    {
        strings[key] = value;
    }

    public int GetInt(string key)
    {
        return ints[key];
    }

    public float GetFloat(string key)
    {
        return floats[key];
    }

    public Vector3 GetVector(string key)
    {
        return new Vector3(vectors[key][0], vectors[key][1], vectors[key][2]);
    }

    public bool GetBool(string key)
    {
        return bools[key];
    }

    public string GetString(string key)
    {
        return strings[key];
    }
}

Then made a parent class that all my monobehaviours with relevant information will derive from, it’s called LevelObject. It essentially handles the serialization behaviour. It generates an ID for itself and fills the data object with essential information.

/// <summary>
/// Base class for handling serialization/deserialization of level objects.
/// </summary>
public class LevelObject : MonoBehaviour
{
    public static Dictionary<int, LevelObject> idToObject = new();
    public static string defaultPrefabPath = "Level Objects/";
    LevelObjectData levelObjectData;

    /// <summary>
    /// Generates a level object ID from from name, position and rotation
    /// </summary>
    /// <returns>An object ID.</returns>
    public int GetID()
    {
        return string.Format("{0}-{1}-{2}", name, transform.position, transform.eulerAngles).GetHashCode();
    }

    /// <summary>
    /// Generates and returns data of the level Object.
    /// </summary>
    public LevelObjectData SaveData()
    {
        levelObjectData = new(this);
        levelObjectData = SaveCustomData(levelObjectData);
        return levelObjectData;
    }

    /// <summary>
    /// Saves the additional custom fields if there are any.
    /// Override this function to serialize other custom fields.
    /// </summary>
    public virtual LevelObjectData SaveCustomData(LevelObjectData levelObjectData)
    {
        return levelObjectData;
    }

    /// <summary>
    /// Initializes GameObject using the given data.
    /// </summary>
    /// <param name="levelObjectData">The data object.</param>
    public void LoadData(LevelObjectData levelObjectData)
    {
        if(levelObjectData.parentId != null) transform.parent = idToObject[(int)levelObjectData.parentId].transform;

        name = levelObjectData.name;
        transform.position = levelObjectData.GetVector("position");
        transform.eulerAngles = levelObjectData.GetVector("eulerAngles");
        transform.localScale = levelObjectData.GetVector("scale");

        if (levelObjectData.id != GetID())
        {
            Debug.LogWarning("ID does not match with saved ID!");
        }

        idToObject[GetID()] = this;
        this.levelObjectData = levelObjectData;
    }

    /// <summary>
    /// Restores the additional custom fields if there are any. Runs after whole hierarchy is instantiated and assigned their ID's.
    /// Override this function to deserialize other custom fields.
    /// </summary>
    /// <param name="levelObjectData">The data object.</param>
    public virtual void LoadCustomData(LevelObjectData levelObjectData)
    {
     
    }

    /// <summary>
    /// Extracts all level object data from the hierarchy and converts it to json. Works recursively.
    /// </summary>
    public string HierarchyToJson()
    {
        List<LevelObjectData> levelObjects = new();
        HierarchyToData(ref levelObjects);
        print(levelObjects.Count);
        return JsonConvert.SerializeObject(levelObjects, Formatting.Indented);
    }


    public void HierarchyToData(ref List<LevelObjectData> levelObjects)
    {
        levelObjects.Add(this.SaveData());
     
        foreach(LevelObject levelObject in GetComponentsInChildren<LevelObject>())
        {
            if(levelObject != this)
            {
                levelObjects.Add(levelObject.SaveData());
            }
        }
    }

    /// <summary>
    /// Instantiates and initalizes all level objects in the given data and reconstructs the hierarchy.
    /// </summary>
    /// <param name="data">Json string of a list of LevelObjectData.</param>
    /// <returns>The root GameObject</returns>
    public static GameObject JsonToHierarchy(string data)
    {
        List<LevelObjectData> levelObjectDatas = (List<LevelObjectData>)JsonConvert.DeserializeObject(data, typeof(List<LevelObjectData>));
        List<LevelObject> levelObjects = new();

        foreach (LevelObjectData levelObjectData in levelObjectDatas)
        {
            GameObject go = Resources.Load<GameObject>(defaultPrefabPath + levelObjectData.name);
            LevelObject lo;

            if (go != null)
            {
                lo = Instantiate(go).GetComponent<LevelObject>();
            }
            else
            {
                lo = new GameObject().AddComponent<LevelObject>();
            }
         
            lo.LoadData(levelObjectData);

            levelObjects.Add(lo);
        }

        //second loop because all objects needed to be instantiated to enable referencing other objects.
        foreach (LevelObject lo in levelObjects)
        {
            lo.LoadCustomData(lo.levelObjectData);
        }

        return levelObjects[0].gameObject;
    }

    private void OnDestroy()
    {
        idToObject.Remove(GetID());
    }
}

Two of the functions can be overriden to save/load additional data like so:

    public override void LoadCustomData(LevelObjectData levelObjectData)
    {
        target = idToObject[levelObjectData.GetInt("targetID")].GetComponent<BlockLaser>();
    }

    public override LevelObjectData SaveCustomData(LevelObjectData levelObjectData)
    {
        levelObjectData.SetInt("targetID", target.GetID());
        return levelObjectData;
    }

Some notes:

  • For finding it’s own prefab reference, it only uses it’s GameObject name. All prefabs that you want serialized must be in the resources folder and must retain it’s name in the scene (the code automatically removes “(Clone)” tag).
  • Whole object hierarchies can be serialized with HierarchyToJson() function but every object that you want to be saved must have a component derived from LevelObject