Skip to content

Async APIs

Every asynchronous OxHeart API returns Task or Task<T>. This page covers how to consume them — including from Awaitable-based code — and what the engine does and does not promise about them.

Awaiting from Awaitable code

If your project has standardised on Unity's Awaitable, you do not need an interop layer. C#'s await binds to any type exposing a GetAwaiter(), so an OxHeart Task is awaited directly inside an async Awaitable method:

using UnityEngine;
using OxHeart.Core.ServiceManagement;
using OxHeart.Core.SceneManagement.Interfaces;
using OxHeart.Core.DataPersistence.Interfaces;

public class LevelFlow : MonoBehaviour
{
    private async Awaitable StartLevel(string sceneKey)
    {
        var scenes = ServiceLocator.Instance.Get<ISceneService>();
        var saves  = ServiceLocator.Instance.Get<ISaveSystem>();

        await scenes.LoadSceneAsync(sceneKey);   // Task, awaited from an Awaitable method
        await saves.SaveGameAsync();             // so is this

        await Awaitable.NextFrameAsync();        // mix freely with Awaitable's own helpers
    }
}

The reverse direction is not needed: no OxHeart API takes a Task or Func<Task> parameter, so there is nothing you have to convert an Awaitable into.

The engine uses Awaitable internally where it needs to yield per frame — scene loading drives its progress reporting with Awaitable.NextFrameAsync — so the type is fully supported at the package's 6000.0 minimum. It is simply not what the public surface returns.

Why the public API stays Task-based

  • It works in both worlds. Task is awaitable from Awaitable methods, from coroutine-adjacent code, and from plain async void/async Task handlers. An Awaitable-returning API is only awaitable from Unity code.
  • Awaitable is single-await. Instances are pooled, and awaiting the same one twice throws. A Task can be stored, handed around, and awaited by several callers — which the engine relies on internally (a language preload already in flight is shared between callers rather than started twice).
  • A public contract should not be Unity-only. ISaveStore, IResourceLoader, and IStringProvider are seams you implement. Keeping them on Task keeps your implementations testable in a plain test runner.

A wrapper such as task.AsAwaitable() would also not save you an allocation — the Task is created either way, and the wrapper adds an Awaitable and a second state machine on top of it.

Fire-and-forget versus awaited

Two save entry points exist deliberately, and they are not interchangeable:

Call Use when
await saveSystem.SaveGameAsync() You need to know the write finished — quitting, a checkpoint the player was told about, before a scene teardown.
saveSystem.RequestSave() Something changed and a write should happen soon. Coalesces bursts on a debounce timer; safe to call every time a value changes.

RequestSave is the right default for gameplay state that changes often. See Data Persistence.

Exceptions

An OxHeart Task that faults surfaces its exception when you await it. If you deliberately do not await, the exception is lost unless you observe the task — so prefer await, or attach a continuation that logs. The audio extensions do exactly this internally for fire-and-forget playback.

Services that are documented as graceful — scene management is the main one — do not throw for expected failures. LoadSceneAsync returns a result describing what happened rather than raising for a missing scene; check the result rather than wrapping the call in try/catch. See Scene Management.

Cancellation

The public async APIs do not currently accept a CancellationToken. Where an operation needs to be abandoned, the engine handles it internally rather than exposing a token:

  • Scene loading allows a single in-flight load. Starting a new load cancels the previous one, which resolves to a graceful failure result rather than throwing at your call site.
  • Language preloads are shared: asking for a language already being preloaded awaits the existing operation instead of starting a second.

Two consequences worth planning around. An Addressables-backed resource load cannot be aborted once started — the underlying handle runs to completion, and the engine releases it through the normal reference-counted path. And a save in progress should not be interrupted; use RequestSave for frequent writes rather than starting and abandoning SaveGameAsync calls.