Functional Unity Architecture: A Developer’s Guide

Functional Unity Architecture: A Developer’s Guide

I’ve been working with game development for a good chunk of time now, and Unity is my go-to. Over the years, I’ve played around with a bunch of different ways to set up projects and a handful set of “dependency solver” solutions— Singletons, Service Locators, Dependency Injection, and the Scriptable Object stuff. After all this messing around, I’ve found what I think is the sweet spot for me. It’s flexible, gets the job done, and doesn’t drive me crazy.

In the past 5 to 6 years, I’ve been settling down with these guidelines. They might not always be the exact fit, but they give me a good direction when developing. However, I want to make something clear: this isn’t set in stone. It might not work for every team or every project. Heck, a couple of years from now, I might have a completely different opinion on all of this! But for now, this is where I stand. I’m always learning, always evolving, so use this as you see fit. Just don’t hold it against me if I change my tune in the future — that’s how we grow, right?

Here’s a breakdown of my approach to Unity:

ServiceLocator: More than a Glorified Singleton

I’ve tried a bunch of ways to handle dependencies in Unity over the years. But now, I’ve found my groove with the Service Locator. It’s not just another Singleton. Here’s a quick look at my experiences and why I picked the Service Locator.

Singleton Problems: We all use Singletons in Unity, right? But those lazy Singletons have given me headaches. Racing issues, and crashes when the application closes? Super annoying.

Dependency Injection Frameworks: I get why some people like them. But for me? Too much extra code. And figuring out what to inject and what not? The constraints on execution order, constraints of which classes are able to have dependencies at all. More headaches. And don’t get me started on everyone using [Inject]. It’s not the best for performance.

Scriptable Object Architecture: I can’t stress enough how unsuitable this approach is, especially in larger projects. On the surface, it might seem like a great fit for small teams or small-scale projects. But in reality, the lack of scope on those scriptable objects creates a chaotic environment that heavily leans on memory recall. “What was the variable name again?” is a common lament. Here’s why I’m ardently against it:

Racing Condition Issues: With no straightforward way to pinpoint the source of triggers, debugging becomes a Herculean task.

Lack of Code References: Navigating a sea of scriptable objects without clear code references hampers efficient project management.

Oversized Naming: When you’re juggling potentially hundreds of such SOs, it necessitates the creation of lengthy, descriptive names, just to salvage some semblance of clarity.

And these are just the tip of the iceberg. Here are a few more points to ponder:

If you’re insistent on adopting this approach, consider ensuring each SO is a unique class. For instance, AsteroidExplodedGameEvent : GameEvent offers significant debugging advantages.

Crafting a suite of tools that assist in organizing these events (like identifying unused or used events) is crucial.

For those targeting this method to benefit designers: encapsulate the usage. It should be designed in a manner that minimizes potential issues arising from human errors — like attaching incorrect variables or dealing with null references.

So, why do I like my Service Locator?

Control Over Services: I decide when services start and stop. And their dependencies, no surprises

Explicit service registration


Less Direct Calls: With ServiceReference<T>, I don’t need to call the Service Locator every time. It’s cleaner. And make more explicit what are the dependencies of this object.

Easy to Find Services: I have a Services.cs file that is auto-generated where all services are listed. It’s like a cheat sheet. Makes life so much easier. Also, every MonoBehaviour that is a service gets a little editor to help

Another tool to improve visibility of what is a Service and what not
Easy way for developers to access or know about what Services exist on the project.


Editor Mode Compatibility: A big plus for me. If possible, services should work in editor mode. This helps them get better with other systems in the project.

After trying so many methods, the Service Locator is the best for me. It’s clear and straightforward.


Playable From Any Scene

Immediate Gameplay: Press play in any scene and the game should run. I’ve got editor tools that determine where you’re playing from and start the necessary bootstrap services.

ServiceReporter Prefabs: With the service locator, almost every game scene includes a ServiceReporter prefab. There’s also a primary Bootstrap prefab. This system lets me know what needs to be instantiated so the game can run smoothly from any scene.

Scene as Levels: If your game sees scenes as levels, creating a new scene should automatically make it a new game level. Whether through a naming pattern or another method, systems should be set up to handle this.

Empty Scene Support: Sometimes it’s useful to have an empty scene outside the main bootstrap. So, make sure there’s a way to easily toggle your system on and off. In my setup, “Untitled” scenes are random empty ones, but once saved, they’re treated as levels.


Application-Wide State Machines

These are awesome for managing stuff like loading and unloading scenes, addressable groups, and UI groups. Makes it easy for other systems to join the party and do what they need. Also makes it super easy to adjust and adapt things in the future. Have any new feature that requires a completely different flow? Should be fairly simple to add a new state, setup the transitions and should be a single call on the state machine to get everything working.

Example of one Application State, this defines what Scene/UI Group/Addressable Group should be loaded when transitioning to it.

A quick example of what a State is in my Project is a Scriptable Object that exposes references and allows quick customization of steps.


So after the initial Boot Scenes, the whole game is controlled by the state machine, when one state enters, multiple system reacts to it, and a lot its already taken care on the transition on the state machine, here is a quick example of how this is setup:

The only 2 scenes are actually loaded regularly, everything else, responds to State Machine transitions and changes.

UI Framework

Layered Approach: This system is ideal for games with intricate UI. While a main menu might have several states, some menus remain visible throughout. The layer system lets you stack items, streamlining the overall UI.

Complex UI Management: Multiple UI states can coexist. For instance, while navigating various menu options, some elements persistently remain on the screen. The layer system handles this seamlessly.

Navigation History: It’s not just about showing and hiding elements; the UI system tracks navigation history too. Not all UI elements need to be a part of this history. For example, pop-ups shouldn’t leave breadcrumbs behind.

UI Component Interfaces: The system provides interfaces like IOnWindowShown, IOnWindowHidden, and IOnWindowWillHide. This ensures components can operate independently without needing direct references from the system. They become self-sufficient units.

Default Animations: No need to reinvent the wheel for each window. The system offers built-in animations for UI elements, enhancing the user experience without redundant work.

Avoid Inspector Action Events: Inspector events can be a nightmare in the long run. They’re tricky to maintain and debug. It’s better to integrate callbacks directly into object events. It’s about making sustainable choices.

Seamless Loading Screens with State Machine: Coupling the UI Framework with a state machine makes transitions smooth. Since the UI doesn’t vanish, transitions between states can be adorned with loading screens or animations, especially when content loads in the background.

Flexible Initialization: A UI framework needs to be adaptable. Sure, in the final product, you’d rely on Addressables. But during development, the freedom to simply drag a window into a scene and get going is invaluable. Before fetching windows from Addressables, the system checks if they already exist in the scene.

Simplified Window Management: Built on the foundation of the Scriptable Object Collection , operating a window is straightforward. For instance, launching the settings window is as easy as calling Windows.SettingsWindow.Open().

Keep UI Simple: Many projects I’ve come across lean towards an MVC pattern for UI. They either use payloads or separate the Model, View, and Controller. I get it; they’re trying to address some challenges. But from what I’ve seen, these methods often create more problems than they solve.

UI can be a pain. A lot of devs aren’t fans of messing around with it. That’s why it’s best to keep it straightforward and functional. Sure, you can have systems that auto-display specific interfaces and stuff. These methods have their merits, but they also bring in more complexity.

Don’t just go for a fancy setup. And don’t bank on the hope that someday your UI will have full automated testing. That’s a dream for most of us. If you ever reach that point, deal with it then. For now, keep things simple and functional.

You can browse around my WIP UI Framework here


Lay the Groundwork with a Functional Game Loop Early On

In game dev, while we often dive deep into polishing gameplay — it being the core of most games — we shouldn’t forget the basics. Getting your game loop up and running early on is pretty essential.

So, why’s that? Well, kicking things off with a simple structure helps the team see where everything’s headed. Think about throwing in a couple of basic buttons for ‘game over’ and ‘win’ scenarios in your game. They might just be placeholders, but they sketch out how the game progresses, paving the way for all the fancy mechanics we’ll add later.

And this isn’t just about the main game loop. Take an in-game shop, for example. Start by slapping in a temporary UI. Put buttons and other elements roughly where you think they’ll end up. It might look bare-bones now, but it paints a clearer image of what you’re aiming for. This method helps you see the big picture early on, and you can spot any design hiccups before they become real headaches.

To sum it up, when you’re in the thick of production, focus on seeing the full picture of your features instead of getting lost in the nitty-gritty details. It speeds things up, makes team work smoother, and just results in a better game. Keeping this mindset from the get-go sets you up for a smoother journey in game dev.


Feature Flags

Feature flags, or feature toggles, are a powerful tool in the developer’s arsenal, serving as a bridge between continuous integration and the dynamic nature of feature development. The principle behind feature flags is straightforward yet incredibly effective: for every significant feature you introduce, begin by setting up a flag to enable or disable it.

By using feature flags, you can consistently merge new developments into your primary branch. This practice helps in reducing the integration challenges often faced during the final stages of feature merging. Regular integrations mean fewer conflicts and smoother overall development flow. With the feature flag in place, testing becomes more flexible. You can easily toggle the feature on for a subset of users or testers, gather feedback, make iterations, and refine the feature before a full-scale launch.

If a newly introduced feature causes unforeseen issues or isn’t received well by a subset of users, the flag acts as an emergency switch. You can quickly toggle the feature off, ensuring stability and allowing time for troubleshooting. Once you’re confident about the feature, you can roll it out progressively to your user base. This phased approach helps in gauging real-world performance and user reception, allowing for tweaks and refinements on the go.

Feature flags also facilitate collaboration. Different teams can work on various features simultaneously without stepping on each other’s toes. By toggling their specific feature flag, they can test in an environment that closely resembles the final product. Incorporating feature flags early on in the development process offers an organized, scalable, and risk-averse approach. It promotes a culture of continuous improvement, where features can be developed, tested, and refined in tandem with the ongoing project, ensuring a smoother and more efficient development journey.


Helpful Unity-Specific Tools

Working in Unity comes with its fair share of peculiarities. My advice: Combat those annoyances head-on by crafting your own utility tools. Investing a bit of time in creating these tools can result in significant time-savings down the line.

Take my toolkit, for instance. I’ve developed a suite of utility tools tailored for standard Unity operations. From handling Scene References to managing Layers and other specifics, these tools are indispensable. The foundation of these utilities is my Scriptable Object Collection package, which streamlines the process and integrates seamlessly with Unity. When you encounter a repetitive task or frustration in Unity, consider whether you can create a tool to make your life easier. It’s an investment that’ll pay dividends in the long run.

A simple quality-of-life thing, that allows you to quickly navigate between known scenes in the project
Automatically generated Scriptable Objects from Layers on the project
Automatically generated Scriptable Object for scene references!

Let’s Talk Cheats and Shortcuts in Development

During game development, we’re always looking for ways to speed things up. One of the best tools in our arsenal? Good old cheats. Yeah, I’m talking about the same cheats we used as gamers to breeze through levels or become invincible.

Say one of your devs is tweaking the game’s tutorial. They don’t want to play through the entire first level every single time, right? And someone working on a high-level boss fight? They probably want to skip the tutorial altogether. Then there’s the developer who’s trying to nail down character movements. A “god mode” or “no-clip” can save them tons of time.

So, the real deal here isn’t just about recognizing these cheats — it’s about implementing them early and making them easily accessible. Set up a system in the editor where devs can quickly toggle these cheats on or off. Doing this isn’t about cutting corners; it’s about efficiency. By giving everyone on the team these shortcuts, you’re cutting down on iteration time and making the entire development process a lot smoother. Don’t reinvent the wheel — just use the tools you already know to make game dev a little easier.


Now some more code-oriented stuff!

Watch Out for Abstraction

Abstraction is a powerful concept in programming. It allows for code reuse, a cleaner structure, and scalability. However, it’s essential to remember that with abstraction comes added complexity.

While I’ve grown fond of coding with Rider for its suite of potent tools, navigating a project bloated with generics and incessant typecasting can be a genuine challenge. Even with the best tools at hand, if a project’s architecture is over-abstracted, it can become a nightmare to traverse.

So, before diving into creating layers of abstraction, ask yourself: is this abstraction genuinely necessary? Are we abstracting just for the sake of it, or does it bring tangible benefits to our codebase? Remember, each layer you add transforms what might’ve been a simple search into a multi-step, convoluted process. Always validate the need for abstraction. Be intentional and strategic about it, ensuring it serves a clear purpose and doesn’t just complicate things further.


Optimize Early

I often hear the advice, “Premature Optimization Is the Root of All Evil”, and while I get the sentiment behind it, I believe it’s essential to differentiate between speculative, heavy optimizations and foundational, good practices. Sure, the early optimization anti-pattern exists mostly to protect developers from spending unnecessary time on speculative fixes. But that shouldn’t translate to implementing subpar practices, especially when the good ones aren’t any harder to adopt.

The crux of my belief here is: why leave for later what can be done correctly now? Especially when the “later” often comes with challenges like not remembering why a particular solution was adopted in the first place. For instance, consider object pooling. Incorporating a Pooling System from the start of your project, or using Unity’s object pooling, doesn’t demand significant extra effort. The same goes for using RaycastNonAlloc, avoiding LINQ, or caching components. These optimizations take almost the same time to implement as their non-performatic counterparts. So, why not opt for them from the get-go?

It’s crucial to discern between optimizing based on personal speculations and making informed decisions rooted in best practices. In essence, if there’s a beneficial optimization that’s not time-consuming and is more of a sure-shot than mere speculation, then it’s worth adopting early on.


Extendable, Not Over-Featured:

A crucial philosophy I stand by is: design your systems smartly. It’s undeniably valuable to build flexible systems. However, there’s a line between flexibility and overengineering. And crossing it often leads to wasted time and resources on features that “maybe” you’ll need down the road but never actually do.

Enter the concept of YAGNI — “You Aren’t Gonna Need It.” This principle holds a special place in my approach to development. The idea is simple yet profound: Don’t add functionality until you genuinely need it.

I’ve noticed a trend among developers: building features with the hope or assumption that they’ll be reused in future games or projects. While this foresight might seem like smart planning, I believe it’s misguided. Instead, the focus should be on creating features that best serve the current game. If a feature is robust and well-implemented, it will naturally become reusable. As you move to subsequent projects, they will bear the cost of abstracting and refining these features. This iterative process, over time, leads to features that are genuinely versatile and can be shared across projects.

In essence, the future is unpredictable. So, rather than attempting to forecast every possible need, concentrate on the present and make systems that cater to current requirements. If the need for expansion arises in the future, then and only then should you consider it.


Coder-Friendly Project Design

In the realm of game development, many of us lean heavily towards coding. The guiding principle I’ve come to rely on is this: the more processes managed through code, the smoother debugging, maintenance, and fixes become. While it’s essential to construct intuitive editors for our colleagues in design and artistry, it’s equally crucial not to overcomplicate things.

There’s a cautionary tale here with overly intricate customization. Sometimes, in our eagerness to provide flexibility, we expose so many tweakable options that it starts to resemble visual scripting. Yet, it’s not. Instead, it’s stuck in an unfavorable middle ground: not as robust or versatile as true visual scripting and not as efficient as traditional coding. This can lead to a cumbersome system that’s challenging to navigate, causing more harm than good.

The core message? Design with the developer’s experience in mind. Equip them with tools that genuinely enhance value and streamline the work for designers and artists. But always be wary of overcomplicating and inadvertently stepping into that treacherous in-between visual scripting and programming zone. Balance is key.


Anticipate Project Needs:

In game development, it’s crucial to think ahead. Consider integrating important components early in the project, even if they might seem relevant only for later stages.

Consider the Unity Addressables system. Integrating it early can make content loading and management smoother as your game develops. Localization is another key aspect. While you might focus on it later in the project, setting it up early can save you from many complications.

But, always remember the YAGNI principle (You Aren’t Gonna Need It). It’s about striking the right balance. Add what you’re sure you’ll need, but avoid unnecessary complexities. Systems like SDKs, libraries, or Addressables should be integrated early. This not only makes them available but also provides examples for the team, ensuring consistent use. It’s about being proactive while also staying focused on the essentials.


Pipelines & Team Knowledge

Make the most of your team’s smarts. If everyone knows C#, don’t start building tools in Python or Ruby. Keep things in the team’s expertise.


Unity-Feel for Custom Tools

When developing custom tools or editors within Unity, shooting for that Unity-consistent vibe is crucial. It’s all about that sense of familiarity. When tools mirror Unity’s own design and behaviour, you’re breaking down barriers. Your team can hop right in, embracing these tools without skipping a beat.

One pitfall I’ve noticed? UI Elements. Sometimes, tools built with them feel like a throwback to the web design days of the 1990s. It’s not just a visual hiccup; it can genuinely disrupt the Unity ambience we all know.

But there’s another oversight that really grinds my gears — overlooking “SerializedObject” and neglecting CTRL+Z support. It’s a no-brainer: developers love their undo button. Not supporting Undo, and, by extension, not handling scene/prefab overrides correctly, feels like a cardinal sin. It’s not just a functionality flaw; it defies the very principle of Unity consistency.

So here’s the bottom line: aim to give your team an interface experience that feels right at home with Unity’s native look and feel. It’s not always a walk in the park, but that’s the benchmark we should always chase. Keeping things consistent isn’t just about aesthetics; it streamlines the workflow and keeps the development gears turning smoothly.


Code Good Practices

Access Levels: In object-oriented programming, encapsulation is one of the pillars that holds your software together. Access levels help in achieving this encapsulation, ensuring that data is protected and can only be accessed or modified in appropriate ways.

Importance of Keeping Data Encapsulated: Exposing only what’s necessary shields the inner workings of a class and allows for a controlled interface to the outside world. It reduces the risk of unintended interference and simplifies the public interface of the class.

Private by Default: A field should start its life as private unless there’s a clear reason for it to be otherwise. If something doesn’t need exposure outside its class, it should be kept under wraps.

Read-Only Exposure: When you want to expose a field outside the class but don’t want it modified directly, utilize properties with getter-only access. This ensures that external entities can read but not tamper with the value.

The Role of Setters: The primary task of a setter should be to assign a value. Be cautious about overcomplicating them. Overstuffing a setter with logic diverges from its expected behavior, potentially leading to bugs and confusion. The principle here is similar to why a method named CalculateScore shouldn’t dispatch an event that ends the game and brings up the score menu. If you have a property named Health, and setting it to 0 triggers an event leading to the character’s death, that’s reasonable because it pertains directly to the Health property. However, setters shouldn’t carry out tasks that aren’t logically tied to the property they’re setting. Adhering to this ensures clarity and alignment with developers’ expectations.

In essence, it’s all about guiding and managing expectations. Encapsulation, achieved through careful management of access levels, not only structures the codebase but also ensures that the team interacts with it in expected, controlled ways. Respecting the principle of least surprise in your design decisions results in a more understandable and maintainable codebase.

SOLID & KISS: I try to stick with SOLID and KISS. But let’s be real: it’s super challenging to make every script do just one thing. So, while these principles are good, they’re not always feasible. Instead, focus on keeping your code clean and easy to read. That should always be the main goal for any team.


Avoid the “One Size Fits All” Approach

In game development, there’s sometimes a tendency to prescribe a single solution for various problems. But each scenario in game development has its own unique characteristics, and the tools and techniques should be selected accordingly.

Consider LINQ as an example. I’ve been cautious about using it in runtime code because of performance issues. Nevertheless, with advancements in C#, particularly version 8.0, LINQ has seen significant improvements. Even though I’m not a fan of it for runtime scenarios, I frequently employ it in Editor Time code, exemplifying the mantra of using the right tool for the right job.

Another ongoing debate in this space is Coroutines vs. Async operations, especially with tools like UniTask . UniTask and Async patterns indeed offer a plethora of benefits and functionalities. However, from what I’ve observed, many implementations fall short. Key aspects like cancellation tokens are often overlooked, rendering them unprepared for sudden stops. Simply put, if you’re switching from Coroutines to improperly implemented Async tasks, you’re just trading one set of problems for another. It’s essential to realize that Coroutines have evolved and are not as resource-intensive as they used to be. In situations requiring a “fire and forget” technique, Coroutines remain a practical choice.

For those seeking guidance on when to opt for one over the other, here’s a basic framework I tend to adhere to:

For operations where you anticipate a return result: Use Async.

For fire-and-forget scenarios: Coroutines are your best bet.

If the method in question is a continuation of another Coroutine, stick with Coroutines. If it follows an Async method, then Async is the way to go.

Anytime you’re considering utilizing the ThreadPool or performing operations outside the main thread: Async is preferable.

However, it’s crucial to be aware that merely using async for concurrent operations won’t always yield direct performance enhancements. Especially if you need to relay this data back to the main thread, the overhead of context switching can negate any performance gains you might have anticipated.

The introduction and utilization of async operations, particularly when leveraging UniTask, can offer developers a potent toolset. However, its correct application is paramount.

A recurring pitfall is either misusing or entirely overlooking cancellation tokens. These tokens play a pivotal role in efficiently managing interruptions or preemptively terminating tasks. When you’re harnessing UniTask, you’re equipped with an extensive array of features tailored explicitly for these tokens.

It’s not merely about choosing async or UniTask — it’s about maximizing their potential. Ensure you harness all the features at your disposal, especially when it comes to cancellation tokens, to uphold the integrity and performance of your codebase. Taking the time to make well-informed decisions in this domain can avert a myriad of potential challenges and performance bottlenecks in the future.


Conclusion:

Alright, we’ve covered a ton of ground here. But remember, this guide isn’t the be-all-end-all. Every game’s different, and what’s a lifesaver in one project might be dead weight in another.

All these best practices? Think of them as tools in your toolbox. Don’t just blindly pick one up because it’s shiny. Use it if it fits the job at hand. If not, leave it.

Game development is a complex beast. There’s no one-size-fits-all. Learn these practices, sure, but always be ready to adapt. Keep it flexible, stay curious, and make the game that feels right to you.

Useful Tools and Resources:

Scriptable Object Collection

https://github.com/brunomikoski/ScriptableObjectCollection/ It’s the bread and butter of most of my systems, the UI Framework / State Machine / Unity Scenes, Tags references and many more, this is a really powerful framework that resolves most of the pains I have when working with ScriptableObjects

UI Framework

https://github.com/brunomikoski/UIManager

It’s not fully fledged but It’s based on the Scriptable Object Collection and has most of the features I mentioned before.

Service Locator

https://github.com/brunomikoski/ServiceLocator

Not fully fledged but feel free to look around

Asset Palette

https://github.com/RoyTheunissen/Asset-Palette/

Great tool to create shareable palettes between the team.

Animation Sequencer

https://github.com/brunomikoski/Animation-Sequencer

Tool to allow tween animations

UniTask

https://github.com/Cysharp/UniTask

Provides an efficient allocation free async/await integration for Unity.

Vertx.Debugging

https://github.com/vertxxyz/Vertx.Debugging

Fast editor debugging and gizmo utilities for Unity.

KISS Principle

https://en.wikipedia.org/wiki/KISS_principle

SOLID Principle

https://www.c-sharpcorner.com/UploadFile/damubetha/solid-principles-in-C-Sharp/

YAGNI - You aren't gonna need it

https://en.wikipedia.org/wiki/You_aren%27t_gonna_need_it


https://twitter.com/brunomikoski

Frederico Zveiter

Senior Software Engineer at Drest

1 年

Golden tips, Bruno! Golden tips! ??????

Felipe Mello

Senior Unity Engineer at Illuvium.io

1 年

Thanks for taking the time to write this and share with us ??

Vinicius Pachá

Senior Game Developer at Supersonic Studios

1 年

Amazing Tips Man!!!

要查看或添加评论,请登录

社区洞察

其他会员也浏览了