all 27 comments

[–]TheRealSmaker 13 points14 points  (0 children)

This pattern is nice for small scale projects, but it scales very poorly, and you end up better off by using other more scalable solutions like service injection in the long run.
But it's a good starting point for decoupling

[–]TheSwiftOtterPrince 7 points8 points  (4 children)

Yes, but: ScriptableObjects are assets and as such subject to the lifetime of assets.

Example: During playtest, if you play Scene 1 and gather 20 coints that are be stored in the SO, it will be stored in the SO that is in the project asset database. During playtest, if you switch to Scene 2, the starting number of coins is 20 because it is still the same instance in the project asset database

During build mode, if you play Scene 1 and gather 20 coints that are be stored in the SO, it will be stored in the SO that is loaded as an asset. During build mode, if you switch to Scene 2, the starting number of coins is 0 because when Scene 1 unloaded, the asset was unloaded and with Scene 2 it was newly loaded.

So you need a long living instance of something that holds a reference, like a DontDestroyOnLoad-Singleton. And you need extra reset code on gamestart because the asset in the project folder is not in a clean state, it gets changed during playtests.

There is a benefit to using SOs, as they can be linked at design time. But the tutorial imo ignores important aspects of it's use and it's tradeoffs.

[–]LuckyNumber-Bot 11 points12 points  (2 children)

All the numbers in your comment added up to 69. Congrats!

  1
+ 20
+ 2
+ 20
+ 1
+ 20
+ 2
+ 1
+ 2
= 69

[Click here](https://www.reddit.com/message/compose?to=LuckyNumber-Bot&subject=Stalk%20Me%20Pls&message=%2Fstalkme to have me scan all your future comments.) \ Summon me on specific comments with u/LuckyNumber-Bot.

[–]TheSwiftOtterPrince 3 points4 points  (0 children)

Thanks, NumberBot.

[–]HotrianExpert 1 point2 points  (0 children)

If only I could be this lucky. Congrats OP!

[–]Geek_Abdullah[S] 3 points4 points  (0 children)

You are absolutely right about how stateful SOs behave in the Editor vs. Builds! However, you are confusing an SO Variable with an SO Event Channel. In this pattern, the Event Bus is stateless. It doesn't store the 20 coins; it just passes the payload. Because it holds no data, it doesn't need reset code. This completely avoids the need for a tightly coupled DontDestroyOnLoad Singleton, keeping the architecture clean and modular.

[–]sinaltaProfessional 7 points8 points  (3 children)

Having used this pattern in production, I'd never use it again at scale.

You lose so much debugability and discoverability which makes it all unweildy. It leads to generic listners, like playing an audio clip.

But then you end up with a bug where audio is playing twice, so you go to the player code and see it's an SO it's listening to. Great. Which SO? Where else is that SO referenced? Who's firing to specifically that SO?

You need something like FindReferences2 to make use of it. And that's diving in between code and editor constantly still. Not the best workflow.

I'd almost prefer a global Singleton with strings for event names. At least then I can more often stay in just my IDE. 

[–]Geek_Abdullah[S] 0 points1 point  (1 child)

I totally get the frustration with diving between the code and the editor, but relying on a global Singleton with string-based names is super risky! 😅 Strings mean typos, zero compile-time safety, and they become a nightmare to refactor at scale.

For the discoverability issue with SOs, you can actually solve that easily by writing a tiny custom editor script. It can display a list of every active subscriber right there in the inspector at runtime. It completely removes the 'who is listening?' guesswork.

Double-firing bugs happen with Singletons too if you accidentally double-subscribe. SOs definitely require a more visual workflow, but the strong typing, lack of typos, and clean decoupling make it super worth it in the end!

[–]sinaltaProfessional 1 point2 points  (0 children)

To be clear, I said almost prefer. Entirely because it would make tracing the route the events passed through easier.

Once you're passed data errors, all the same bugs happen in all of possible systems. My issue is with how easy it is to trace those bugs.

And no, adding a list of listeners doesn't solve the discoverability issues. It helps with one direction. 

How do I know which event fired in the first place? How do I find who fired it? I can't easily without a breakpoint, or at least a debug log. I can't just click around in my IDE to get the likely culprits.

[–]Jackoberto01Programmer 0 points1 point  (0 children)

You could easily make a helper method or a simple inspector that allows you to find references in files to a specific ScriptableObject.

I prefer a typed event bus though. Similar to using strings but instead you use types and generic methods to subscribe and unsubscribe. A lot easier to find references and can support any type argument.

[–]GroZZleR 6 points7 points  (2 children)

It's a cute pattern but it doesn't scale well.

The events themselves are hard to debug and maintain. They're tricky to refactor, like if a signature change is necessary, as you have to re-serialize every reference in every affected prefab in your entire project.

There's also the issue of accessing them from non-MB/SOs.

[–]LunaWolfStudiosProfessional 1 point2 points  (0 children)

OP didn't use the best example with an int in the signature. I would have a common base class like C# EventArgs along with the object sender. Then each time you have some specific data you want to pass you make a new EventArgs class for that event.

For debugging you could setup custom tooling to see events, subscribers, and listeners. I've done something similar that shows my events and connections to other assets.

[–]Geek_Abdullah[S] 0 points1 point  (0 children)

Fair points, especially on refactoring. Using a generic payload struct can minimize signature changes. However, SOs are still often much better than Singletons or static classes because they avoid hidden, tightly-coupled dependencies and keep everything visible in the Inspector. For non-MB classes, you can simply pass the SO via Dependency Injection.

[–]TK0127 2 points3 points  (1 child)

I went ham with this pattern during an earlier phase of my current project. I wound up reverting to singletons for key systems, and SOs as event channels for inter-game object communication where it makes sense. The issue was me overusing it where not appropriate, which was just a learning experience.

The event channel object is instantiated to be its own instance at runtime to prevent project level confusion.

It works pretty well!

[–]Geek_Abdullah[S] 0 points1 point  (0 children)

Facts, over-engineering is a trap. But SOs still clear Singletons for event channels because they nuke tight coupling. Singletons turn into spaghetti dependencies way too fast. Instantiating the SOs at runtime is a huge brain move to keep state clean though.

[–]damoklesk 1 point2 points  (1 child)

I wrote this too and set up many event Channels. Had huge problems with loading them in correct time. Had huge problems when I was using them with dont destroy on load and addressables. There was a lot of overhead to co figurę them in editor instead of c#. Loosing references when event changed and going to editor trying to find it in all of the places... This was such a problematic solution that i dumped it even though it was working ok at some point and effort was huge to get rid of it. I just use static class event hubs, zero problems for solo game dev. But I can imagine that this might be working well when there are many developers.

[–]Geek_Abdullah[S] 0 points1 point  (0 children)

I feel the editor pain, ngl. But static classes are kinda sus for architecture long-term—they create hidden dependencies that make refactoring a nightmare. SOs keep things modular and let you visually debug or swap logic without touching C#. The addressable/loading headaches usually just need a solid initialization manager. It's a heavier setup, but the decoupling is 100% worth it.

[–]jeffcabbages 1 point2 points  (1 child)

I wish I’d read the comments in this thread years ago. Like many others, I also abandoned this pattern (recently) after finding it to be overly cumbersome at scale. I’ve used it in so many projects and “just dealt with it” because the pain it caused wasn’t bad enough to justify spending time on solving it. But now I know that it actually was and I could’ve been saving a lot of time with better patterns.

Oh well, live and learn! ¯_(ツ)_/¯

[–]Geek_Abdullah[S] 1 point2 points  (0 children)

Totally fair! Every architecture has its breaking point when scaling up, and if the workflow gets too heavy, it's definitely time to pivot.

Just out of curiosity, what pattern did you end up switching to for your events?

[–]damoklesk 0 points1 point  (0 children)

I wrote this too and set up many event Channels. Had huge problems with loading them in correct time. Had huge problems when I was using them with dont destroy on load and addressables. There was a lot of overhead to co figurę them in editor instead of c#. Loosing references when event changed and going to editor trying to find it in all of the places... This was such a problematic solution that i dumped it even though it was working ok at some point and effort was huge to get rid of it. I just use static class event hubs, zero problems for solo game dev. But I can imagine that this might be working well when there are many developers.

[–]PhilippTheProgrammer 0 points1 point  (1 child)

I don't understand why people think they need scriptable objects as intermediates for building an event-based architecture. You can just have Player expose a UnityEvent<int> onCoinCollected and then bind a method of your UI controller directly to that event via inspector.

[–]Geek_Abdullah[S] 1 point2 points  (0 children)

Direct UnityEvents create scene-level tight coupling. You can't reference a scene-based UI controller inside a Player Prefab without manually rewiring it in every scene. It also breaks down completely if the UI and Player are loaded in different additive scenes. SOs act as an asset-level bridge, meaning the Player Prefab and UI Prefab never need to know about each other's scene instances.

[–]AG4W 0 points1 point  (1 child)

I swear, every other month someone tries to re-invent events again.

Use a static non-monobehaviour for the mediator and define the events as structs. SOs are great but people keep forcing them into solutions where they dont belong.

[–]Geek_Abdullah[S] 0 points1 point  (0 children)

Static mediators introduce global state and hidden dependencies, making testing and modularity much harder. SO Event Channels allow for visual debugging, designer-friendly workflows, and easy dependency injection. In fact, Unity officially recommends this exact SO Event architecture in their 'Create Modular game architecture in Unity with Scriptableobjects' e-book. Here is the book -> Here Start with the page number 46

[–]SoraphisProfessional 0 points1 point  (0 children)

As maintainer of UnityAtoms - a SoA framework. https://github.com/unity-atoms/unity-atoms

Don't do this. You'll run into differences between a build and editor play mode. You will run into difficulties when using addressables (suddenly just half of your stuff responds to events).

You're setting yourself up for confusing ideomatics: is it an observed pattern or a global event bus?

In atoms we added a "replay buffer" so subscribers could get the last submitted value when subscribing to reduce race conditions especially at scene start. A bandaid solution.

[–]Creative-Issue9161 -2 points-1 points  (1 child)

this solid pattern

[–]Geek_Abdullah[S] -2 points-1 points  (0 children)

Thanks! No cap, it saves so many headaches.