Coroutines and the MainThreadRunner
Coroutines are the backbone of all long-running operations in the Nunu SDK. They provide a way to handle asynchronous operations that can’t be completed in a single frame, need to track progress, or require continuous monitoring while maintaining thread safety.
Why Use Coroutines with MainThreadRunner?
All Flayer functions are executed on the main game thread, meaning they all need to safely interact with Unity’s main thread. But long operations like navigation, camera manipulation, or other complex tasks can take multiple frames to complete.
So instead of blocking/freezing the main thread by trying to complete a complex operation in a single tick, we can use coroutines with the MainThreadRunner to:
- Execute code across multiple frames
- Maintain proper thread safety when called from network threads
- Return typed results from coroutines
- Handle timeouts gracefully
How Do MainThreadRunner Coroutines Work?
When you use RunCoroutineOnMainThread, you’re telling the MainThreadRunner: “This operation will take some time to complete across multiple frames, and I’ll yield values to control flow and eventually return a result of type T.”
The MainThreadRunner handles:
- Thread-safe queuing of the coroutine
- Proper execution on the main thread
- Converting the coroutine’s result to a Task
- Exception propagation
- Automatic cancellation and cleanup
All coroutines have a built-in timeout mechanism through WithTimeout extensions, ensuring operations don’t run indefinitely if something goes wrong.
Integration with Flayer Functions
When creating a Flayer function that needs to run across multiple frames, you’ll use the following pattern:
- Create a method marked with
[FlayerFunction]attribute - Specify the
yieldReturnTypeparameter in the attribute to indicate what type the coroutine will return - Return an
IEnumeratorfrom the function
All Flayer functions are already automatically deployed on the main thread using the MainThreadRunner, so you don’t need to worry about that. But because they are all executed on the main thread, we need to ensure that any long-running operations are handled in a coroutine or we would freeze the main thread.
// Define our camera control coroutine with the Flayer Function attribute
[FlayerFunction("look_at_target", "Smoothly rotates the camera to look at a target actor",
yieldReturnType: typeof(bool))]
public static IEnumerator LookAtTarget(string actorName, float duration, PacketLogger logger)
{
// Validate inputs
var target = GameObject.Find(actorName);
if (target == null)
{
logger.Log($"Could not find target: {actorName}", LogLevel.Error);
yield return false; // Return default value
yield break;
}
var camera = Camera.main;
if (camera == null)
{
logger.Log("Could not find main camera", LogLevel.Error);
yield return false;
yield break;
}
logger.Log($"Starting camera look at: {actorName}", LogLevel.Info);
// Track our operation state
float timeElapsed = 0f;
float interpSpeed = 5f;
// Execute across multiple frames
while (timeElapsed < duration)
{
// Calculate desired rotation
Vector3 targetPosition = target.transform.position;
Vector3 cameraPosition = camera.transform.position;
Quaternion desiredRotation = Quaternion.LookRotation(targetPosition - cameraPosition);
// Smoothly interpolate
camera.transform.rotation = Quaternion.Slerp(
camera.transform.rotation,
desiredRotation,
Time.deltaTime * interpSpeed
);
// Log progress occasionally
if (Mathf.FloorToInt(timeElapsed * 2) != Mathf.FloorToInt((timeElapsed + Time.deltaTime) * 2))
{
float rotationDiff = Quaternion.Angle(camera.transform.rotation, desiredRotation);
logger.Log($"Rotating camera. Difference: {rotationDiff:F2} degrees", LogLevel.Info);
}
// Update elapsed time
timeElapsed += Time.deltaTime;
// Yield control back to Unity until next frame
yield return null;
}
// Final update to ensure we're looking at the target
camera.transform.LookAt(target.transform);
logger.Log("Camera rotation completed", LogLevel.Info);
// Return final camera position as our result
yield return true; // Indicate success
}Coroutine Extensions
The CoroutineExtensions utility class provides powerful tools for working with Unity’s coroutines in a more modern, type-safe, and controlled manner. These extensions bridge the gap between Unity’s coroutine system and .NET’s Task-based asynchronous pattern, making them particularly valuable for Flayer functions that need to span multiple frames.
AsTask
Converts a coroutine to a Task, allowing it to be awaited. This is essential for integrating coroutine-based Unity operations with task-based asynchronous code.
Task<T> AsTask<T>(this IEnumerator<T> coroutine, MonoBehaviour runner);// Define a simple coroutine that returns an integer
IEnumerator<int> CountdownCoroutine()
{
int count = 3;
while (count > 0)
{
Debug.Log($"Countdown: {count}");
count--;
yield return null; // Wait a frame
}
yield return 0; // Return final result
}
// Convert and await the coroutine
async Task<int> DoCountdown()
{
int result = await CountdownCoroutine().AsTask(this);
Debug.Log($"Countdown complete with result: {result}");
return result;
}
AsTask with Timeout
Converts a coroutine to a Task with a timeout. This prevents long-running coroutines from blocking indefinitely.
Task<T> AsTask<T>(this IEnumerator<T> coroutine, MonoBehaviour runner, float timeoutSeconds);// Load a resource with timeout protection
async Task<Texture> LoadTextureWithTimeout(string path)
{
try
{
return await LoadTextureCoroutine(path).AsTask(this, 5.0f); // 5 second timeout
}
catch (TimeoutException)
{
Debug.LogWarning($"Loading texture {path} timed out after 5 seconds");
return null;
}
}
IEnumerator<Texture> LoadTextureCoroutine(string path)
{
ResourceRequest request = Resources.LoadAsync<Texture>(path);
while (!request.isDone)
{
yield return null;
}
yield return request.asset as Texture;
}
WithTimeout
Wraps a coroutine with a timeout mechanism. This allows controlled execution of coroutines, preventing them from running indefinitely.
IEnumerator<TimeoutResult> WithTimeout(this IEnumerator coroutine, float timeoutInSeconds);// Use WithTimeout directly with a coroutine
IEnumerator MoveToPosition(Vector3 targetPos)
{
foreach (var result in NavSystem.MoveTo(targetPos).WithTimeout(30.0f))
{
if (result.TimedOut)
{
Debug.LogWarning("Navigation timed out after 30 seconds");
yield break;
}
yield return null;
}
Debug.Log("Successfully reached destination");
}WithTimeout with Result
Wraps a coroutine with a timeout mechanism, but also preserves the result value when available. This is particularly useful for Flayer functions that need to return a value even if timeout occurs.
IEnumerator<TimeoutResult<T>> WithTimeout<T>(this IEnumerator<T> coroutine, float timeoutInSeconds);// Process data with timeout, returning the best available result
[FlayerFunction("process_world_data", "Analyzes world data with timeout",
yieldReturnType: typeof(WorldDataSummary))]
public static IEnumerator ProcessWorldData(PacketLogger logger)
{
var startTime = Time.time;
var processor = new WorldDataProcessor();
// Use WithTimeout to handle long-running analysis
foreach (var result in processor.AnalyzeWorldData().WithTimeout<WorldDataSummary>(10.0f))
{
if (result.TimedOut)
{
logger.Log("Analysis timed out, returning partial results", LogLevel.Warning);
yield return result.Result; // Return the partial result we have so far
yield break;
}
// Log progress
float progress = processor.ProgressPercentage;
if (Mathf.FloorToInt(progress / 10) != Mathf.FloorToInt(processor.PreviousProgress / 10))
{
logger.Log($"Analysis {progress:F0}% complete", LogLevel.Info);
processor.PreviousProgress = progress;
}
yield return null;
}
// Return the complete analysis result
logger.Log($"Analysis complete in {Time.time - startTime:F2} seconds", LogLevel.Info);
yield return processor.FinalResult;
}Coroutine Result Types
CoroutineExtensions includes specialized types to handle timeout and result conditions:
public class TimeoutResult
{
/// <summary>
/// Indicates whether the coroutine execution timed out.
/// </summary>
public bool TimedOut { get; set; }
}public class TimeoutResult<T>
{
/// <summary>
/// Indicates whether the coroutine execution timed out.
/// </summary>
public bool TimedOut { get; set; }
/// <summary>
/// The result of the coroutine execution, which may be partial if timeout occurred.
/// </summary>
public T Result { get; set; }
}
Implementation Examples
[FlayerFunction("fade_to_black", "Gradually fades screen to black",
yieldReturnType: typeof(bool))]
public static IEnumerator FadeToBlack(float duration, PacketLogger logger)
{
var screenFader = ScreenFader.Instance;
if (screenFader == null)
{
logger.Log("Screen fader not found", LogLevel.Error);
yield return false;
yield break;
}
logger.Log($"Starting screen fade over {duration} seconds", LogLevel.Info);
float elapsedTime = 0;
while (elapsedTime < duration)
{
float normalizedTime = elapsedTime / duration;
screenFader.SetFadeAmount(normalizedTime);
// Log progress at 25%, 50%, 75%
if (Mathf.FloorToInt(normalizedTime * 4) != Mathf.FloorToInt((elapsedTime + Time.deltaTime) / duration * 4))
{
logger.Log($"Fade progress: {normalizedTime * 100:F0}%", LogLevel.Info);
}
elapsedTime += Time.deltaTime;
yield return null;
}
// Ensure we reach 100%
screenFader.SetFadeAmount(1.0f);
logger.Log("Screen fade complete", LogLevel.Info);
yield return true;
}