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.
Taskis awaitable fromAwaitablemethods, from coroutine-adjacent code, and from plainasync void/async Taskhandlers. AnAwaitable-returning API is only awaitable from Unity code. Awaitableis single-await. Instances are pooled, and awaiting the same one twice throws. ATaskcan 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, andIStringProviderare seams you implement. Keeping them onTaskkeeps 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.