Some tasks in game development are not synchronous — they are asynchronous. This means they are not linearly executed within the game code. Some of these asynchronous tasks can require quite a long time to complete, while others are associated with intensive computations.
Performing network requests
Loading scenes, resources, and other assets
Reading and writing files
Artificial intelligence for decision making
Long animation sequences
Processing large amounts of data
Pathfinding
Now, crucially, since all Unity code runs in one thread, any task like one of those mentioned above, if they were performed synchronously, would lead to the main thread being blocked, and thus, frame drops.
Hello everyone, my name is Dmitrii Ivashchenko and I am the Head of the Development Team at MY.GAMES. In this article, we’re going to talk about avoiding any such issues. We’ll recommend asynchronous programming techniques to perform these tasks in a separate thread, thus leaving the main thread free to perform other tasks. This will help ensure smooth and responsive gameplay, and (hopefully) satisfied gamers.
First up, let’s talk about coroutines. They were introduced in Unity in 2011, even before async / await appeared in .NET. In Unity, coroutines allow us to perform a set of instructions over multiple frames, instead of executing them all at once. They are similar to threads, but are lightweight and integrated into Unity's update loop, making them well suited for game development.
(By the way, historically speaking, coroutines were the first way to perform asynchronous operations in Unity, so most articles on the Internet are about them.)
To create a coroutine, you need to declare a function with the IEnumerator
return type. This function can contain any logic you want the coroutine to execute.
To start a coroutine, you need to call the StartCoroutine
method on a MonoBehaviour
instance and pass the coroutine function as an argument:
public class Example : MonoBehaviour
{
void Start()
{
StartCoroutine(MyCoroutine());
}
IEnumerator MyCoroutine()
{
Debug.Log("Starting coroutine");
yield return null;
Debug.Log("Executing coroutine");
yield return null;
Debug.Log("Finishing coroutine");
}
}
There are several yield instructions available in Unity, such as WaitForSeconds
, WaitForEndOfFrame
, WaitForFixedUpdate
, WaitForSecondsRealtime
, WaitUntil
as well as some others. It’s important to remember that using them leads to allocations, so they should be reused wherever possible.
For example, consider this method from the documentation:
IEnumerator Fade()
{
Color c = renderer.material.color;
for (float alpha = 1f; alpha >= 0; alpha -= 0.1f)
{
c.a = alpha;
renderer.material.color = c;
yield return new WaitForSeconds(.1f);
}
}
With each iteration of the loop, a new instance of new WaitForSeconds(.1f)
will be created. Instead of this, we can move creation outside of the loop and avoid allocations:
IEnumerator Fade()
{
Color c = renderer.material.color;
**var waitForSeconds = new WaitForSeconds(0.2f);**
for (float alpha = 1f; alpha >= 0; alpha -= 0.1f)
{
c.a = alpha;
renderer.material.color = c;
yield return **waitForSeconds**;
}
}
Another significant property to note is that yield return
can be used with all the Async
methods provided by Unity because AsyncOperation
s are descendants of YieldInstruction
:
yield return SceneManager.LoadSceneAsync("path/to/scene.unity");
This all being said, coroutines also have a few drawbacks to note:
MonoBehaviour
that launches it. If the GameObject
is turned off or destroyed, the coroutine stops being processed.try-catch-finally
structure cannot be used due to the presence of the yield syntax.yield return
before the next code starts to execute.Promises are a pattern for organizing and making asynchronous operations more readable. They have become popular due to their use in many third-party JavaScript libraries, and, since ES6, have been implemented natively.
When using Promises, we immediately return an object from your asynchronous function. This allows the caller to wait for the resolution (or an error) of the operation.
Essentially, this makes it so that asynchronous methods can return values and “act” like synchronous methods: instead of returning the final value right away, they give a “promise” that they will return a value sometime in the future.
There are several Promises implementations for Unity:
The main way to interact with a Promise is via callback functions.
You can define a callback function that will be called when a Promise is resolved, and another callback function that will be called if the Promise is rejected. These callbacks receive the outcome of the asynchronous operation as arguments, which can then be used to perform further operations.
According to these specifications from the Promises/A+ organization, a Promise can be in one of three states:
Pending
: the initial state, this means that the asynchronous operation is still in progress, and the outcome of the operation is not yet known.Fulfilled
(Resolved
): the resolved state is accompanied by a value that represents the outcome of the operation.Rejected
: if the asynchronous operation fails for any reason, the Promise is said to be "rejected". The rejected state is accompanied by the reason for the failure.Additionally, promises can be chained together, so that the outcome of one Promise can be used to determine the outcome of another Promise.
For example, you can create a Promise that fetches some data from a server, and then use that data to create another Promise that performs some calculation and other actions:
var promise = MakeRequest("https://some.api")
.Then(response => Parse(response))
.Then(result => OnRequestSuccess(result))
.Then(() => PlaySomeAnimation())
.Catch(exception => OnRequestFailed(exception));
Here’s an example of how to organize a method that performs an asynchronous operation:
public IPromise<string> MakeRequest(string url)
{
// Create a new promise object
var promise = new Promise<string>();
// Create a new web client
using var client = new WebClient();
// Add a handler for the DownloadStringCompleted event
client.DownloadStringCompleted += (sender, eventArgs) =>
{
// If an error occurred, reject the promise
if (eventArgs.Error != null)
{
promise.Reject(eventArgs.Error);
}
// Otherwise, resolve the promise with the result
else
{
promise.Resolve(eventArgs.Result);
}
};
// Start the download asynchronously
client.DownloadStringAsync(new Uri(url), null);
// Return the promise
return promise;
}
We could also wrap coroutines in a Promise
:
void Start()
{
// Load the scene and then show the intro animation
LoadScene("path/to/scene.unity")
.Then(() => ShowIntroAnimation())
.Then( ... );
}
// Load a scene and return a promise
Promise LoadScene(string sceneName)
{
// Create a new promise
var promise = new Promise();
// Start a coroutine to load the scene
StartCoroutine(LoadSceneRoutine(promise, sceneName));
// Return the promise
return promise;
}
IEnumerator LoadSceneRoutine(Promise promise, string sceneName)
{
// Load the scene asynchronously
yield return SceneManager.LoadSceneAsync(sceneName);
// Resolve the promise once the scene is loaded
promise.Resolve();
}
And of course, you can organize any combination of promise execution order using ThenAll
/ Promise.All
and ThenRace
/ Promise.Race
:
// Execute the following two promises in sequence
Promise.Sequence(
() => Promise.All( // Execute the two promises in parallel
RunAnimation("Foo"),
PlaySound("Bar")
),
() => Promise.Race( // Execute the two promises in a race
RunAnimation("One"),
PlaySound("Two")
)
);
Despite all the convenience of use, promises also have some drawbacks:
The async/await feature has been a part of C# since version 5.0 (2012), and it was introduced in Unity 2017 with the implementation of the .NET 4.x runtime.
In the history of .NET, the following stages can be distinguished:
BeginSmth
method returns the IAsyncResult
interface. The EndSmth
method takes IAsyncResult
; if the operation is not completed at the time of the EndSmth
call, the thread is blocked.Task
and Task<TResult>
.
The previous approaches were rendered obsolete due to the success of the last approach.
To create an asynchronous method, the method must be marked with the keyword async
, contain an await
inside, and the return value must be Task
, Task<T>
or void
(not recommended).
public async Task Example()
{
SyncMethodA();
await Task.Delay(1000); // the first async operation
SyncMethodB();
await Task.Delay(2000); // the second async operation
SyncMethodC();
await Task.Delay(3000); // the third async operation
}
In this example, the execution will take place like this:
SyncMethodA
) will be executed.await Task.Delay(1000)
is launched and expected to be executed. Meanwhile, the code to be called when the asynchronous operation is completed (the "continuation") will be saved.SyncMethodB
) will start executing.await Task.Delay(2000)
) is launched and expected to be executed. At the same time, the continuation — the code following the second asynchronous operation (SyncMethodC
) will be preserved.SyncMethodC
will be executed, followed by execution and waiting for the third asynchronous operation await Task.Delay(3000)
.
This is a simplified explanation, as in fact async/await is syntactic sugar to allow for convenient calling of asynchronous methods and waiting for their completion.
You can also organize any combination of execution orders using WhenAll
and WhenAny
:
var allTasks = Task.WhenAll(
Task.Run(() => { /* ... */ }),
Task.Run(() => { /* ... */ }),
Task.Run(() => { /* ... */ })
);
allTasks.ContinueWith(t =>
{
Console.WriteLine("All the tasks are completed");
});
var anyTask = Task.WhenAny(
Task.Run(() => { /* ... */ }),
Task.Run(() => { /* ... */ }),
Task.Run(() => { /* ... */ })
);
anyTask.ContinueWith(t =>
{
Console.WriteLine("One of tasks is completed");
});
The C# compiler transforms async/await calls into an IAsyncStateMachine
state machine, which is a sequential set of actions that must be performed to complete the asynchronous operation.
Every time you call an await operation, the state machine completes its work and waits for the completion of that operation, after which it continues to execute the next operation. This allows asynchronous operations to be performed in the background without blocking the main thread, and also makes asynchronous method calls simpler and more readable.
Thus, the Example
method is transformed into creating and initializing a state machine with the annotation [AsyncStateMachine(typeof(ExampleStateMachine))]
, and the state machine itself has a number of states equal to the number of await calls.
Example of the transformed method Example
[AsyncStateMachine(typeof(ExampleStateMachine))]
public /*async*/ Task Example()
{
// Create a new instance of the ExampleStateMachine class
ExampleStateMachine stateMachine = new ExampleStateMachine();
// Create a new AsyncTaskMethodBuilder and assign it to the taskMethodBuilder property of the stateMachine instance
stateMachine.taskMethodBuilder = AsyncTaskMethodBuilder.Create();
// Set the currentState property of the stateMachine instance to -1
stateMachine.currentState = -1;
// Start the stateMachine instance
stateMachine.taskMethodBuilder.Start(ref stateMachine);
// Return the Task property of the taskMethodBuilder
return stateMachine.taskMethodBuilder.Task;
}
Example of a generated state machine ExampleStateMachine
[CompilerGenerated]
private sealed class ExampleStateMachine : IAsyncStateMachine
{
public int currentState;
public AsyncTaskMethodBuilder taskMethodBuilder;
private TaskAwaiter taskAwaiter;
public int paramInt;
private int localInt;
void IAsyncStateMachine.MoveNext()
{
int num = currentState;
try
{
TaskAwaiter awaiter3;
TaskAwaiter awaiter2;
TaskAwaiter awaiter;
switch (num)
{
default:
localInt = paramInt;
// Call the first synchronous method
SyncMethodA();
// Create a task awaiter for a delay of 1000 milliseconds
awaiter3 = Task.Delay(1000).GetAwaiter();
// If the task is not completed, set the current state to 0 and store the awaiter
if (!awaiter3.IsCompleted)
{
currentState = 0;
taskAwaiter = awaiter3;
// Store the current state machine
ExampleStateMachine stateMachine = this;
// Await the task and pass the state machine
taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter3, ref stateMachine);
return;
}
// If the task is completed, jump to the label after the first await
goto Il_AfterFirstAwait;
case 0:
// Retrieve the awaiter from the taskAwaiter field
awaiter3 = taskAwaiter;
// Reset the taskAwaiter field
taskAwaiter = default(TaskAwaiter);
currentState = -1;
// Jump to the label after the first await
goto Il_AfterFirstAwait;
case 1:
// Retrieve the awaiter from the taskAwaiter field
awaiter2 = taskAwaiter;
// Reset the taskAwaiter field
taskAwaiter = default(TaskAwaiter);
currentState = -1;
// Jump to the label after the second await
goto Il_AfterSecondAwait;
case 2:
// Retrieve the awaiter from the taskAwaiter field
awaiter = taskAwaiter;
// Reset the taskAwaiter field
taskAwaiter = default(TaskAwaiter);
currentState = -1;
break;
Il_AfterFirstAwait:
awaiter3.GetResult();
// Call the second synchronous method
SyncMethodB();
// Create a task awaiter for a delay of 2000 milliseconds
awaiter2 = Task.Delay(2000).GetAwaiter();
// If the task is not completed, set the current state to 1 and store the awaiter
if (!awaiter2.IsCompleted)
{
currentState = 1;
taskAwaiter = awaiter2;
// Store the current state machine
ExampleStateMachine stateMachine = this;
// Await the task and pass the state machine
taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter2, ref stateMachine);
return;
}
// If the task is completed, jump to the label after the second await
goto Il_AfterSecondAwait;
Il_AfterSecondAwait:
// Get the result of the second awaiter
awaiter2.GetResult();
// Call the SyncMethodC
SyncMethodC();
// Create a new awaiter with a delay of 3000 milliseconds
awaiter = Task.Delay(3000).GetAwaiter();
// If the awaiter is not completed, set the current state to 2 and store the awaiter
if (!awaiter.IsCompleted)
{
currentState = 2;
taskAwaiter = awaiter;
// Set the stateMachine to this
ExampleStateMachine stateMachine = this;
// Await the task and pass the state machine
taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
return;
}
break;
}
// Get the result of the awaiter
awaiter.GetResult();
}
catch (Exception exception)
{
currentState = -2;
taskMethodBuilder.SetException(exception);
return;
}
currentState = -2;
taskMethodBuilder.SetResult();
}
void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) { /*...*/ }
}
In the AwaitUnsafeOnCompleted
call, the current synchronization context SynchronizationContext
will be obtained. SynchronizationContext is a concept in C# used to represent a context that controls the execution of a set of asynchronous operations. It is used to coordinate the execution of code across multiple threads and to ensure that code is executed in a specific order. The main purpose of SynchronizationContext is to provide a way to control the scheduling and execution of asynchronous operations in a multithreaded environment.
In different environments, the SynchronizationContext
has different implementations. For example, in .NET, there are:
System.Windows.Threading.DispatcherSynchronizationContext
System.Windows.Forms.WindowsFormsSynchronizationContext
System.Threading.WinRTSynchronizationContext
System.Web.AspNetSynchronizationContext
Unity also has its own synchronization context, UnitySynchronizationContext
, which enables us to use asynchronous operations with binding to the PlayerLoop API. The following code example shows how to rotate an object in each frame using Task.Yield()
:
private async void Start()
{
while (true)
{
transform.Rotate(0, Time.deltaTime * 50, 0);
await Task.Yield();
}
}
Another example of using async/await in Unity to make a network request:
using UnityEngine;
using System.Net.Http;
using System.Threading.Tasks;
public class NetworkRequestExample : MonoBehaviour
{
private async void Start()
{
string response = await GetDataFromAPI();
Debug.Log("Response from API: " + response);
}
private async Task<string> GetDataFromAPI()
{
using (var client = new HttpClient())
{
var response = await client.GetStringAsync("https://api.example.com/data");
return response;
}
}
}
Thanks to UnitySynchronizationContext
, we can safely use UnityEngine
methods (such as Debug.Log()
) right after an asynchronous operation has been completed, since the execution of this code will continue in the main Unity thread.
This class allows you to manage a Task
object. It was created to adapt old asynchronous methods to TAP, but it’s also very useful when we want to wrap a Task
around some long-running operation that upon some event.
In the following example, the Task
object inside taskCompletionSource
will complete after 3 seconds from start, and we’ll get its result in the Update
method:
using System.Threading.Tasks;
using UnityEngine;
public class Example : MonoBehaviour
{
private TaskCompletionSource<int> taskCompletionSource;
private void Start()
{
// Create a new TaskCompletionSource
taskCompletionSource = new TaskCompletionSource<int>();
// Start a coroutine to wait 3 seconds
// and then set the result of the TaskCompletionSource
StartCoroutine(WaitAndComplete());
}
private IEnumerator WaitAndComplete()
{
yield return new WaitForSeconds(3);
// Set the result of the TaskCompletionSource
taskCompletionSource.SetResult(10);
}
private async void Update()
{
// Await the result of the TaskCompletionSource
int result = await taskCompletionSource.Task;
// Log the result to the console
Debug.Log("Result: " + result);
}
}
A Cancellation Token is used in C# to signal that a task or operation should be canceled. The token is passed to the task or operation, and the code within the task or operation can check the token periodically to determine whether the task or operation should be stopped. This allows for a clean and graceful cancellation of a task or operation, instead of just abruptly killing it.
Cancellation Tokens are commonly used in situations where a long-running task can be cancelled by the user, or if the task is no longer needed, like a cancel button in a user interface.
The overall pattern resembles the use of TaskCompletionSource
. First, a CancellationTokenSource
is created, then its Token
is passed to the asynchronous operation:
public class ExampleMonoBehaviour : MonoBehaviour
{
private CancellationTokenSource _cancellationTokenSource;
private async void Start()
{
// Create a new CancellationTokenSource
_cancellationTokenSource = new CancellationTokenSource();
// Get the token from the CancellationTokenSource
CancellationToken token = _cancellationTokenSource.Token;
try
{
// Start a new Task and pass in the token
await Task.Run(() => DoSomething(token), token);
}
catch (OperationCanceledException)
{
Debug.Log("Task was cancelled");
}
}
private void DoSomething(CancellationToken token)
{
for (int i = 0; i < 100; i++)
{
// Check if the token has been cancelled
if (token.IsCancellationRequested)
{
// Return if the token has been cancelled
return;
}
Debug.Log("Doing something...");
// Sleep for 1 second
Thread.Sleep(1000);
}
}
private void OnDestroy()
{
// Cancel the token when the object is destroyed
_cancellationTokenSource.Cancel();
}
}
When the operation is canceled, an OperationCanceledException
will be thrown and the Task.IsCanceled
property will be set to true
.
It’s important to note that Task
objects are managed by the .NET runtime, not by Unity, and if the object executing the task is destroyed (or if the game exits play mode in the editor), the task will continue to run as Unity has no means to cancel it.
You always need to accompany await Task
with the corresponding CancellationToken
. This leads to some redundancy of code, and in Unity 2022.2 built-in tokens at the MonoBehaviour
level and the entire Application
level appeared.
Let's see how the previous example changes when using the destroyCancellationToken
of the MonoBehaviour
object:
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class ExampleMonoBehaviour : MonoBehaviour
{
private async void Start()
{
// Get the cancellation token from the MonoBehaviour
CancellationToken token = this.destroyCancellationToken;
try
{
// Start a new Task and pass in the token
await Task.Run(() => DoSomething(token), token);
}
catch (OperationCanceledException)
{
Debug.Log("Task was cancelled");
}
}
private void DoSomething(CancellationToken token)
{
for (int i = 0; i < 100; i++)
{
// Check if the token has been cancelled
if (token.IsCancellationRequested)
{
// Return if the token has been cancelled
return;
}
Debug.Log("Doing something...");
// Sleep for 1 second
Thread.Sleep(1000);
}
}
}
We no longer need to manually create a CancellationTokenSource
and complete the task in the OnDestroy
method. For tasks not associated with a particular MonoBehaviour
, we can use UnityEngine.Application.exitCancellationToken
. This will terminate the task when exiting Play Mode (in Editor) or when quitting the application.
Despite the convenience of using and the capabilities provided by .NET Tasks, they have significant drawbacks when used in Unity:
Task
objects are too cumbersome and cause many allocations.Task
is not matched to Unity threading (single-thread).
The UniTask library bypasses these restrictions without using threads or SynchronizationContext
. It achieves the absence of allocations by using the UniTask<T>
struct-based type.
UniTask requires the .NET 4.x scripting runtime version, with Unity 2018.4.13f1 being the official lowest supported version.
Also you can convert all the AsyncOperations
to UnitTask
with extension methods:
using UnityEngine;
using UniTask;
public class AssetLoader : MonoBehaviour
{
public async void LoadAsset(string assetName)
{
var loadRequest = Resources.LoadAsync<GameObject>(assetName);
await loadRequest.AsUniTask();
var asset = loadRequest.asset as GameObject;
if (asset != null)
{
// Do something with the loaded asset
}
}
}
In this example, the LoadAsset
method uses Resources.LoadAsync
to load an asset asynchronously. The AsUniTask
method is then used to convert the AsyncOperation
returned by LoadAsync
into a UniTask
, which can be awaited.
As before, you can organize any combination of execution order using UniTask.WhenAll
and UniTask.WhenAny
:
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
public class Example : MonoBehaviour
{
private async void Start()
{
// Start two Tasks and wait for both to complete
await UniTask.WhenAll(Task1(), Task2());
// Start two Tasks and wait for one to complete
await UniTask.WhenAny(Task1(), Task2());
}
private async UniTask Task1()
{
// Do something
}
private async UniTask Task2()
{
// Do something
}
}
In UniTask, there is another implementation of SynchronizationContext
called UniTaskSynchronizationContext
which can be used to replace UnitySynchronizationContext
for better performance.
In the first alpha version of Unity 2023.1, the Awaitable
class was introduced. Awaitable Coroutines are async/await-compatible Task-like types designed to run in Unity. Unlike .NET Tasks, they are managed by the engine, not the runtime.
private async Awaitable DoSomethingAsync()
{
// awaiting built-in events
await Awaitable.EndOfFrameAsync();
await Awaitable.WaitForSecondsAsync();
// awaiting .NET Tasks
await Task.Delay(2000, destroyCancellationToken);
await Task.Yield();
// awaiting AsyncOperations
await SceneManager.LoadSceneAsync("path/to/scene.unity");
// ...
}
They can be awaited and used as the return type of an async method. Compared to System.Threading.Tasks
, they are less sophisticated but take performance-enhancing shortcuts based on Unity-specific assumptions.
Here are the main differences compared to .NET Tasks:
Awaitable
object can only be awaited once; it cannot be awaited by multiple async functions.Awaiter.GetResults()
won't block until completion. Calling it before the operation is finished is undefined behavior.ExecutionContext
. For security reasons, .NET Tasks capture execution contexts when awaiting in order to propagate impersonation contexts across asynchronous calls.SynchronizationContext
. Coroutine continuations are executed synchronously from the code that raises the completion. In most cases, this will be from the Unity main frame.ObjectPool
has been improved to avoid Stack<T>
bounds checks in typical get/release sequences generated by async state machines.
To obtain the result of a lengthy operation, you can use the Awaitable<T>
type. You can manage the completion of an Awaitable
using AwaitableCompletionSource
and AwaitableCompletionSource<T>
, similar to TaskCompletitionSource
:
using UnityEngine;
using Cysharp.Threading.Tasks;
public class ExampleBehaviour : MonoBehaviour
{
private AwaitableCompletionSource<bool> _completionSource;
private async void Start()
{
// Create a new AwaitableCompletionSource
_completionSource = new AwaitableCompletionSource<bool>();
// Start a coroutine to wait 3 seconds
// and then set the result of the AwaitableCompletionSource
StartCoroutine(WaitAndComplete());
// Await the result of the AwaitableCompletionSource
bool result = await _completionSource.Awaitable;
// Log the result to the console
Debug.Log("Result: " + result);
}
private IEnumerator WaitAndComplete()
{
yield return new WaitForSeconds(3);
// Set the result of the AwaitableCompletionSource
_completionSource.SetResult(true);
}
}
Sometimes it’s necessary to perform massive calculations that can lead to game freezes. For this, it is better to use Awaitable methods: BackgroundThreadAsync()
and MainThreadAsync()
. They allow you to exit the main thread and return to it.
private async Awaitable DoCalculationsAsync()
{
// Awaiting execution on a ThreadPool background thread.
await Awaitable.BackgroundThreadAsync();
var result = PerformSomeHeavyCalculations();
// Awaiting execution on the Unity main thread.
await Awaitable.MainThreadAsync();
// Using the result in main thread
Debug.Log(result);
}
This way, Awaitables eliminate the drawbacks of using .NET Tasks and also allow for awaiting PlayerLoop events and AsyncOperations.
As we can see, with the development of Unity, there are more and more tools for organizing asynchronous operations:
Unity |
Coroutines |
Promises |
.NET Tasks |
UniTask |
Built-in Cancellation Tokens |
Awaitable API |
---|---|---|---|---|---|---|
5.6 |
✅ |
✅ |
|
|
|
|
2017.1 |
✅ |
✅ |
✅ |
|
|
|
2018.4 |
✅ |
✅ |
✅ |
✅ |
|
|
2022.2 |
✅ |
✅ |
✅ |
✅ |
✅ |
|
2023.1 |
✅ |
✅ |
✅ |
✅ |
✅ |
✅ |
We have considered all the main ways of asynchronous programming in Unity. Depending on the complexity of your task and the version of Unity you are using, you can use a wide range of technologies from Coroutines and Promises to Tasks and Awaitables, to ensure a smooth and seamless gameplay in your games. Thanks for reading, and we await your next masterpieces.