I’ve actually come around just a little on ScriptableObjects for logical processing. It goes against their nature as “data bags” for sure, but there’s something to be said for the techniques used in that “Pluggable AI” tutorial series. However, it’s worth noting that when used that way they don’t contain state themselves- the state for the AI is stored as a POCO externally, created and held in the MonoBehaviour, and passed into the SO every frame as needed to process state changes. All scene objects use the exact same SOs as AI processors, they’re not duplicated for each one, making them somewhat of an irrelevant mention in the linked thread honestly.
I still stand by everything I said in that thread though- Instantiate/CreateInstance with SOs are wasteful, and almost always the wrong tool for the job, with no benefits at all I’ve seen over a POCO at runtime, just more overhead (unless, as Kurt-Dekker suggests, you use Odin and can edit runtime-generated SOs in the inspector for debugging, which I hadn’t considered). They can be data bags, or they set up processes (like the Pluggable AI example) via drag-and-drop- either/or, not both, or things get really messy and inefficient.
I dislike most static classes (with state) and singletons not really because of arguments about global state, but just because of the difficulty in modifying and/or extending them. These days I use the Service Locator pattern rather extensively, as I’ve found that to be the best balance, especially in situations like Unity development with so many cross-cutting concerns all over the place. Services aren’t functionally much different from using singletons or static classes, except they can be overridden and/or swapped out as needed, and you can use interfaces easily, which is always a huge plus. In the sense that they’re easily accessible everywhere, and can be changed at any moment without additional protections in place, they’re still static state, just “better” static state. Static state isn’t evil, and people who think otherwise tend to still be using it anyways, just with enough levels of abstraction and indirection to confuse the issue. Cross-cutting concerns are infinitely harder to deal with otherwise.
My mentality now is to never use MonoBehaviours for anything that isn’t directly related to moving and being viewed on the screen- GameObjects have locations, rotations, and scales. They’re visual elements. Using visual elements as owners of data unrelated to their own display feels silly to me, so I reject the entire premise. All of my “owners” are classes outside of the Unity “scene” paradigm, they aren’t GameObjects or MonoBehaviours or ScriptableObjects, they’re just normal classes, accessed through services. If they have a representative GameObject/entity in the scene, then they own that representation, not the other way around- they can create, change, or discard those representations at will, which means the GameObjects can be re-pooled and used by other owners when they fall out of the screen view and get culled, or the scene changes, etc…
This also makes saving and loading data much simpler, because the scene is irrelevant- it’s only the visual representation of data that exists in pure data classes in a service. No need to ever iterate over the active scene objects and trigger any sort of state save process (eww). This was completely necessary as my latest game dev project involved making factories that ran even when they were out of view. Machines still needed to talk to eachother, electricity and inputs examined, items needed to be generated, placed in containers, etc, even when the player was nowhere near the factories in question. This applies to pretty much all game creation though IMO- don’t rely on the visual elements, they should be treated as temporary, swappable, and disposable. In that sense, the “prefabs within prefabs” approach you mentioned is really my ideal, though I don’t really create them that way.
An example: The UI Manager Service is just a class, implementing IService and with a Service attribute defining how it’s accessed by the rest of the applicaiton. The UI Manager Service doesn’t exist within the scene, it’s not a MonoBehaviour or attached to a GameObject, but it needs scene objects to function properly- to display things to the player, so it has a Config SO that references a prefab for it to generate its initial scene entity, hierarchy, give it some MonoBehaviours as proxies it can hook into.
Some elements of that UI, like menu buttons, may be their own prefabs. Another field in the Config SO referencing a prefab entity, instantiated and then placed programmatically in the right place by the UI Manager Service, as needed. That button may itself have config, and a prefab set there, which it instantiates and places in the proper place too. In that sense, there’s “prefabs in prefabs”, but they aren’t saved/stored that way, they’re constructed dynamically. Other services might also have their own little UI prefabs, menus they want to display (for the Character Info, or Inventory system, for instance), and they can communicate with the UI service, hand off that prefab for displaying that menu, and have it instantiated, placed in the right location for them.
That’s how cross-cutting concerns are handled.
That said, this isn’t really a “beginner friendly” way to do things, nor is it something you’ll probably see in tutorials anywhere, because it requires a backbone to your development that wouldn’t be easy or reasonable to explain in the context of a tutorial for a very narrow/specific feature. If you make a tutorial on sound management, you don’t want to spend the first half explaining the services system, which has nothing to do with sound management / Unity functions directly. It’s also not very “Unity like”. I prefer it, a lot, but it isn’t for everyone.
If you’re curious, doing a Service Locator well isn’t really that difficult in a typical C# application, but in Unity it’s made harder by not having a real entry point for the app. We don’t have “main()” access, after all. So, there are a couple of things to be made aware of that simplify this.
First, the RuntimeInitializeOnLoadAttribute. Attach that to a static method, and it’ll run before/after the first scene loads, giving you something resembling an entry point. This is really important if you want to initialize non-UnityEngine.Object-derived classes at the start without using a MonoBehaviour to do it.
Second, using SOs as “configuration” can be a real time-saver. I tend to use Resources.Load with a specific path and filename, like “ParnassianStudios/Config/LoggingServiceConfig” or something. You can use whatever setup you like, paths hardcoded into a static class, or into attributes (as I do), but it should be consistent. Using this, you can drag-and-drop prefabs into the config SO’s fields in the inspector, swapping out UI prefabs or entity prefabs (buildings, characters, etc), or other blueprints you want to use to generate GameObjects in the scene. The Services will Resources.Load to get the config when they’re first initialized, then use that as their guidelines for generating scene objects as needed to do their jobs. My games are now one-scene games, and the scene is completely empty- everything is constructed programmatically by instantiating prefabs as they’re needed, and I use prefab editor scenes instead.
Here’s an example of the service locator class I use (a somewhat simplified version anyways, without all of the logging).
Anyways, I hope that this gives you at least some ideas, even if it doesn’t address your specific concerns directly. I’ve been a bit more open about my process than I normally am because you said you’re a software developer of some experience (if you have any experience with ASP.NET Core, my methods are actually pretty similar, as that’s my primary job these days), but no doubt I’ve missed some critical elements as I’m unaccustomed to trying to explain my system here. If you have any questions, just let me know and I’ll do my best to answer. =)