Tracking and responding to changes in the state of your game is one of the age-old challenges of game programming. There are always tradeoffs and decisions about this problem, and the challenges only increase the longer you go without deciding on your paradigm.
So what do you do about it? What even are the options to choose from? And do I have my own solution to self-promote with this writing? The answer to the last one is a resounding yes! But more on that later.
The Problem
In short, the challenge is this: When something happens, and your game needs to respond to that thing happening, how do we accomplish that? The object directly involved in the change are easy enough, but what about other objects in the world? How should nearby NPCs respond to a car crash? How does your UI know what your main character's health just changed to? None of these are diretly connected to the event that happened, but they should do something about it.
Option 1: Polling
The first and simplest option is, in most ways, the worst one. Polling involves repeatedly checking the state at some regular interval, and then activating some functionality based on that.
The good news about polling is that it's the old reliable. There is rarely if ever a state that cannot be watched for using the polling approach. Whether you're looking at your game's internal state, a plugin, a website, or hardware, polling will always work.
There are basically three downsides of polling.
Performance: If you have a hundred systems checking the state of one other thing in their Update methods, that has a lot of potential to be a performance drain. This is especially true if checking the state requires extra resources, such as the bandwidth needs of pulling a website over the internet.
Response Delay: If you mitigate the performance drain of polling by reducing the polling frequency, such as checking only once per second, then your game will suffer from slow response time on reacting to new states.
Limited Information: While you can see the old state and the new state when polling, you generally won't get any information about the transition itself. So this object used to be on the left and now it's on the right. Was it teleported there? Did it get knocked over there by physics? Did the player move it? Is it staying still in that new position or is it still moving? You probably won't be able to get that information when polling, and how you respond to that movement may be different in those different scenarios.
Polling is best used as the method of last resort, when no other event types are available to you. In practice, this usually happens when you're integrating with something made by somebody else or to hardware.
When you do use polling, it's good practice for the pipeline that you're using to be as narrow as you can make it. One approach to this would be to just poll from one central system, and when a change is detected, that system pushes out the updated state via whatever your real event system is.
Option 2: Everything Talks to Everything
This is a common approach for people relatively new to the industry, who haven't yet had the chance to get burned by the long term problems of it. When your player character takes damage, it finds the UI system and tells the health bar to show its new value.
This is kind of accepted for rapid prototyping, and it's very easy to let small amounts of it creep into serious projects, even for seasoned professionals. But large projects just plain can't work with large amounts of this, for two huge reasons:
Code maintenance that increases exponentially with project size. Right now, sure, there's just one screen where you need to display your player character's health, so your character can directly point at that place and tell it what to show. But next week what if you are displaying the health in a second place? What if you get a second player character that needs to show its health, too? Every new feature you add needs to connections, and with the "everything talking to everything" approach, the number of code changes you'll need to make for each change will increase exponentially as your project gets more and more interconnected.
Code dependency. This is closely related to the first point, but sort of the other side of the coin. Using the healthbar example, think about completely replacing your UI with a newly built one, maybe a different UI for mobile or VR. In theory, if you delete your UI, your entire game shouldn't break. You might not be able to click buttons, but the things that break should be limited to things that make sense. But if you're directly changing values across systems, then you delete your UI and your character controller breaks. That's bad.
Unit Testing. Unit testing is basically impossible with this approach because everything is a single unit. When you change your character's health, it must change the healthbar's value. So a unit test simply isn't able to test the character's health-changing logic without also finding issues with the UI, which isn't how unit testing is supposed to work - unit testing needs to be able to narrow problems down as specifically as possible.
Beyond just being able to delete and add systems without breaking everything, being able to separate code has benefits in the form of assembly definitions (asmdefs). While this is a topic for another day, essentially an asmdef divides up your codebase into separate sections, and when you do this, the compiler doesn't need to recompile unchanged parts. On big projects, especially those with lots of third-party code packages, compilation times can quickly get out of hand if you're not using asmdefs. I've seen compilation times drop from minutes to seconds with judicious use of asmdefs to section of large pieces of code.
In short: don't do this. In fact, if you can get some asmdefs set up from day one in your project, this will help enforce code separate and keep this from happening in the first place.
Option 3: C# Events
Using C# events is a little bit more versatile than directly manipulating values. In our health bar example, instead of the player character telling the health bar directly what to change, it announces its own change and lets other systems subscribe to that. Depending on how they're used, this can be a big improvement.
public class PlayerCharacterController : MonoBehaviour {
public PlayerCharacterController instance;
private void Awake() {
instance = this;
}
public event Action<float> OnHealthChanged;
public float health = 100f;
public void TakeDamage() {
health -= 10f;
OnHealthChanged(health);
}
}
public class UIHealthBar : MonoBehaviour {
void Start() {
PlayerCharacterController.OnHealthChanged += UpdateHealthBar;
}
public Text txtHealth;
public void UpdateHealthBar(float newHealth) {
txtHealth.text = newHealth.ToString();
}
}
But it doesn't fix the biggest problem of code dependency. In this example, UIHealthBar still requires the PlayerCharacterController to exist and be active, so it's difficult to decouple them. Unit testing is about halfway better; you can now test the character's health functions in isolation at least, but you still can't change the healthbar without going through the character.
It should be noted that it is possible to create a system based on C# events that resolves all of the above issues. You can create base classes in their own asmdef so that, say, your healthbar doesn't need to actually directly interact with the player character. And to resolve the unit testing issue, a "test character" class could derive from that base class. However, such an approach in my opinion creates a lot of extra boilerplate code for every interaction between classes, which is just plain annoying.
Option 4: UnityEvent
I'll keep this one brief. UnityEvent is fairly similar to C# events, but with a huge difference. The main way of assigning listeners to a UnityEvent is via the inspector UI. This has the advantage of allowing the code to be entirely decoupled - our healthbar code doesn't need to know anything about the character and vice versa, we just need the inspector to tell each other about each other.
On the other hand, assigning code relationships via the inspector is rife with its own problems. Most coders just plain find it distasteful, to be frank, but there are practical issue too.
Refactoring the code connections in any way isn't really supported, and code relationships created in this way are likely to be lost and need to be rebuilt when anything changes. Moving around and renaming prefabs or scenes can have a similar destructive effect. That will lead to random things stopping working when you look at them wrong.
Tracing execution via IDE tools is impossible, because there is no real code connection between these systems.
In short, UnityEvents are not really a good solution for this problem. It's useful for small, contained systems where its fragility can be easily caught (such as hooking up UI elements), but I just don't trust it for any system where subtle changes can cause big problems that are hard to track down.
Option 5: Your Own Event Management System
On most of the projects I've worked on in the last decade, the need for a global event system has been found early on, causing the project lead to write up an event management system for that project, which is then used for the rest of the project's lifetime.
In theory, this is great! An event solution custom-made for a given project should logically be the solution best suited to that project, and sometimes it is. A custom system can choose to implement any number of features, such as:
Basic global event broadcasting & subscribing
Localized events
Event caching for persistent game state changes (which can be picked up on by newly created objects as they subscribe)
Event inheritance (an effect can subscribe to VehicleSpawnedEvent, and when PickupTruckSpawnedEvent is called, that listener will be called)
The downsides of thisare the same downsides that any bespoke system (especially complex ones) will have:
Development time. Rolling your own system takes time, and at the rate most coders capable of such a system go for, the real cost of such a system will be in the thousands of dollars.
Rushed development. The realities of game development and deadlines mean that generally only the bare minimum of development time can be allocated to the event system. This limits the features that can be included in the event system, and the features included rarely extend beyond the needs of the current task.
Incremental, piecemeal development. Sometimes features to accommodate future needs may be added later, but often this results in an uneven system with an inconsistent design. If event caching is implemented at one time and event inheritance at some other time, for example, it's very easy for the two features not to work together, and cached events won't get inherited.
Inconsistent or nonexistent unit testing. A foundational system such as this can fail in subtle ways that will have confusing effects on the final product. Unit testing is vital to preventing these, and bespoke systems rarely implement it well.
While a custom system made for your project ought to be the best fit for your project's specific needs, the truth is that event systems aren't actually all that different from project to project. The differences between the systems built for various projects tend to have little to do with what functionality would best suit that project, but instead are driven by which features the development team had the time to implement and test.
MantaMiddleware's EventCaster
Such a scenario is fertile ground for a ready-made solution that encompasses the needs of most projects, so I created exactly that. EventCaster incorporates basically every feature that has been part of the intended feature set of any event system on a project I've worked on.
Asynchronous execution of events. Cast an event, wait for all listeners to complete their handling of the event (without halting game execution), and then resume execution within the same block of code. (This uses the UniTask library)
Request handling, even with multicast events. Broadcast a request, await the request to let all handlers respond, and get back all the results as an array. Or, just request a single result from the first (or only) handler.
Hierarchical event type inheritance. If your listener has subscribed to the VehiclePositionChangeEvent, it can respond to derived event classes CarPositionChangeEvent and BicyclePositionChangeEvent with no additional code. This is fully and easily controllable in the event class implementations.
Cached event support. If the event type is set to allow it, the system will cache the most recent event of a given type; new listeners subscribing to that event type will instantly receive that event. Especially useful for config change events; the cached config event can replace a static storage of config data, reducing redundant code (initialization and live updates get handled with the same line of code).
Event locality. While the system supports global events, it is also identically easy for listeners and casters to use a local EventCaster instance. This may live on a prefab, for example, and SwordSwingEvent may be cast on a specific character without other character instances needing to respond to it.
Fully unit tested. All anticipated use cases have had a unit test written for them, and the package will not be released without fully passing unit tests. Any bugs reported in the future will be accounted for by a future unit test to prevent regressions.
EventCaster is the first major package from Manta Middleware. I hope I've demonstrated the value of the package, and you can get it from the asset store here.
Comments