If you have no experience, it might be best to first use a prebuilt solution like something from the Asset Store, just to learn those concepts and see the limitations in action. Then you can always write your own.
At the same time, you can follow a few tutorials. Just a random one that I remember doing: Persisting Objects
But, don’t just take any single thing for an answer. Look at multiple approaches, make your own experiments and then apply what you’ve learned to your own project. Maybe at this point you want to implement a custom save system, maybe you no longer feel the need or found something perfect along the way.
My dev studio used different practices in the past:
Key-Value Storage
Something like PlayerPrefs, but custom. You can build things like GetInt(string key), but also more complex: GetVector, GetArray etc. Everything is stored in dictionaries and serialized to a file. All components go through this global system and save/load their individual state or some managers manage the process for sub-objects. It’s all spaghetti and might become too confusing, but it’s very flexible. You can turn this system into something designer-driven, e.g. let developers add keys and values directly in the inspector and assign them to prefabs, which on certain actions saves other values (e.g. a quest system or inventory collect thingy). Some topics like versioning the save file can become difficult with this approach, because everything is scattered, or you would have to add additional features from other approaches to make this possible.
Save Game As Single Source Of Truth
There’s a single class called GameState and it contains everything that needs to be saved, nested in a completely serializable hierarchy. All systems have a reference to this single class or parts of it, but there’s only one copy (a singleton potentially). This means, once loaded, all systems simply use this state and write to it, then somebody can save the structure to a file at any point. This system is very easy to understand, because nothing is copied and everyone has the most recent state of things etc. It’s also probably one of the fastest approaches performance-wise because there’s only a single step involved. However, it tightly couples all components to this big model or parts of it, which reduces flexibility. But things like versioning become easy, because now version 1 is just a single file and model, version 2 a different one and you can add upgraders in between and just pass the new data to the rest of the game.
Serializer Passed To Subsystems
Combine both approaches. Somewhere, a serializer loads a file and the reference to this class is passed to all implementers of ISaveable. Each class takes the data it needs or stores it. If its key/value based, the order doesn’t matter. If its binary, the ordering is important and probably impossible to implement. Some commercial products work by making the ordering deterministic via GUID keys or manually maintaining an orderer list of saveable components. In any case, you may be able to split the big monolith into multiple parts to make different systems more flexible.
File Format
I recommend pretty-printed JSON because its a good balance between performance and editing/debugging. You can even use a ScriptableObject as the data model and then serialize it to JSON. This makes it possible to store save games as assets in the project (maybe as starting points or for cheating/testing). You can see and edit values directly in the inspector. Unity’s JsonUtility is extremely fast and supports all types which show in the inspector (but this means its bound to the same limitations, no dictionaries for example). Other serializers handle more complex data types, but are slower, e.g. Json.Net.
For maximum performance, a custom binary solution is best. Nothing beats a handcrafted serializer which is hardcoded to every property in the game and writes out the minimum number of required bytes to represent the data. However, its also very maintenance heavy. Some libraries build on top of this idea. A search for “serializer” turns up many good results. To name a random one: MessagePack. Keep in mind, you can beat even the best serializer library performance-wise, but probably not in regards to time spent on implementing and maintenance.
Representing Game State
The most difficult task: how to represent Unity GameObject state with any system? Everything else is easy: score values, player progress data, inventory, achievements, all can usually be stored as some strings and ints. But scene state (position of GameObjects, active state, animations) are more difficult because the data needs to be converted and the internal Unity state recreated somehow. The most common and practical solution is to build systems for every part of your game. E.g. if the board spawner spawns board game tokens, it can also represent each token position in the save file and load it to spawn it from saved positions). A PlayerController can keep track of its position and rotation and write it to file and load that. You see, it becomes a nightmare for big games, but it works. Some things will give you headaches, like recreating animation state, coroutines, etc. All of these have solutions, but they are involved.
Some systems attempt to generalize the process. For example, they walk the entire GameObject hierarchy and store the Transform data of each object. Each known component type has some sort of Handler that knows how to serializer/deserializer its state. However, this also breaks down easily in a fight between tradeoffs. If you save everything you waste massive amounts of space and time. But to optimize the system you will need to mark things as saveable or not, or even individual properties, and now you’re building an entire editor system to configure the previously hardcoded save/load systems. If anyone (or Unity) introduces new components, you also need to add new specific handlers. This is probably the most complex way of doing things and only yields benefits if both game and team are large.