Search Unity

  1. Good news ✨ We have more Unite Now videos available for you to watch on-demand! Come check them out and ask our experts any questions!
    Dismiss Notice

Architecture query (ScriptableObject / MonoBehaviour)

Discussion in 'Scripting' started by bazzawood, Sep 23, 2019.

  1. bazzawood

    bazzawood

    Joined:
    Aug 7, 2019
    Posts:
    4
    Hi,

    First a bit of background, I am new to Unity and component based architecture but have > 20 years of commercial software development and even worked as a game dev back in the PS2 days. I drew a lot of inspiration from https://unity3d.com/how-to/architect-with-scriptable-objects (I too think singletons are devil spawn or something that suggests badly thought out architecture but they seem to get thrown around like lollies in Unity discussions). But after reading this thread https://forum.unity.com/threads/scriptableobject-createinstance-vs-instantiate.515757/ I fully respect everything that @Lysander had to say.

    I have a working solution but am curious to get some thoughts on design / improvements or areas that are not "ideal". Essentially I have a randomly generated world in which areas of the world can be generated / modified / even removed at runtime. Different components will need to reference these generated areas so need to be accessible. I ended up with the following main systems.
    1. Area dictionary <position, Area> (MB): Accessible to anything that needs to access an area
    2. Area (class): Contains Area GO reference + some core information around an area
    3. Area prefab: Instantiates on creation of an area, has a bunch of components that generate and maintain the Area details
    4. A "World" Singleton that is used by Area components to reference
    Previously I had architected the entire solution using ScriptableObjects and RuntimeSets but @Lysanders arguments persuaded me that I was abusing what a SO should be used for.

    I have a core question around this architecture but also am open to improvements.
    1. As both prefabs and SO cannot reference scene items, it seems as if you kinda get forced to use either singletons (I am not a fan, but will use if it makes sense), GetComponent APIs (I am not a fan as it forces the component to know something about the Scene when I am a big fan of the component being self contained), or nested prefabs (which I will admit I have not fully fleshed out so may have merit)
    Anyway any and all constructive feedback would be appreciated. I am generally enjoying my venture into Unity so far.
    Thanks
     
    uotsabchakma likes this.
  2. Kurt-Dekker

    Kurt-Dekker

    Joined:
    Mar 16, 2013
    Posts:
    8,941
    ScriptableObjects are just bags of data. The only reason to derive from SO rather than just making your own data class (or just keeping data in JSON / XML) is to let you edit it within the Unity Editor and refer to other assets. That's really the only benefit.

    That said, you can make them at runtime all day long, which is a handy way to clone preset databags and modify them at runtime without changing the on-disk asset. But unless you have a better inspector (such as Odin Inspector or Advanced Inspector), you won't be able to change anything on a runtime-created SO in the default Unity editor inspector, something which can often be very useful for debugging. To me, that's one of the ONLY points of using them: editing at debug/test time. That and handing them off to other content creators on the team to make more goodies in-game.

    MonoBehaviors on a GameObject let you do everything define-wise that a ScriptableObject does (configure custom fields in a prefab or scene), but you can also stick MonoBehaviors into scenes, where they can reference other stuff in the scene and in the Assets folder. AND... you can also modify the fields on a MonoBehavior in-scene whether you cloned it or not.

    The main point of a MonoBehavior is that Unity pumps it with a bunch of useful messages: Awake, Start, OnEnable, Update, and more, all of which you may wish to respond to. ScriptableObjects don't have any such useful hooks. Even OnEnable/OnDisable doesn't really work, not in the way you expect, so don't use those in SOs.

    One thing you didn't mention is interfaces. These can be highly useful in Unity and there's plenty of tutorials out there about using them in the C# and Unity context to find important parts of your game in the scene.

    I don't know where this "singletons are devil spawn" stuff starts from. It's truly baffling. I've read the literature, I understand the stated reasons, but I've used them for years, they have a valid place. They're just another programming tool. Use them or use something else. Programs can HAVE global state. It's not the end of the world.
     
    DonLoquacious likes this.
  3. bazzawood

    bazzawood

    Joined:
    Aug 7, 2019
    Posts:
    4
    Thanks @Kurt-Dekker for your thoughtful response. I think that top paragraph was why I preferred SO over a class. I could design and reference other SO's during the design of my SO.

    I appreciate you raising interfaces as I can totally see how they will fit in the development of my application and keeps with my ideology better (I will admit that architecture is an ideology and that my general "uneasiness" with singletons is an ideology - I also don't want to derail this conversation into a pro - con singleton discussion. It's perhaps more of an overuse of them that I don't like. People far smarter than me have articulated such concerns with Singletons here https://stackoverflow.com/questions/137975/what-is-so-bad-about-singletons if you are interested)
     
  4. DonLoquacious

    DonLoquacious

    Joined:
    Feb 24, 2013
    Posts:
    1,667
    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. =)
     
    Last edited: Sep 23, 2019
    eisenpony and Ryiah like this.
  5. bazzawood

    bazzawood

    Joined:
    Aug 7, 2019
    Posts:
    4
    Thanks @Lysander for your very detailed response. Most of your points make total sense, In particular your use of MB / SO / plain old classes really helps me come up with a sensible design. Unfortunately, too many of the beginner tutorials seem to be creating some potentially bad habits. Especially with the overuse of MB.

    I have one quick question around global states and cross cutting concerns as you articulated my dislike for singletons much better than I. I have worked primarily in the .NET space for the past 10ish years (one of the reasons that attracted me to Unity3d) and have been around long enough to see various trends come and go. In particular, most projects (in fact all projects) that I have worked on over the past say 5 years have relied heavily on dependency injection of one flavour or another. I have just started using Zenject and am interested in your opinion on its pros / cons in a Unity project.

    It might take a little while to digest your previous reply as I will need to play around a bit but I truly thank you for your advice.
     
  6. DonLoquacious

    DonLoquacious

    Joined:
    Feb 24, 2013
    Posts:
    1,667
    @bazzawood Service locators and dependency injection are both just different approaches to IoC- in other words, putting aside the pros and cons of specific implementations, I've found that it's mostly a stylistic choice in which you prefer over the other. This becomes clearer in cases like ASP.NET Core, where both approaches are available to access exactly the same pool of services (though there are times where one approach is available and the other is not, depending on what you're working on, subtle differences in scope, etc). You could also just describe them as two sides of exactly the same coin: are you giving an object/service to something (injection), or are you requesting an object/service from something (locator)?

    So, as someone who has a fondness for IoC, I actually really like dependency injection in general, and Zenject in theory. I've played around with it, and most of the issues I've seen with it are simply the time it takes to learn and understand how it works. I've seen nothing that would make me hesitate to use it in production. Pros: versatile, solves the problems it intends to solve quite well, is maybe slightly more time to load than I would prefer, as it builds all of its dependency graphs, but it's only a cost you pay once. Cons: takes time to learn, difficult to recommend to newcomers not just because the implementation is complex, but because of the concepts aren't really easy to swallow in general for new programmers.

    That said, I don't actually use it production myself, because I find that my service locator system accomplishes 90% of the same things, and it's not something I need to "learn to use", since I created it myself. It's a little less versatile, but it's also simpler. I'm missing the half of the equation that forces an implementation onto consumers (injection), all services are handled through requests, but I mostly get around that by my service locator actually being capable of acting as an instance service itself. I can create a new instance of the locator class, assign it its own local pool of services as I want (using attributes, or manually, whatever), then have other classes pull from that as needed instead of using the global locator. If you read the sample script I linked before, you should be able to see how the class can function both ways (globally, or locally).

    So, if you find that you like the binding/injection route, by all means, use Zenject. It takes some time to study and learn to use effectively, but it's a top-notch approach to the problem of cross-cutting concerns for sure. If you want to do service locators, feel free to take mine, or find another on the net (though I haven't seen one as "complete" or well-supported as Zenject is for DI), or make your own. Whatever you feel suits you the best- I would definitely advocate using one approach or the other though, and I think you're definitely on the right track for better / more re-usable code in Unity for non-trivial game development.

    =)
     
    Last edited: Sep 23, 2019
  7. Kurt-Dekker

    Kurt-Dekker

    Joined:
    Mar 16, 2013
    Posts:
    8,941
    We deployed Zenject broadly in 2016 and 2017 and it was a complete nightmare. Here's why: when you have multiple team members all trying to wiggle stuff into a bootup dependency sequence, what you end up with is a miasmic soup of undebuggable crapware. Nobody is sure if this dependency or that dependency is still valid, nobody is really sure if X gets started before Y, because someone else changed some obscure requirement. The problem with implicit creation/initialization is that it becomes impossible to glance at your initialization order all in one list and see what is happening in what order. I want a list of initialization steps all in one page where I can put a breakpoint when it fails, because it WILL FAIL, and it most likely will end up in my lap to fix. :)

    The stated objective of reverse injection and testing and mocking for unit testing sounds great, but the actual bugs you face in mobile game development simply are uncatchable with unit testing. How do you catch, for instance, the corner of a sprite that animates going past irregularly interfering with the raycasts towards UI buttons? Or a button that gets mysteriously left in "not interactable" state when you do certain obscure menu UI flows? Those are the types of bugs that predominate (90% + or more) our bug lists, not stuff that is actually catchable by unit test.

    In other excellent Singleton news, Unity also gives you the power to create what I call a "Scene Singleton," a singleton which only hangs around for the duration of your scene, but is shared by all components in the scene. It's a Unity singleton (Monobehavior) but you do not call DontDestroyOnLoad() on it. This pattern lets you blow it away when you get to the next scene, thus confidently resetting all global state it keeps. MissionManagers are a great use of this pattern: a mission manager comes into being in a scene, "runs" the mission, handling all state within the scene, and then can go away after reporting the final outcome of how you did in the scene.
     
  8. Dameon_

    Dameon_

    Joined:
    Apr 11, 2014
    Posts:
    542
    There's another method of dependency injection that's usually completely overlooked when we talk about dependency injection and Unity. This may sound a little weird, but UnityEvents can act as dependency injection. You wire them up using the inspector, and your dependencies are "injected" by the engine. It's all the IoC power of events, with the added bonus of injection from the engine. Of course, there's the usual difficulties of events, and they have their limitations, but I find UnityEvents to be a great way to implement DI without having to introduce a whole extra framework to obtain it.

    Of course, this doesn't work for say, hooking up prefabs to in-scene objects, but for that using a simple Service Locator along with an Interface works just fine. C# includes all kinds of powerful tools for easily implementing DI/IoC, and I've never really gotten why people feel the need to introduce large, obfuscating frameworks to obtain them when what these frameworks accomplish best is disrupting the Unity workflow.
     
  9. Kurt-Dekker

    Kurt-Dekker

    Joined:
    Mar 16, 2013
    Posts:
    8,941
    Amen Brutha, speak it! Yeah, reaching for whacky middleware solutions just because you "don't like how Unity does it" is exactly the wrong approach. The old adage about, "When in Rome ..." and all.
     
  10. DonLoquacious

    DonLoquacious

    Joined:
    Feb 24, 2013
    Posts:
    1,667
    No one mentioned not liking how Unity does it- Unity just doesn't do it. That isn't Unity's fault, it doesn't promise a one-stop solution to every problem, it expects developers to be able to design/use tools to fill in the blanks in a way fitting their own game, their own skills, their own workflow. Zenject didn't work for you, and it doesn't work for me, but that isn't to say it's a poor solution, or that a solution isn't needed.

    If you want an adage, there's also one about not reinventing the wheel, and one about fitting square pegs in round holes. =)

    Anyways, if you want to be technical, dragging-and-dropping references in the inspector is also a form of dependency injection, abstracted out to a GUI. That doesn't make that particular form (or UnityEvents for that matter) actually helpful in solving problems of cross-cutting concerns in a scalable way either though, and I have a lot of experience with losing references randomly in Unity to want to put too many eggs in any of those baskets. I still think the simple service locator is the best (and easiest) solution, but I stand by Zenject looking to be a perfectly viable option if it strikes a person's fancy and they're willing to put in the time to learn it, particularly if they look out for the pitfalls you've outlined with team management difficulties.
     
  11. bazzawood

    bazzawood

    Joined:
    Aug 7, 2019
    Posts:
    4
    Thanks all for your thoughtful advice.

    I agree with the sentiment @Dameon_ and @Kurt-Dekker that one should work with the tools that make most sense in the environment you are working with. After all these years, it just seems that I have got used to DI as the simplest and most robust way to well ... handle dependencies. I do agree that Zenject breaks the standard Unity workflow. Interestingly enough I feel that the most rudimentary feature of Unity - the inspector variable is a great example of DI albeit not an especially scalable one.

    I guess the reason I'm tending towards Zenject is that I have a lot of familiarity with DI (though Zenject is a little more complicated then most) and to be honest all other alternatives that Unity provide or have been suggested (other than perhaps Service Locator) either have limitations or don't really resolve issues of decoupling, single responsibility principles, or brittleness.

    [Edit: I actually got distracted at real work ... Lysander just beat me to inspector variables!]
     
unityunity