Game Architecture and patterns (SO events, UnityEvents, ServiceLocator, Singleton, ...)

Hello everyone,

First topic here, I hope I’m not mistaken on where I’m posting this.
I’ve started Unity a few months ago, and decided to make a simple top-down game to learn. At first it was a bit confusing, but now I’ve made my way through the basics of Unity and my small project is nearly finished.
I have a small character I can control, 2 levels, an inventory system, items I can pick up, objects I can interact with, even a NPC that is moving and who I can talk to. OK fine.
But as I was testing my game, I came accross a bug that is inherent to the architecture I chose, which lead me to read discussions here about the different patterns, and now I’m confused about things should really be.

So I’m gonna make it a unique question, through the following example.

EXAMPLE :
When the player’s character enters a collider somewhere in the level, I want the following things to happen :

  • the player is unable to control the character anymore
  • the npc (who is usually constantly moving) stops moving
  • a dialogue box appears with a text in it

When the player press the key “Z” :

  • the dialogue box closes
  • the player character is teleported to a “spawner” object position
  • the player regains control of character
  • the NPC restarts moving

Thing to take into account :
Player Character and Dialog Box are in Scene1
The “world”, NPC, collider and spawner are in Scene2

END OF EXAMPLE

I’m taking a real example so we all understand what we’re talking about and I think it might help the answers be more specific.
So the question is : how would you technically do that ?
To be a bit more specific, I’m interested in how you make the action happening in a collider object in scene2 have impact on objects that are in scene1, and same for the key input after the dialog appeared.

I’m going to answer my own question with the solution I chose and that I’m now doubting

MY SOLUTION
I used custom ScriptableObject events as suggested in this (outdated I guess) talk :

https://www.youtube.com/watch?v=raQ3iHhE_Kk

In my collider, I raise an event “OpenDialogBox” with a string parameter. I also set a “actionStarted” bool to true.
Player subscribed to that event and I disable the controls when it is raised. Same for the NPC, disabling its movement.
DialogManager also subscribed to that event (of course!) and shows itself with the string that was passed as a parameter to the event.

In DialogManager Update function, I check if the DialogBox is active and if the Z key is pressed, then I close the DialogBox and raise an event “CloseDialogBox”.
Player subscribed to it and enables the controls back when raised. NPC same.
Collider subscribed to it and if “actionStarted” is true, sets it to false and raises a “SpawnPlayerEvent”.
Player subscribed to this “SpawnPlayerEvent” and proceeds to a GameObject.FindWithTag(“Spawner”) to find the spawner and modifies its position with that of the spawner.

END OF MY SOLUTION

I started implementing this pattern after I found so many warnings about the Singleton pattern. But since, I’ve read about different patterns and quite some critics about the ScriptableObjects Events pattern. After reading a lot of topics, I’m lost as how to implement other patterns (Service Locator, DI, UnityEvents, others ?) and if one can be considered better.
I’m not seeking the answer “if your project is small you shouldn’t care as long as it works”, I want to learn how I can do something that would be optimized for a big project.

Sorry for the long post, and thanks in advance to anyone who will share their insights with me or point the flaws and stupidities of my solution. I’m here to learn.

Service locator is a anti pattern, but since we cant do constructor injection you are left with half assed DI. I just gave up on that and used singletons for the few times I needed that.

I use a small event aggregator i wrote that publishes structs so it does not allocate. Works very well. Also our items (vr game with interactible items) can publish to only its components inside the item, that way for example parts of a firearm can talk to each other in a decoupled manner.

Edit: one could argue that a prefab is a sort of DI. For example our firearm component has a Slide (the moving part of a firearm) seriliable member. You can drop in any kind of a slide. If you want a shotgun you drop in a pump action slide and you have a shotgun. A roller delayed bolt slide and you have a MP5 or G3 etc. So in a sense the prefab is a DI system

1 Like

The first thing I want to say, is awesome post! Congrats! Not only because it is a breath of fresh air from low effort posts by first time posters, as it is well formatted and easy to the eye, but also because managing after a few months to have implemented those systems in a game is impressive. It is equally impressive that you took the time to read discussions here about different patterns and actually make your question specific enough based on those answers instead of the usual abstract questions.

Now to answer your questions. I will try to be as specific as I can, but you probably understand after all that reading, that architecture is very contextual and depends on experience, so there will be no answer like: “This way is the best, forget everything else and do it always like this.”

Generic answer

The solution you chose for your specific problem, is the first solution that came to my mind as I was reading about it. That doesn’t mean that it’s the “best”, but certainly it is a good starting point for refactoring later, if there is a need.

For all the possible architectural solutions and principles that exist, the most important thing are not the solutions and principles themselves, but the problem they try to solve. By learning the problem, you are able to recognize it easily, so then you have to make a conscious decision, if this problem will affect you and how in the future.

Specific considerations in choosing architecture

You have to remember that architecture is an investment. If the time you spent refactoring to a specific architecture is less than the time it will save you from protentional problems in the future, then it was a good decision. But as with all investments, first you have to loose before start winning. In general, you can separate the problems different architectural solutions solve into four big categories:

  1. How easy it is to modify, extend, add/remove features to your game, with the minimum amount of code change.
  2. How easy it is to debug your code.
  3. How easy it is to test your code.
  4. How easy it is for more than one programmers to work in the code base without stepping into each other’s toes.

As you can understand, with each different architectural decision made, you will gain some into one or two areas and you will loose some in the others. Experience plays a big part into this decision, and it is something that I see being asked often: How can someone move from beginner to advanced when it seems that there aren’t enough tutorials about that on the internet or videos on youtube.

Experience And How to learn easily more advanced subjects

The answer is that experience is not transferable, no one can teach it to you. The only solution, is to get a job in the industry so that you find yourself dealing with real world problems but also see how more experienced people than you handle those situations. Working in a real world environment, is by far the best way to get more experience in a short time.

Theory about the best solution is one thing, but having a deadline literally tomorrow, can show you that sometimes, best practices are tossed out of the window and the ability to quickly slam some code together that barely works is the best skill for the moment.

My opinion about your example

If I try to be more specific for your example, a service locator looks nice, but adds a hidden dependency. This can hurt testing, if you want to be able to test game objects in isolation. The counter argument is that it can make your program easier to extend by having an easier way to handle dependencies.

DI is a difficult one, first Unity’s game objects which are monobehaviours , don’t handle DI very well as you are missing the constructor, so support for immutable fields for each object is limited. If by DI, you mean a DI framework, then there are two options: either you can build something small on your own, but will take time and probably will be missing some functionality, plus it is another thing you have to maintain, or use one that already exists, but most of those will be bloated with functionality you don’t need plus you just added another external dependency to your program. So the previous four problems still exist, you will make some easier to solve and other’s harder depending on you decision.

For Unity events, I don’t use them very often, only when I want to expose the functionality to game designers, as I find C#'s events easier to use because I don’t have to change between the Unity editor and my IDE. Still, they can save some time, because you don’t change your code, which means less compilations.

For other solutions you ask, there are many. I will start by saying that even if I despise singletons, for a 48hour jam or a quick prototype are probably the best solution.

You can also check, different variations of the mediator pattern, either with POCO classes, or with SO’s.

Monostates is an option, but I would say is a horrible one, has all the disadvantages of singleton plus all those static fields you have to deal with in domain reload.

Not having injections, but depending on concrete implementations, can be useful sometimes as it keeps your code with less abstractions, even if it makes it less modifiable.

Also saving you data, and initializing a new scene by loading that data is always an option. especially if your game is big enough and performance of scene loading is not that important.

Events are not the only way to check if something is done, you can always poll the info you need, by keep asking if something has happened. This might seem a waste, but you exchange precision for performance, something that cannot be done with a signal based approach. Of course this is important, only if you need more precision than the time a game loop takes.

This are some options that just came into my head, I’m sure others will add things too and if I think of more I will update this post.

Finally I want to say, keep coding, keep making games. The things you mentioned that you managed to implement in a few months, show that you can be an awesome addition to the community. :clap:

2 Likes

You didn’t mention … are they in two separate but additively loaded scenes which are loaded at the same time? If so, you can get ahold of these references the usual way. Or they could both put themselves in a central Components Registry where assignment could optionally invoke a public event Action to inform interested parties.

If they are in separate scenes … why? We create way too many scenes!

Clearly they needn’t be in single-loaded scenes that you go back and forth between the two. That’s just wasting the users time and complicating your code and will consume more resources (energy) due to all the pointless asset load/unload. Single-Scene loading under memory pressure are also typical choke points where it’s just too much and the app gets force closed (mobile).

Instead, the whole dialog really only needs to be a gameobject that you SetActive while the rest of the game content may or may not be SetActive(false). It may be an issue to implement this now. But next time you start a project, you can easily set it up to work with an additive scene workflow.

I currently design my project to have only a single scene, with additional content either instantiated or large content (ie world, level) being additively loaded and unloaded.

Whatever you do, do NOT shoot for DI. DI is terrible to debug, it typically has a clunky API on top. Just write that boilerplate assignment code and stay in control. The whole concept of DI is not something I ever needed in game code if it’s architected the right way, where dependencies have levels of hierarchy and the separation of concerns is enforced by Assembly Definitions. Eg the GUI has its own asmdef so it can use the game code, but the game code can never “tell” the GUI to popup a dialog - instead there’s an event that gets invoked that the GUI listens to.

2 Likes

Thanks for your answer meredoth, lots of things to think about.

Never came accross this pattern. I read your article about it, and can’t help but think that it’s just SO events named differently and handled a bit differently ? Seems very similar to me, or is there something I misunderstood ?

Could you develop those 2 points ? I’m confused about what you mean here.

Not sure about this one. How is asking if something has happened more precise than responding to an event ? Because answering the event will only happen at the next frame instead of the current ?

Thanks for this. It’s been ages I wanted to make games. Now I’m finally at a time of my life where I’m determined to make it happen.
Thank you for taking the time to make an extensive answer, this is very helpful to me.

[quote=“CodeSmile, post:4, topic:1515336”]
You didn’t mention … are they in two separate but additively loaded scenes which are loaded at the same time?[/quote]

Scene 1 is a scene loaded at game start and containing all the things my game needs at any moment (AudioManager, GameManager, PlayerCharacter with controls, Main Camera, UI elements, Inventory, SceneLoader).
Scene 2 is one “level” in my game, precisely the house where the character begins it’s adventure.
There is also a scene 3 with another level.

Scene2 is loaded additively. When transitioning to Scene3, Scene2 is unloaded and Scene3 gets loaded.

What is “the usual way” ?

Interesting article, thank you. Although I don’t get how you would handle references to objects that are in 2 additively loaded scenes (as I have) with this pattern ?

Read your article, I have one question about scene transition and UnloadUnusedAssets : if your persistent objects are in a separate scene, and you only unload/load levels as I do, what shared assets are we talking about ?

As for the rest of your post, I think it’s not relevant for me as I’m already doing what you’re describing (I think).

Thanks for your answer !

Here’s the basic mediator pattern for C#: https://refactoring.guru/design-patterns/mediator/csharp/example#lang-features Here’s a blog post I made of how it can be used in Unity with Monobehaviours, SO’s and their its event version, is this the article you are mentioning? because it also has examples without events: The Mediator Pattern In Unity - C# and Unity development. Here’s the github repo of my blog post: https://github.com/meredoth/Mediator-In-Unity.

Anything can act as a mediator, for example a singleton can be a mediator, the difference is in the intent. A mediator has no logic or purpose other than having classes communicate with each other instead of having them communicate directly. The SO’s are one way to handle the dependencies between the classes and the mediator, they don’t necessarily need to have events.

For the first, I mean that you don’t always need to create an interface to depend upon, as having too many abstractions can be problematic, so sometimes having a method Foo(ConcreteClass concreteClass) instead of Foo(IAbstract abstraction) is fine.

For the second, I mean that if the requirements of your game allow, then saving data is not only about making save games. For example in a turn based strategy, you can save the data you need before loading a new scene to create a save game and load that data when you load a new scene. This is slow, but sometimes you need to create save games anyway, so the data communication can be done from reading from a file from the disk.

No, I mean you have the option to exchange precision for performance for the poll. It may be enough to check something not every frame but at every n frames and respond to it, not have something else happen the moment it changes. For example, when the player draws his sword a lot of things may need to react to that, but you don’t want everything to happen that moment because that is too much processing power at once, so you may need some things to have checks every 1/10th of a second (or every 6 frames or some other number) but not at the same time, some of these things happen at the first 1/10th, other things happen at the second 10th, others at the third 10th and so on.

You are welcome.

Well, you need one object in the scene that’s always available, and add any “scene 2” object references to it. This could be done by the scene 2 object itself and the target holding the references could be something like the Components Registry.

If you have additive scenes loaded or just a single scene makes no difference on how you assign and retrieve references.

The loading of the scene 2 would behave the same way as the instantiation of a prefab. In both cases you’d have to dynamically “register” the reference and also “unregister” it, typically done in OnEnable/OnDisable.

The persistent (shared) objects. :wink:

Well with additive scenes you can’t use Unity Inspector to assign references, but I guess you’re talking for a code-only solution.

If they are persistent they would never get unloaded ?

Maybe my games scene setup can give you some ideas

I have a SO defining game modes

Then a MapConfig defining map settings

AS you can see it points out a settings prefab for in this case the Bomb game mode.

Thats a normal prefab that spawns in with settings for this specific game mode

Don’t think about it that way.

A better summary is:

“if they are persistent, I get to control when they go away.”

Think about the term DontDestroyOnLoad()(DDOL), the name of the method used to make things persistent.

When you load a fresh scene (NOT additively), everything that has NOT been marked as DontDestroyOnLoad() is gone.

That’s it. Nothing else.

As for the details, things NOT in a scene are generally going be unaffected by Unity, eg, “pure C# singletons.”

Another detail is that Unity creates a temporary scene where DontDestroyOnLoad() objects (and their children) are moved. This further backs up the documented restriction that only root objects may be marked as DDOL.

EDIT: if it’s not already obvious, you make things go away by Destroy()-ing them, like anything else you want gone.

Important to know is that nothing in c# is really gone until the reference is not referenced any more and the GC have collected it.

There for unity have a boolean operator that returns false if the component is marked as destroyed. Not very csharpish but it works.

I personally don’t like ScriptableObject based event system, because of multiple reasons, but mainly because your game logic isn’t obvious from IDE or version control GUI. It’s important for big projects, because big projects are usually development by teams of programmers.
Also whole approach seems brittle and it’s hard to find bugs.
Important game events should be wired inside code, you should look at it and say “I’m sure it will work”.
Editor has its place, all configuration, tooling and “sand-boxing” for game-designers, components composition etc. but it shouldn’t be wobbly flexible spaghetti all over.

Other stuff like EventBus and reactive approach aren’t bad, but I don’t think that’s the default way to structure your game either.

I myself usually use DI. If I couldn’t do that I would use simple ServiceLocator.

But I wouldn’t advice you not to use Singleton pattern or dive into DI asap, especially if you are just learning. Maybe you will use singletons through your whole career without needing anything else. A lot of successful games made it with singletons.

There are easy to abuse, but most of problems they introduce can be mitigated.

  1. Have strict intialization order, if your singletons initialize in Awake and some singletons need other singletons for initialization, that’s bad.
  2. Avoid too much static mutable state, try to make one single place where your get access to your singletons, don’t lose control over their lifetime.
  3. Avoid big managers. If you have 1000+ loc class it’s pain regardless of approach.
  4. Even though they can be globally accessed, you shouldn’t abuse that, like don’t look into player’s inventory inside WeatherManager unless absolutely required by your game. Think about what classes with which name you create and how they all work together. Try to avoid circular dependencies.

That means that you have something like that (code below). This is just an quick example from my head, the key point is approach. If you ever try DI or ServiceLocator you will see it’s not that different from what I’m trying to show you here.

[DefaultExecutionOrder(-100)] 
public class GameInitialization 
{
    public HeroConfig HeroConfig; // ScriptableObject config
    public EnemySpawner EnemySpawner; // Object on game scene
    public UiConfig; // ScriptableObject config with all window prefabs

    private void Awake()
    {
        var hero = Instantiate(HeroConfig.HeroPrefab)
        hero.Inventory = new Inventory(HeroConfig.StarterItems)
        var heroHolder = new HeroHolder(hero);

        EnemySpawner.DoSomethingWhenYouSureHeroWasCreated(hero);

        var uiController = new UiController();
        uiController.CreateWindows(UiConfig);

        GameData.HeroHolder = heroHolder;
        GameData.EnemySpawner = EnemySpawner;
        GameData.UiController = uiController;
    }
}

public static class GameData
{
    public static HeroHolder HeroHolder;
    public static EnemySpawner EnemySpawner;
    ....
}
2 Likes

In a small project, it’s usually all just about the speed of development - use whatever solution pops to your head first, that is quick enough to type out and serviceable enough.

In a large project, other system quality attributes besides just short-term timeliness start to increase a lot more in importance. For example:

  1. Scalability
  2. Maintainability
  3. Debuggability
  4. Testability
  5. Efficiency
  6. Discoverability
  7. Learnability

Through these lenses, it can be possible to start seeing some of the potential pitfalls with a simple scriptable object -based event system:

Scalability

  • Once you have 1000 scriptable object events, is it still easy to find a particular one you need to modify among all of them?
  • As features get cut from the game, and UIs get replaced with new ones, is it easy to determine which events are no longer actually being used?
  • Can difficult-to-resolve merge conflicts occur frequently between team members when objects in scenes and prefabs are hooked into events?

Maintainability

  • If an event should accidentally get deleted, and missing references turn up in some of your many prefabs and scenes, is it easy to notice this issue? Is it easy to locate and fix all the missing references?

Debuggability

  • If something goes wrong with a chain of events, and your UI element isn’t reacting to a game event like it should, how easy is it to uncover where the issue lies?
  • If there’s a memory leak, and some big UI panel’s assets aren’t getting released from the memory, how easy is it determine if it’s because some objects in the panel forgot to unregister themselves from some event?

Testability

  • Is it easy to create automated tests for your systems to ease the burden on the QA team?

Efficiency

  • Can the event system become a performance bottleneck when there are dozens or hundreds of events being broadcast during one frame in the worst case?

Discoverability

  • If somebody needs to tweak the timing on a UI transition, how easy is it for them to find the location in code that triggers the event?

Learnability

  • How difficult is it to teach new programmers, game designers, audio designers etc. to work with the system?

Scriptable object event based systems tend to have exceptional simplicity, learnability, and quite good discoverability, but they can be subpar when it comes to scalability, maintainability and debuggability.

1 Like

Personally i have never seen the value of having ability to publish serialized data like a SO.

A message can contain a SO though for example (pseudo code)

public struct ShotFired
{
   public ProjectileType Type { get; init; } //Reference to SO
}

Could you give a code example of how you use DI ? Or a link to an article about DI that you think presents it the way you use it ?

Yes, I don’t think any approach is bad in itself. Some are just easier to make it work and maintain in a particular context.

Which solution would you use when you need scalability, maintainability and debuggability then ?

These are a couple of installers (there are more). It’s Zenject. I would use VContainer now, but I don’t have big examples for it, and they are not that different in binding dependencies.

For the example you gave, I would probably do something like this:

class OnCollision : Trigger
{
    [SerializeField] Condition<GameObject> condition;
    [SerializeField] Effect effect;
 
    void OnCollisionEnter(Collision collision)
    {
        if(condition.Evaluate(collision.gameObject))
       {
            effect.Execute();
       }
}

class PlayCutscene : Effect
{ 
    [SerializeField] Cutscene cutscene;

    void Execute() => cutscene.Play();
}

So on the code side, it’s possible to lean on Unity’s game object component architecture to create modular, reusable components that follow the open-closed principle.

One can also lean on Unity’s nestable prefab system, to divide the game into small enough individual building blocks, to allow many developers to work on assets in parallel without that many merge conflicts.

If there are still too many conflicts, then a claiming system could be implemented, so only one artist/designer can ever work on one particular asset at any given time.

The cutscene system could hook into an event system, which could further hook into the input system (to control input locking), dialogue and localization systems (resolving texts for the speech bubbles), UI system (controlling speech bubble visibility), tweening system (controlling speech bubble animations) etc.

By creating separate systems for different layers of the game, each tool can specifically be optimized for that particular use case. Instead of having 10000 scriptable object events, you could have 100 dialogues in the dialogue authoring tool, 100 audio events in the audio event authoring tool, 100 cutscenes in the cutscene database, 100 tweeners in the tweening library etc. Each database can have it’s own set of filters and categories specifically optimized for filtering its views. Each database can have it’s own validation logic for detecting entries with invalid state, and surfacing those errors to the users as early as possible.

And each of those tools could benefit from features like “Find All References”, “Find All Unused Entries”, tags, categories, hierarchical organization, a search box etc. to help with managing them.

Keep in mind that while Unity’s scenes, prefabs and scriptable objects are awesome tools, they tend to be the most painful ones to deal with when there are merge conflicts. IDE’s have a lot of useful functionality built into them (compile errors, Find All References, greying out of unused functions…), which you mostly have to implement manually when you create a custom Editor tool. Merge conflicts in code also tend to be easy to resolve in most cases. For this reason, managing events on the code side could sometimes be a better idea than doing it using scriptable object assets.

As for resolving Object references across scene and prefab boundaries, it’s possible to achieve this using a Guid-based system and custom property drawers that automatically assign those guids to Objects when you drag them into Object fields from one asset into another. This is called pure dependency injection, and is a really powerful and flexible way to resolve dependencies.

Another option is using a DI Container, which automates the process of wiring services to clients. This way you can just register your Player class as a service once, and then 100 clients can automatically receive the instance. And if you later on during development need to change the Player service to some other instance in some particular context, you just change the configuration in a single place, and all 100 clients will immediately be switched over to use the new service.

The singleton pattern offers similar functionality, but doesn’t support using interfaces, hides dependencies, and can lead to tightly coupled, fragile, spaghetti code if overused.

The service locator is an improvement over the singleton pattern in terms of flexibility, and does support interfaces. But it does not fix the issue of hidden dependencies, like dependency injection does.