Skip to main content

Indie game storeFree gamesFun gamesHorror games
Game developmentAssetsComics
SalesBundles
Jobs
TagsGame Engines

Decoupling with Style: My Journey from Custom Code Generation to Roslyn in Unity

Hey everyone! 👋

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.

Why I Use a Message Broker in Unity

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:

  • ❌ The project becomes harder to read.
  • ❌ Relationships between systems become harder to follow.
  • ❌ Debugging becomes a painful game of tracing call chains.
  • ❌ Testing in isolation? Nearly impossible.

So, what is the approach I'm following to fix this?

A Hybrid Between Observer and Mediator

In my architecture, I'm aiming for the benefits of the Observer pattern:

  • ✅ Decoupled communication between systems
  • ✅ One-to-many messaging
  • ✅ Real-time event propagation

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:

  • ✅ Loose coupling
  • ✅ Centralized control over message flow
  • ✅ Easier testing, logging, and debugging

How it works:

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.

Example of a message in Unity Editor
This picture shows how a message of type OnCharacterCreated looks like in the Inspector panel in Unity Editor


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

Why I Initially Went with Code-Generating Code

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:

  • Be tedious,
  • Introduce room for human error,
  • Break the SOLID principles
  • And tightly couple the broker’s structure to the dev cycle.

So I asked myself: What’s the minimum I should have to write to define a new event? Just the event definition. Nothing more.

The First Solution: Code That Writes Code

Here’s what I came up with:

I define message events as ScriptableObjects.

Each one specifies:

  • The event name
  • Parameter names and types
  • (Optionally) other metadata

I wrote a custom Unity editor script that:

  • Scans for these ScriptableObjects
  • Generates the boilerplate C# code to update the MessageBroker
  • Creates subscriber hooks, type-safe accessors, and helper methods

The result?

Whenever I need a new message, I just:

  • Create a new ScriptableObject instance (this creates the message object)
  • Fill in a few fields in the Inspector (this defines the message object)
  • Run a menu command in Unity (this regenerates the message broker code at once)

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.

Why I Switched to Roslyn

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.

What Is 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.

Was It Easy?

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.

But was it worth it?

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.

Long-Term Gains

Now that I’ve got it working, I have:

  • A cleaner, more robust code generation pipeline.
  • Type safety and compiler errors when something’s wrong.
  • No more stale generated files or manual cleanup.

Disadvantages

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.

Lessons Learned

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.

What’s Next?

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:

  • How I set up a Roslyn source generator
  • The challenges I faced integrating it into a Unity project
  • And how the final system works under the hood

So if you’re curious about how to actually build something like this — stay tuned!

Thanks for reading and happy coding!

— Dee-Dee-R

Support this post

Did you like this post? Tell us

Leave a comment

Log in with your itch.io account to leave a comment.