When to use singletons, event bus, different patterns etc.?

For me important qualities are debuggability and simplicity. There is often advice to use different patterns for different problems, but what are the examples of this? Like a player and enemies, which pattern do you recommend for these to interact which each other?

That is EXCELLENT thinking. Don’t lose sight of that. It will carry you far.

Examples won’t be immediately useful. “players and enemies” is completely different in a Dark Souls game vs a bullet hell shooter vs a platformer vs… etc.

Instead, think of your data, the transformations you’re doing on it, and the lifetime of that data.

If you have data that must survive from scene to scene, you will need some kind of construct to enable that. There are many.

Also think of the interoperation of the data. What does thing A need to know about thing B? And then make an appropriate construct to enable that.

2 Likes

Events

Heavily relying on events for cross-object communication, has a tendency to not have great debuggability. There’s a risk there that the indirection introduced by events obfuscates what exactly was the original cause for triggering a chain of events that lead into some issue manifesting.

With events there can also be a risk with execution order issues, where an event gets raised before an interested party has had time to subscribe to it. Since event raisers by design usually shouldn’t know or care about who is listening to events, you don’t usually get any error messages for bugs like this.

For this reason events are usually not the best choice when:

  1. Delivering critical messages, where the game can break if some listener doesn’t receive the message.
  2. There should always be at least one recipient for a message.

What events can be great for is things like triggering visual and auditory effects. You can easily hook more effects to an event, without having to go modify the event triggerer. And if some footstep SFX doesn’t get played because an addressable asset containing the audio clip is still being loaded, that doesn’t break anything critical.

Good tooling can greatly enhance the debuggability for events though. Like if you use scriptable object based events, being able to easily find out all the objects that have subscribed to that event across all your different scenes and prefabs can be extremely helpful.

One benefit that static events can have over singletons, is that you can’t run into null/missing reference exceptions when subscribing or unsubscribing from events. It can be satisfyingly simple when you can just do Player.Damaged -= OnPlayerDamaged in OnDisable, without having to introduce complexity with if-else branches to handle situations like the scene being unloaded, or the application being exited.

Singletons

Singletons are super simple to use, and give you a clear exception if a client tries to use one before the object has been initialized. For this reason they tend to be a better choice for delivering important messages than events.

The biggest benefit of singletons is also their biggest pitfall: it is extremely easy to access the singleton getter from anywhere, at any time. Which is great - until it isn’t.

If you use singletons a lot, then it’s quite probable that over time you will get into a situation where you have many singletons, which depend on many other singletons, which depend on many other singletons, and so on…

Now, there may come a point during the lifetime of your project, where some singleton might not actually be ready to be used in some particular context.

  • Maybe Player.Instance is null in the main menu.
  • Maybe UIManager.Instance is null during a scene transition.
  • Maybe InputManager.Instance is null when the game is launched in headless mode as a server, or to run automated tests.
  • Maybe AudioManager.Instance.PlaySound is unsafe to use while the audio database is still being initialized.

This can create a dynamic, where it becomes very easy to cause a Null/Missing Reference Exception anywhere in the project.

In the long run you might find yourself adding a bunch of fragile if-else code all over your code base before accessing certain singletons…

if(!GameConfig.Instance.IsHeadlessMode
    && LevelManager.Instance.CurrentLevel != Level.MainMenu
    && !LevelLoadingManager.Instance.IsLoading
    && PlayerManager.Instance.Player != null)
{
    PlayerManager.Instance.Player.AudioController.Play(soundId);
}

Now what used to be extremely simple to use in isolation, doesn’t feel as simple anymore.

Singletons can be great when:

  1. You can guarantee that the singleton object, and all of its public members, are always ready to be used, regardless of the context.
  2. They have a limited scope (e.g. the static instance getter is private, and only used inside the one class).
  3. You don’t plan on writing much of any unit tests for your game.

tl:dr

  • Think of how the patterns you use will scale over the entire lifetime of your project.
  • Think of how to optimize for the overall complexity of the codebase, not just for the complexity inside a single class.
  • Think of how well the patterns can handle different edge cases.
  • Is it easy or difficult to make mistakes when using this pattern across many clients?
2 Likes

Also, I highly recommend that you try out new pattern ideas you might get in small test projects, rather than immediately going and refactoring your main project to use them.

It’s very easy to waste weeks rearchitecting your project for little gain, and lose all momentum, rather than focusing on finishing it :slightly_smiling_face: Remember that you can also always adapt that great new architecture idea in your next project.

Secondly, I find that a great way to go about making decisions about how a system should be architected, is to start from the pain-points.

You can just start off by using whatever solution first pops to your mind, and keep using it as long as it works. Then once it becomes apparent that there’s some major pain point with this approach, you can try to come up with a design that solves that pain point.

This way you’re not choosing design patterns using an overtly simplistic dogmatic formula (“Player class → ah, I should always use a singleton here!”), but instead keep your design decisions grounded firmly in practicality.

3 Likes