Is it possible to create a Decoupled Save System? (ISaveable thoughts)

First of all, thanks for such outstanding quality courses, you can tell that all the code was designed with great design principles in mind.

I have one question regarding the saving system used by the Intermediate C# courses, the ones about the RPG game, is it possible to decouple the data and saving concepts completely from the Mono Behaviours?

For more context, I consider implementing the ISaveable interface to be coupling the Save system with whatever it has to save. Wouldn’t it be possible to create another intermediary class that would handle saving and loading for any given object without the actual object knowing what saving and loading is nor any saving system? (an example from this lecture would be Mover → ISaveable)

If so, what would the best approach be? Do you have any resources that could help with this?

Thanks in advance! :slightly_smiling_face:

It’s surely possible, but this ‘intermediary class’ would need to know how every single object need to be saved, or save everything it possibly can. Unity kinda already does that with its SerializeField attributes, saving everything that is marked to be serialised. In C# it’s also possible to serialise/deserialise objects to xml, json or binary with the built-in serialisers. The saving system uses the binary serialiser for this, but it constructs a specific object. You can certainly just serialise everything instead.
I don’t really see the benefit of storing everything as opposed to just what need to be saved, though. Each MonoBehaviour has knowledge of what it wants to have persisted and need only provide those to the save system. An intermediary class would have none of this knowledge and will have no choice but to supply everything it possibly can.

Thanks for the quick reply! :+1:

I agree with the reasoning behind the approach and the limitations you stated.

That being said, what I meant was if there was any way to decouple the MonoBehaviour, in this case Mover, from its state serialization and deserialization.

Because the Mover class could easily work without the serialization system, but by making it implement ISaveable now the Mover class will always be required to implement methods that are coupled with the saving system.

So is there any design that would allow that separation by decoupling the specific state data that needs to be serialized and encapsulating the saving/loading into a separate class making Mover completely unaware of the saving system?

You can probably have classes that are specifically tailored to each other class that can do that. Like having a Persistence<T> type base class and then implementing the saving for each object separately. They may still need to be MonoBehaviours to allow the saving system to find them

public abstract class Persistence<T> : MonoBehaviour, ISaveable where T : MonoBehaviour
{
    [SerializeField] protected T _source;
    public abstract object CaptureState();
    public abstract void RestoreState(object state);
}
public class MoverPersistence : Persistence<Mover>
{
    public override object CaptureState()
    {
        var data = new MoverSaveData();
        data.Position = new SerializableVector3(_source.transform.position);
        data.Rotation = new SerializableVector3(_source.transform.eulerAngles);
        return data;
    }
    public override void RestoreState(object state)
    {
        var data = (MoverSaveData)state;
        // would need this 'Warp' on mover
        _source.Warp(data.Position.ToVector());
        _source.transform.eulerAngles = data.Rotation.ToVector();
    }

    [System.Serializable]
    struct MoverSaveData
    {
        public SerializableVector3 Position;
        public SerializableVector3 Rotation;
    }
}

This gives you a separate component that can save the Mover data without the Mover knowing anything about saving. It would probably have to live on the same game object as mover, and you’d have to assign the mover to its exposed field in the inspector. As you can see you won’t have access to some of the internals of the Mover (in this case the NavMeshAgent to call Warp()) and would have to expose some way to it.

I’m sure if @Brian_Trotter gets here he’ll be able to give you more. He’s really well versed in the saving system. I’ve used the saving system as-is, rewrote my own version to save json and have used subsets (or concepts, really) of it in some cases. But I haven’t had the need to do this, so I’m kinda brainstorming this as we go

1 Like

Thanks a lot, @bixarrio! :slightly_smiling_face:

That accomplishes pretty much what I was looking for!

It’s true that this approach could lead to having to expose unnecessary methods or fields just for the sake of saving. Maybe there is a way around that too? :thinking:

Hopefully, @Brian_Trotter can give us some insights on this.

The design looks really clean though, so unnecessary exposure of methods might be a detriment I can live with :rofl:

Yeah there’s reflection, but I try to avoid reflection if I can. With reflection you should be able to get hold of the agent in the mover and call warp on it.

That’s also true, I agree with that rule of thumb about avoiding reflection, at least during runtime, mainly because it can be really detrimental to performance. :+1:

Are you asking for a way to have the Mover without the ISaveable, so that some separate component would be responsible for saving if you wanted it to?

While this is certainly technically possible, over the series, you would find such a solution to be bloated beyond reason. At a certain point, you really only want the class responsible for dealing with the data to be responsible for encoding/decoding it, unless you don’t mind walking all over the Law of Demeter.

Now this may be a bit more approachable through either an interface or inheritance. You could, for example, have an IMover interface

public interface IMover
{
    void StartMoveAction(Vector3 position);
    void MoveTo(Vector3 position);
}

Then for Movers you don’t need to be Saveable, you could have a Mover, and for ones that do, you could have a SaveableMover class. This does, however, create a bit of duplication of effort, since each class would then have to implement the needed steps.

Another approach may be to subclass Mover

So you have your regular Mover class without the ISaveable interface. Then you could create a new class deriving from Mover which could implement the saving.

public class SaveableMover : Mover, ISaveable
{
    //Capture and RestoreState methods here
}

In the case of Mover, this would work just fine, because Mover is actually only saving and restoring information about the Transform. There’s no actual state internal to mover being serialized or deserialized. This pattern breaks down, however, once you get to later components like Health, Experience, or in later course, Inventory, Shops, TraitStores, etc. At this point, you’re using a wrapper class to save information that often should not be exposed publicly, or at least not settable publicly. You don’t want a public method to Set Health, only to Get the Health. Anything that Sets the health, should always be within the Health class itself. This also applies to Experience, or many of the other classes we’ll be saving.

2 Likes

Thanks a lot for the detailed explanation! :slightly_smiling_face:

I haven’t thought of the subclass approach for this and it’s the one approach that would still leave the core Mover logic decoupled from saving while allowing to keep access to a minimum (with some changing to protected if needed, but never public)

Those were some awesome insights on the pros and cons of every approach! :muscle:

This topic was automatically closed 24 hours after the last reply. New replies are no longer allowed.

Privacy & Terms