I wanted to share a behind-the-scenes update on a big improvement I recently made to the architecture powering my Unity projects, specifically the system I use for message/event handling.
Before diving in, a quick disclaimer:
I don’t claim to own the truth. I'm a senior software engineer with years of experience — but I don't know everything on everything. My goal here is to learn, explore, and build. That means trying out both well-established patterns and weird ideas that pop into my head. Sometimes those ideas turn out to be great — sometimes not. But every step teaches me something.
This post is about one of those steps: replacing my custom code generation pipeline for my message broker system with Roslyn.
Now, with that out of the way…
In Unity projects, especially those that grow beyond prototypes, maintaining clean, decoupled communication between systems becomes essential.
That’s where a Message Broker comes in.
One of the main reasons I adopted a message broker is simple: I can’t stand messy webs of references between classes.
When scripts start directly referencing other scripts, scenes quickly become a spaghetti mess of dependencies. That might get things working fast, but it comes at a cost:
So, what is the approach I'm following to fix this?
In my architecture, I'm aiming for the benefits of the Observer pattern:
However, instead of relying on direct event references (like native C# events), all messages go through a central class: the MessageBroker.
This introduces a structural element of the Mediator pattern:
Components don’t talk to each other directly. They send messages to the MessageBroker, which forwards them to subscribers.
So while the behavior is that of an Observer system, the architecture uses a Mediator-style central dispatcher to handle routing.
This hybrid approach gives me:
This is the definition of a Message scriptable object.
Here, InputParameter and ReturnParameter both inherit from ParameterBase. Multiplicity and ParameterTypes are enumerations.
[CreateAssetMenu(menuName = "MessageBroker/New Message", fileName = "NewMessage")] public class Message : ScriptableObject { [SerializeField] private string messageName [SerializeField] private string messageCategory; [SerializeField] private string sendMethodComment; [SerializeField] private string eventComment; [SerializeField] private List<Inputparameter> inputParameters; [SerializeField] private ReturnParameter returnParameter; } [Serializable] public abstract class ParameterBase { [SerializeField] private Multiplicity multiplicity; [SerializeField] private ParameterType parameterType; [SerializeField] private bool isNullable; [SerializeField] private string otherType; [SerializeField] private string parameterComment; }
This is an example of how a message created as a ScriptableObject looks like in the inspector panel in Unity Editor. As you can see this is a message of type CharacterCreated, grouped in a category named Character.
The following code shows the code generated by the message above by the code generator.
The MBCharacter class groups event messages by categories.
public class MBCharacter { public event EventHandler<charactercreatedeventargs> OnCharacterCreated; public void Send_OnCharacterCreated(object sender, object target, Character.CharacterStats characterStats) { if (sender == null) { var errorEventArgs = Common.CreateArgumentNullExceptionEventArgs("Character", target, "sender"); MessageBroker.Instance.Logger.Send_OnLogException(sender, target, errorEventArgs); return; } if (characterStats == null) { var errorEventArgs = Common.CreateArgumentNullExceptionEventArgs("Character", target, "characterStats"); MessageBroker.Instance.Logger.Send_OnLogException(sender, target, errorEventArgs); return; } var eventArgs = MessageBrokerEventArgs.Pool<charactercreatedeventargs>.Rent(); eventArgs.Sender = sender; eventArgs.Target = target; eventArgs.CharacterStats = characterStats; eventArgs.EventName = "OnCharacterCreated"; OnCharacterCreated?.Invoke(sender, eventArgs); } }
The following code shows how the various categories concur to compose the MessageBroker.
public interface IMessageBroker { MBCharacter Character { get; } MBGame Game { get; } ... }
The following snippet shows how to subscribe to the same event from another class interested in receiving that kind of messages.
private void Subscribe() { MessageBroker.Instance.Character.OnCharacterCreated += Handle_OnCharacterCreated; } private void Handle_OnCharacterCreated(object sender, DeeDeeR.MessageBroker.CharacterCreatedEventArgs e) { // Handle the event }
And finally this is how the CharacterBuilder class broadcasts the CharacterCreated event, once it is done creating it.
private void CreateCharacter() { var instance = new CharacterStats(); // Create new character ... // ... and finally MessageBroker.Instance.Character.Send_OnCharacterCreated(this, null, instance); }
If you are interested in the full source code, you can find it on my D&D SDK source code repo on GitHub
When I first started designing my message broker system, one goal was very clear:
It needs to be flexible and evolve as the game takes shape.
As new gameplay features are added, new messages are needed, often spontaneously. I didn’t want to go back and manually edit the MessageBroker every time I introduced a new event.
That would:
So I asked myself: What’s the minimum I should have to write to define a new event? Just the event definition. Nothing more.
Here’s what I came up with:
I define message events as ScriptableObjects.
Each one specifies:
I wrote a custom Unity editor script that:
The result?
Whenever I need a new message, I just:
And just like that, the code updates itself. No manual edits. No repetitive typing.
At the time, this felt like the simplest and most scalable solution, and it worked well during the early phases of development when things were changing constantly.
At some point, I started questioning whether maintaining my own custom code generation pipeline was the best approach for such a task.
The reason is simple:
I don't believe in reinventing the wheel.
If a functionality I need already exists, and it's well-tested, widely used, and actively maintained, then that’s the path I’ll usually take. Custom code is effective and straight to the point, but making it stable, maintainable, and robust is a big investment. Time I’d rather spend building and experimenting with other activities.
That’s when I turned to Roslyn.
Roslyn is Microsoft's open-source compiler platform for C#.
It gives you deep access to the syntax, semantics, and compilation process of your code, including the ability to generate code at compile time via source generators. It’s a natural fit for problems like mine, where I want type-safe, auto-generated code.
Absolutely Not. 😵
Switching to Roslyn came with a very steep learning curve.
The APIs are powerful but unintuitive, the documentation is scattered, and getting things working inside Unity adds a few extra headaches.
In the short term... probably not.
The system I had worked just fine for the job. But I was tempted. I wanted to learn Roslyn. I wanted to understand how it works and what it can offer. And this project gave me the perfect excuse.
Now that I’ve got it working, I have:
While Roslyn has many strengths, it’s not without trade-offs:
Steep learning curve – Getting up to speed with Roslyn's APIs and tooling is challenging.
More verbose code – The generated code tends to be bulkier and more complex than what I previously produced with my custom generator.
Inherited complexity – Some of the issues I had with custom code generation (like structural complexity and boilerplate maintenance) were simply transferred into the Roslyn-based version — just in a more sophisticated wrapper.
Experimenting with Roslyn has been a rewarding experience, but it also came with important lessons:
Roslyn is powerful, but not always necessary – If your code generation needs are simple and stable, a well-maintained custom generator might be more efficient.
Clarity matters more than cleverness – Tools like Roslyn can abstract away complexity, but they can also hide it. Make sure the benefits outweigh the added obscurity for your future self or team.
Invest time where it matters – Building infrastructure is tempting, but only worth it if it meaningfully reduces effort down the line. If you're spending more time maintaining tooling than building the game… something’s off.
Learning has its own value – Even if Roslyn didn’t save me time immediately, learning how it works expanded my understanding of the C# ecosystem and improved the way I think about architecture.
In this post, I focused on why I moved from custom code generation to Roslyn, and what motivated that decision.
In the next post, I’ll dive into the how:
So if you’re curious about how to actually build something like this — stay tuned!
Thanks for reading and happy coding!
— Dee-Dee-R
Did you like this post? Tell us
Leave a comment
Log in with your itch.io account to leave a comment.