game dev

UNITY GAME ARCHITECTURE: PATTERNS THAT WORK

Every Unity project I've worked on starts the same way. Someone makes a GameManager. It's empty for about three days. Then it has a reference to the player. Then it has the current score. Then it has the pause menu, the save system, the audio manager, the analytics client, a singleton accessor, and a coroutine that runs the main menu animation. By month two it's a 4000 line class that nobody wants to open and everybody is afraid to delete. This is the God-Object trap, and I've fallen into it more times than I'd like to admit.

The reason it happens isn't laziness. It's that Unity doesn't ship with an opinion about how to architect a game. The engine gives you GameObjects and MonoBehaviours and that's the shape of the sandbox. Everything past that is on you. Which is a problem, because "figure it out yourself" is a terrible default for people shipping their first game on a deadline. So this post is me writing down the patterns I've actually used, where they work, and where they fall apart. No silver bullets, just the tradeoffs I wish someone had spelled out for me in year one.

The component pattern, which you're already using

Unity's built-in architecture is component-based. A GameObject is a bag of components, each component does one thing, and the GameObject's behavior emerges from the combination. Rigidbody handles physics, Collider handles collision, AudioSource handles sound, your PlayerController handles input. You don't write inheritance hierarchies like Enemy extends Character extends Entity. You compose behavior by adding components.

This is the thing I wish I'd taken seriously earlier. When I was new to Unity I kept trying to write object-oriented hierarchies because that's what my C# textbook taught me. I'd make a base Enemy class, a RangedEnemy subclass, a FlyingEnemy subclass, a ExplodingFlyingRangedEnemy that inherits from all three somehow, and then I'd hit the diamond problem and scream. The component pattern solves this by saying: don't inherit, compose. A flying ranged explosive enemy is a GameObject with a FlightComponent, a RangedAttackComponent, and an ExplodeOnDeathComponent. Each one is small, testable, and reusable.

Where the component pattern falls apart is when your components need to talk to each other. A HealthComponent needs to notify a UIComponent needs to notify an AudioComponent. If you do this by having each component hold references to the others, you're back to spaghetti. Which is why the next patterns exist.

ScriptableObject-based architecture

Ryan Hipple's 2017 Unite Austin talk "Game Architecture with ScriptableObjects" is the single most influential Unity talk I've ever watched. If you haven't seen it, go watch it before you read the rest of this post. The core idea is that ScriptableObjects aren't just for data. They're runtime singletons that live as assets in your project, and you can use them as event channels, shared state containers, and configuration objects.

The simplest version is a ScriptableObject event. You make a GameEvent asset. Publishers fire it. Subscribers listen. Nobody references anybody. Here's roughly what it looks like.

[CreateAssetMenu(menuName = "Events/Game Event")]
public class GameEvent : ScriptableObject
{
    private readonly List<GameEventListener> listeners = new();

    public void Raise()
    {
        for (int i = listeners.Count - 1; i >= 0; i--)
        {
            listeners[i].OnEventRaised();
        }
    }

    public void Register(GameEventListener l) => listeners.Add(l);
    public void Unregister(GameEventListener l) => listeners.Remove(l);
}

The listener is a MonoBehaviour that registers itself on enable and fires a UnityEvent when the GameEvent raises. What you end up with is a system where designers can wire gameplay reactions in the Inspector without writing code. Your player fires a PlayerDied event, and you can drop a listener on a UI panel, a camera shake, an audio trigger, and a save system, all without editing the player script.

The other pattern from the same talk is variable references. You make a FloatVariable ScriptableObject, your health bar reads it, your damage system writes to it, and the asset becomes a shared pipe between systems that never touch each other. It's almost too elegant. I've shipped two projects with this architecture and it works beautifully for small to medium games.

Where it breaks down is scale and debuggability. When you have 400 GameEvent assets in your project, finding which one fires when is a genuine chore. The Inspector wiring that felt magical at 20 listeners feels like detective work at 200. And debugging a call chain of "A raises event, B listens, B raises another event, C listens" is harder than stepping through a function call, because the stack trace doesn't tell you anything useful. I still use this pattern, but I use it surgically now, not as the whole architecture.

If you want a deeper dive on the event side of this, I wrote a whole post on event-driven architecture in Unity that covers the tradeoffs between C# events, UnityEvents, and SO events.

The service locator

Service locator is the pattern people reach for when they've outgrown singletons but haven't committed to dependency injection yet. The idea is that you have a central registry, and systems register themselves with it. When another system needs the audio manager or the save system, it asks the registry.

public static class ServiceLocator
{
    private static readonly Dictionary<Type, object> services = new();

    public static void Register<T>(T service) => services[typeof(T)] = service;
    public static T Get<T>() => (T)services[typeof(T)];
}

This is a step up from singletons for a few reasons. You can register a fake implementation in tests. You can swap implementations at runtime. You don't have to put a static Instance property on every manager you write. And you can actually reason about initialization order, because you control when things get registered.

The downside is that service locator hides dependencies. When you write ServiceLocator.Get<IAudioManager>() inside a method, the fact that this class needs an audio manager is buried in the method body. Someone reading the class signature has no idea what it depends on. This is fine at small scale and becomes a nightmare at large scale, where you want to be able to look at a constructor and understand what a class needs to function.

I use service locator for small projects and game jams. For anything I expect to live longer than six months, I reach for dependency injection.

Dependency injection with Zenject or VContainer

Dependency injection flips the relationship. Instead of your class going and asking for what it needs, something else gives the dependencies to the class. In Unity land, the two main options are Zenject (now called Extenject) and VContainer. VContainer is newer, faster, and more actively maintained. I use VContainer on new projects.

The pitch is simple. You write your classes to take their dependencies as constructor parameters. A container somewhere in your project knows how to construct those dependencies. When the container builds your class, it figures out what to inject and wires everything up automatically.

public class PlayerController
{
    private readonly IAudioService audio;
    private readonly IInputService input;

    public PlayerController(IAudioService audio, IInputService input)
    {
        this.audio = audio;
        this.input = input;
    }
}

What you get in exchange is massive. Dependencies become explicit. Testing becomes trivial because you just pass in fakes. You can swap implementations per scene, per build config, per platform. And the container can manage lifetimes, so you know exactly when things get created and destroyed.

What you pay for is a learning curve that's genuinely steep. VContainer and Zenject both have their own mental models and their own footguns. Setting up a project with DI for the first time is a weekend. Debugging a resolution error when your bindings are wrong is a special kind of frustration. And if you're working with a team that hasn't used DI before, you have to train them, because the patterns are different enough that nobody figures it out by reading code.

My rule of thumb is this. If the project has more than two engineers and is going to live longer than a year, DI is worth the cost. If it's just me on a game jam, it's overkill.

Event bus, the big global one

An event bus is the logical endpoint of the "events everywhere" philosophy. You have one central bus. Anyone can publish any event type to it. Anyone can subscribe to any event type. It's essentially a typed pub-sub system for your whole game.

EventBus.Publish(new PlayerDiedEvent { position = transform.position });
EventBus.Subscribe<PlayerDiedEvent>(e => audioSource.Play(deathSound));

I like event buses for specific use cases. Analytics is a great one. When something interesting happens in the game, you publish an event, and an analytics subscriber picks it up and fires it off to your backend. The gameplay code doesn't know analytics exists. Tutorials are another good one. The tutorial system listens for specific gameplay events to advance steps, without the gameplay code ever importing anything tutorial-related.

What I've learned to avoid is using an event bus as the primary communication channel for gameplay systems. When combat is driven entirely by events, debugging a "why didn't this attack register" question becomes a scavenger hunt. You can't step through the code because there is no code path. You have to set breakpoints in the bus itself and hope you catch it.

Events are great for cross-cutting concerns. They're bad for tight gameplay loops. Mix accordingly.

ECS and DOTS, a different universe

Everything I've talked about so far assumes you're using MonoBehaviours. Unity's Entity Component System is a different paradigm. In ECS, entities are just IDs, components are pure data structs, and systems are stateless functions that operate on components. It's much closer to how games like Overwatch or Factorio are architected under the hood, and it's built for raw performance.

The reason ECS exists is that MonoBehaviours are cache-unfriendly. They're reference types scattered all over the heap, and when the CPU has to chase pointers to find each one's data, you're paying cache miss costs that add up fast when you have ten thousand enemies on screen. ECS stores data in tight contiguous arrays, which means the CPU can chew through thousands of entities in the time it would take to update a handful of MonoBehaviours.

If you're making a game where you have hundreds of thousands of active things at once, ECS is the right answer. RTS games, factory builders, bullet hells with insane density, physics sandboxes. If you're making a narrative adventure game or a platformer with 30 enemies on screen, ECS is massive overkill and the restrictions will hurt you more than the performance helps. The tooling is still maturing, authoring is less friendly than MonoBehaviours, and a lot of the Unity package ecosystem doesn't play nice with Entities.

I treat ECS as a specialist tool. I have used it for simulation-heavy subsystems inside an otherwise MonoBehaviour game, which is the hybrid approach Unity officially supports. Useful, but not a free lunch. If you're thinking about performance more broadly, the conversation about C++ game development covers some of the same cache-locality concerns from a different angle.

Avoiding the Manager God-Object trap

Now we get to the real topic. Every pattern I've described above has the same enemy, which is the tendency of a Unity codebase to grow a class called GameManager or LevelManager or MainController that ends up owning everything. Here's how to not do that.

First, give your managers one job. If a class is called AudioManager, the only thing it should do is audio. Not audio plus saving preferences, not audio plus the pause menu, not audio plus loading levels. If you find yourself naming a class Manager with nothing after the word, that's a red flag. Managers of what? Of the whole game? That's not a class, that's a mood.

Second, stop using static singletons for everything. Static state is sticky. Once AudioManager.Instance is used in sixty places, you can never change how it's constructed, you can never replace it with a fake in tests, and you can never have two of them at once. Use DI, or a service locator, or at least make your singletons implement an interface so you can mock them.

Third, prefer many small scripts to one big orchestrator. A player in a well-architected Unity game might have a PlayerInput, a PlayerMovement, a PlayerHealth, a PlayerCombat, a PlayerAnimation, and a PlayerAudio. Each is maybe 100 lines. They communicate through events or shared ScriptableObject variables, not through a PlayerController that does all six things in 900 lines.

Fourth, resist the urge to centralize. The reason God-Objects form is that centralizing feels easier in the short term. You have two systems that need to talk, and sticking a manager in the middle feels simpler than wiring events. It is, for the first five minutes. Then you have five systems talking through the manager. Then ten. Then the manager knows about everything and everything knows about the manager. You're back where you started, but with worse naming.

So which one should you use

Honestly, it depends. A solo game jam project? Components plus a couple of ScriptableObject events, ship it. A team project with a one year horizon? Components plus VContainer plus ScriptableObject events for designer-wired reactions. A simulation-heavy game? Start with MonoBehaviours, migrate hot paths to ECS when you measure an actual problem. A narrative game with 20 active entities? MonoBehaviours all the way down, with an event bus for cross-cutting stuff like analytics and tutorials.

The mistake I see most often is people picking an architecture because it's trendy rather than because it fits. I've seen game jam projects with full DI containers and I've seen 40 person studios running on static singletons. Neither of those is the right choice. Pick the simplest thing that solves your actual problems, and be willing to change it when those problems change. The goal isn't architectural purity. The goal is shipping a game you're proud of without hating your codebase by the end.

If you're just picking your first language and engine for this kind of work, I wrote a separate piece on programming languages for game development that covers how C# stacks up against C++, Rust, and the rest. Architecture patterns matter a lot less if you're fighting the language itself.

Architecture is a tool. The enemy isn't bad patterns, it's unexamined ones. Pick one, try it, notice what hurts, pick something else next project. That's the whole game.

← Back to the Sketchbook