Asynchronous Flows
Managing time and flows without 'Zombie Logic' - automatic cancellation and linearized async.
The Challenge of Time
In traditional software, a function executes and returns. In game development, a use case often spans time - waiting for an animation to finish, a network response to arrive, or a player to make a choice. This introduces Temporal Coupling, where the sequence of events is as important as the state itself.
The greatest risk in asynchronous game logic is “Zombie Logic”: code that continues to run after its context has been destroyed. A menu is closed while a network request is pending. The response arrives. The handler tries to update a UI element that no longer exists. In the best case you get a null reference. In the worst case you get silent state corruption.
This isn’t a theoretical concern - it’s the single most common source of hard-to-reproduce bugs in game projects.
Why Callbacks and Coroutines Fail
Most game developers first encounter async through engine-specific mechanisms: Unity coroutines, Unreal latent actions, or hand-rolled callback chains. These all share the same problems.
Callback Spaghetti
// Callbacks: the "what happens next" is buried inside nested lambdas
void CompleteQuest(QuestId questId) {
_questLog.Complete(questId);
_rewardDialog.Show(rewards, (claimed) => {
if (claimed) {
_inventory.AddItem(reward, (success) => {
if (success) {
_questLog.MarkRewarded(questId);
_analytics.LogRewardClaimed(questId);
} else {
_errorDialog.Show("Inventory full", () => { });
}
});
}
});
}
The flow is inside-out. Error handling is scattered. Adding a step means nesting deeper. And none of this handles cancellation - if the player closes the menu mid-flow, every callback still fires.
Coroutine Coupling
Engine coroutines (StartCoroutine in Unity, latent actions in Unreal) tie async logic directly to engine objects. The coroutine lives on a MonoBehaviour or Actor. If that object is destroyed, the coroutine stops - but not cleanly. Any state changes in progress are left half-applied. And because coroutines are engine-specific, Use Cases layer code that uses them depends on the engine - violating the dependency rule.
Linearized Flows
The solution is to write async logic as a linear sequence of await statements, using a task-based system like UniTask. The same quest completion flow becomes:
public async UniTask CompleteAndClaimReward(QuestId questId) {
_questLog.Complete(questId);
var claimed = await _rewardDialog.ShowAndWaitForClaim(rewards);
if (!claimed) return;
if (await _inventory.AddItem(reward))
{
_questLog.MarkRewarded(questId);
_analytics.LogRewardClaimed(questId);
}
else
{
await _errorDialog.Show("Inventory full");
}
}
The flow reads top-to-bottom. Each step completes before the next begins. Error handling can use standard try/catch. Adding a step means adding a line, not nesting deeper.
This isn’t just a readability improvement - it’s an architectural one:
- The Use Cases layer describes the flow (“show the dialog, wait for the player, grant the reward”) without knowing how the dialog is rendered, how the animation plays, or how the network request is made.
- The Controllers provide completion signals. The dialog returns a
UniTask<bool>. The Use Cases layer awaits it. The Use Cases layer never touches the engine. - Tests can drive the flow by providing test doubles that complete immediately or on demand. No engine runtime required. (See Unit Tests for the full pattern.)
Lifecycle-Bound Cancellation
Linearized flows solve readability. But they don’t solve Zombie Logic on their own - an await that never completes will keep the flow alive forever, and an await that completes after its owner is destroyed will execute against a dead context.
The solution is automatic lifecycle-bound cancellation: when an object is disposed, all async flows it owns are cancelled immediately.
Async Handlers for UI Events
A common pattern in game UI: the player clicks a button, and the response involves async work - showing a dialog, making a network call, waiting for an animation. The handler needs to be async, but it also needs to be cancellation-safe.
public class QuestLogController {
private readonly IQuestLogView _view;
private readonly ICompleteQuest _completeQuest;
public QuestLogController(IQuestLogView view, ICompleteQuest completeQuest) {
_view = view;
_completeQuest = completeQuest;
// CreateAsyncHandler binds the async lambda to this object's lifetime.
// If the controller is disposed mid-flow, the handler is cancelled.
_view.OnQuestClicked.Subscribe(
CreateAsyncHandler<QuestId>(async (questId) => {
await WaitFor(_completeQuest.CompleteAndClaimReward(questId));
RefreshView();
})
);
}
}
CreateAsyncHandler does two things: it wraps the async lambda in the object’s cancellation scope, and it ensures that only one invocation runs at a time - if the player clicks rapidly, the second click is ignored until the first flow completes. This prevents the double-click bugs that plague most game UIs.
The Async Trigger Pattern
Sometimes a Controller needs to expose a blocking operation to the Use Cases layer: “show this dialog and wait until the player responds.” The Controller doesn’t know when the player will respond - it could be immediate or it could take thirty seconds. The Use Cases layer needs to await the result.
The AsyncTrigger pattern bridges this gap:
// Use Cases layer defines what it needs
public interface IRewardDialog {
UniTask<bool> ShowAndWaitForClaim(Rewards rewards);
}
// Controller implements it using an AsyncTrigger
public class RewardDialogController : IRewardDialog {
private readonly IRewardDialogView _view;
public async UniTask<bool> ShowAndWaitForClaim(Rewards rewards) {
_view.Show(rewards);
// AsyncTrigger blocks until Invoke() is called - by the view,
// when the player clicks Claim or Dismiss
var trigger = new AsyncTrigger<bool>();
_view.OnClaimClicked.Subscribe(() => trigger.Invoke(true));
_view.OnDismissClicked.Subscribe(() => trigger.Invoke(false));
var result = await WaitFor(trigger);
_view.Hide();
return result;
}
}
From the Use Cases layer’s perspective, ShowAndWaitForClaim is a single awaitable call that returns a bool. It doesn’t know about triggers, UI widgets, or player input. From the Controller’s perspective, the trigger is a simple mechanism that bridges the gap between an event-driven UI and an await-driven flow.
This pattern is especially powerful in tests. A test double can call trigger.Invoke(true) immediately, making the entire async flow execute synchronously in the test harness:
// Test double - resolves immediately
public class RewardDialogTestDouble : IRewardDialog {
public bool ClaimResult { get; set; } = true;
public UniTask<bool> ShowAndWaitForClaim(Rewards rewards) {
return UniTask.FromResult(ClaimResult);
}
}
The Deterministic Core
While the Use Cases layer spans time, the Core layer represents a point-in-time snapshot. This separation is fundamental.
Sync Core, Async Use Cases. Domain rules and state transitions in the Core are synchronous and deterministic. The Use Cases layer handles the “waiting” and only modifies the Core once all conditions are met. This means:
- Core methods never return
UniTask. They execute and complete immediately. - Core state is never in an “in-progress” state. It’s either the old state or the new state.
- Core logic can be tested with purely synchronous tests - no async test infrastructure needed.
// Core - synchronous, deterministic
public class QuestLog : IQuestLog {
public void Complete(QuestId questId) {
// Instant state transition. No waiting, no async.
Assert(_quests[questId] == QuestState.Active);
_quests[questId] = QuestState.Completed;
OnQuestCompleted.Emit(questId);
}
}
// Use Case - async orchestration around the sync Core
public async UniTask CompleteAndClaimReward(QuestId questId) {
_questLog.Complete(questId); // sync
var claimed = await WaitFor(_dialog.ShowAndWaitForClaim(rewards)); // async
if (!claimed) return;
await WaitFor(_inventory.AddItem(reward)); // async
_questLog.MarkRewarded(questId); // sync
}
Single-Threaded Authority. Even if work is done on background threads (asset loading, network I/O, heavy computation), the results must be synchronized back to the main game loop before touching the Core. This prevents race conditions and ensures the game state remains predictable. The async framework handles this marshalling - developers write linear code, and the framework ensures it executes on the correct thread.
The Async Boundary
| Layer | Async Role | Rules |
|---|---|---|
| Core | Synchronous “Truth.” | Never returns UniTask. Never waits. State transitions are instant. |
| Use Cases | The Flow Master. | Orchestrates the sequence (Step A await B C). |
| Controllers | The Signal Providers. | Perform the actual waiting (animations, network, player input) and signal completion via UniTask/AsyncTrigger. |