Bug-free UI in Unity
![]() |
---|
A feature prototype (for Airspace Defender) created entirely with Unity Canvas Rendering. |
Game UI Generalities
Before we begin, I want to establish a few facts about UI rendering, and Unity's UI approach in particular. First is that there are 2 general approaches to UI:
Immediate Mode (Simple), where the application constructs the UI with draw commands each frame
Retained Mode (Flexible), where the UI is a persistent "scene" which the application can modify during the frame
The difference is in whether the UI system maintains state from frame to frame. In games, we generally use Retained UI. After all, we're highly accustomed to the existence of a visual "scene",1 since "what you see is what you get" is pretty much a necessity as complexity increases. Put another way, how would you synchronize an animation and particle effect in immediate mode? I'm not saying you couldn't, but what a hassle!
Nonetheless, I will assert here that Immediate Mode is easier to debug. There's a truism (provided to me by Kyle Schmitz) that bugs are the result of systems that allow an incompatible state. Immediate Mode simply displays the game state every frame. When it's wrong, the errors are consistent and self-contained.
Unity UI
Unity actually supports both methods. The Immediate Mode system is called "IMGUI", and is generally used for editor tools, because the simplicity and bug-resistance of "IM" are a huge advantage when looking good isn't important. The Retained Mode system is "UGUI" (commonly known as Canvas), which is constructed of GameObjects with Materials and thus maximally visually flexible. For this reason, I exclusively use UGUI in my games. Recently they have added "UI Toolkit", which is an unfinished high-performance CSS-style framework, that I think we can safely ignore for now.
![]() |
![]() |
|
---|---|---|
IMGUI (Simple) | UGUI (Flexible) | UI Toolkit (Performant) |
Now, I bring all this up because, despite the clear necessity of Retained Mode in game UIs, I'd like to try to replicate Immediate Mode's ease of debugging in my game architecture. How to go about this? I think it has something to do with reading the current state each frame. Emphasis on reading. If all backend operations for the frame (including processing the results of inputs) are complete, and then we update the UI to reflect that state, it by definition must accurately reflect the game state.
In my experience, the most awful UI bugs tend to crop up when the visuals or capabilities of the user don't reflect the backend state...
Read-only Retained UI
As I introduce my UI approach, I want to first make reference to the MVC pattern (or Model View Controller pattern) it is based on. In my view, the key insight of the pattern is that state, display, and input are natural categories, and relatively easily separated. However, the traditional model of MVC has the "model" updating the "view", which feels a lot more retained than immediate. Furthermore, in a game engine with a scriptable scene, it's not immediately obvious which elements should constitute the model vs the view vs the controller...
![]() |
---|
MVC Wikipedia Diagram |
In strategy games especially, I prefer my game-state to be defined in C# classes that are not MonoBehaviours, so I can make state duplicates (we'll see why shortly), as well as easily save or undo. These are managed by a MonoBehaviour called GameState, so we can have a serialized reference to the whole thing (it's also a nice central place to put debug settings).
This maps well to the Model in MVC, and so I prefer to apply the following additional categories: The State script is referenced by MonoBehaviours with Input in the name, and the State and Input scripts are both referenced by MonoBehaviours with Display in the name. So my model might be more accurately named "State Input Display".
Let's create a toy example, where Unit
is a state class and the state of all units is accessible from GameState:
public class GameState : MonoBehaviour
{
public IEnumerable<Unit> Units => _units;
public Unit GetUnit(uint id) => _units.Find(x => x.id == id);
private List<Unit> _units = new();
void Awake()
{
_units.Add(id: u0, startingPos: new Unit(Vector2Int.zero));
}
}
public class Unit : IEquatable
{
public readonly uint ID;
public Vector2Int Position { get; private set; }
public Unit(uint id, Vector2Int startingPos)
{
ID = id;
Position = startingPos;
}
public void Move(Vector2Int targetPos) => Position = targetPos;
...
}
As it says in the section header, our UI should be read-only, so the UI gets a serialized reference to the GameState
but not vice-versa. And then here is my incredibly simple discovery: UI logic goes in LateUpdate()
. Assuming your gameplay logic happens in FixedUpdate()
and Update()
, your UI can do all the read-only operations it wants in LateUpdate()
and always accurately reflect the current state.
![]() |
---|
Simple text element with read-only access to GameState |
In our toy model, a UnitDisplay with a reference to GameState might look like this:
public class UnitDisplay
{
[SerializeField] private GameState _State;
private Unit _displayedState;
void LateUpdate()
{
var state = _State.GetUnit(u0); // Example with a single unit ID
if (state == _displayedState) return; // Early out if no state change
transform.position = state.Position;
_displayedState = state;
}
}
As we can see, if Unit
implements IEquatable, then we can easily poll for changes every frame without worrying about performing unnecessary operations.2 But in practice, polling will tend to more centralized than this, since UnitDisplay
instances probably need to be spawned and despawned regularly -- they won't always start in the scene. Below is an example of a UnitDisplayMgr
which might be a persistent scene feature, and which can act as both the lifecycle manager and a source of GameState
dependency injection for UnitDisplay
.
public class UnitDisplayMgr
{
[SerializeField] private GameState _State;
[SerializeField] private UnitDisplay prefab_UnitDisplay;
private List<UnitDisplay> _unitDisplays;
void LateUpdate()
{
DisplayUtility.MatchCollection(
_unitDisplays,
_State.Units,
spawnAction: () => LeanPool.Spawn(prefab_UnitDisplay),
updateAction: (state, display) => display.Show(state)
);
}
}
public static class DisplayUtility
{
public static void MatchCollection<D, T>(List<T> spawnedElements, IEnumerable<D> data,
System.Func<T> spawnAction, System.Action<D, T> updateAction = null)
where T : Component
{
int i = 0;
foreach (var d in data)
{
if (spawnedElements.Count == i) spawnedElements.Add(spawnAction());
updateAction?.Invoke(d, spawnedElements[i]);
i++;
}
spawnedElements.DespawnAndTrim(i);
}
public static void DespawnAndTrim<T>(this List<T> target, int maxCount)
where T : Component
{
while (target.Count > maxCount)
{
LeanPool.Despawn(target[^1]);
target.RemoveAt(target.Count - 1);
}
}
}
The above assumes UnitDisplay
has a public void Show(Unit unit)
method, rather than polling directly. Note that the lifecycle management part uses a static helper because it comes up very often!
![]() |
---|
Example "Squad" panel from a real prototype, with a reference to the GameState and a display prefab to manage. |
Input
At a surface level, Input should be simple in this framework -- like the Display scripts, Input scripts have references to the game state. They thus get read-write access to state, with the "writes" limited to gameplay actions defined in the state code directly.
However, there are two wrinkles to address:
First, how does, say, a menu or a button know if it should be visible? Does the Input get a reference to it and update its state all the time? No! In my framework, the Display scripts also have read-only access to Input. That way, something like a "Selected Unit" can be a simple read-only value in UnitInput
, and the current input state is easy to understand. For example:
public class UnitInput : MonoBehaviour
{
[SerializeField] private GameState _State;
public Unit SelectedUnit { get; private set; }
public void RegisterUnitButtons(IEnumerable<UnitDisplay> unitDisplays)
{
foreach (var display in unitDisplays)
{
var unitID = display.ID;
display.Button.RegisterListener(() =>
{
SelectedUnit = _State.GetUnit(unitID)
});
}
}
}
The second wrinkle is actually already demonstrated above. UnitDisplay
is assumed in the example to expose a Button
property. And, RegisterUnitButtons has to be called by something... I call this "Display Registration" and it reflects the fact that in Unity, Canvas UI buttons and other interactable elements are fully self-contained display and input systems. It would be a huge hassle to maintain separate input references to buttons. So, for simplicity, Display elements call the Input elements they already have a reference to and register any buttons. Since this only happens on initialization, I don't consider this a problematic exception to our "read-only" rule.
![]() |
---|
This "Mission" panel enables and disables itself based on the Input mode. It then registers its buttons with MissionInput. |
Last thing to mention. Who decides whether a button is interactable? Isn't that an Input question? Well, yes... but I think it's clear that read-only polling is the most bug-resistant way to decide, so in practice I set this value in Display scripts (keep in mind Displays already maintain references to buttons). Here's an example of a tweaked UnitDisplay to reflect this:
public class UnitDisplay
{
[SerializeField] private Button _Button;
public Button Button => _Button;
private Unit _displayedState;
// Remember this is called in LateUpdate by UnitDisplayMgr
public void Show(UnitInput input, Unit unit)
{
_Button.interactable = input.AllowSelection; // Always-correct interactivity
if (unit== _displayedState) return; // Early out if no state change
transform.position = state.Position;
_displayedState = state;
}
}
Framework Summary
My goal is to combine the flexibility of Unity's retained UI, the bug-resistance of immediate-mode UI, and the separation of concerns in MVC. Thus, I use what I call the "State Input Display" framework for all Canvas UI. It gives Input a read-write reference to State, and Display a read-only reference to anything, which is acted on in LateUpdate polling.
I find this especially useful for strategy games which often have complex state that needs to be reflected in a large number of buttons. However, I also use a less-strict version for action games -- for instance, input can still be centralized, and a 2D HUD which shows the HP of an actor in the environment is a perfect time for read-only polling.
< Previous (Hexagon Rendering) (Mixing Fake Physics with "Real" Physics) Next >
I do think there is an interesting case to be made that the 3D rendering pipeline of a game engine is highly immediate in its construction. Most of the game view changes every frame, so the whole thing is drawn anew, front to back, without retained pixels (except in the case of motion vectors or TAA, but those are edge cases).↩
I don't always check for state, often the results of polling have negligible cost. However, IEquatable state is exceptionally useful when you want animated/one-time transitions in the UI. It allows the UI to keep a very lightweight state of its own for comparison to the ground truth, and make arbitrarily complex visual decisions without losing read-only-ness.↩