Skip to Content

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:

  1. Execute code across multiple frames
  2. Maintain proper thread safety when called from network threads
  3. Return typed results from coroutines
  4. 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:

  1. Create a method marked with [FlayerFunction] attribute
  2. Specify the yieldReturnType parameter in the attribute to indicate what type the coroutine will return
  3. Return an IEnumerator from 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; }
Last updated on