Onity AI Usage Guide

This guide is written for coding assistants and dense enough for people: the rules that produce correct Onity code, the canonical scene, a per-module API index and an error-to-fix table. Onity (com.onity.framework, Unity 2022.3 LTS or newer) puts dependency injection, reactive state, typed messaging, factories with pooling and OnityTask async in one package with one lifetime model: a scope owns its services, subscriptions, tasks and pools and disposes them together. Every name here was checked against the 0.6 source; the installed package source (Runtime/) and tests (Tests/) win when a sentence and the code disagree.

  • The core assemblies (Onity.Core, Onity.DI, Onity.Reactive, Onity.Messaging, Onity.Factory, Onity.Composition) are engine-free (no UnityEngine). The Unity glue lives in Onity.Unity (Onity.Unity.Contexts, Onity.Unity.Installers, Onity.Unity.Messaging, Onity.Unity.Reactive, Onity.Unity.Async, Onity.Unity.Input) and the optional Onity.Unity.UGUI.
  • Hot paths avoid per-call managed allocation: resolve, Publish, OnNext, the frame streams and the pooled task sources allocate only the objects a call must create (a transient resolve still allocates the instance it returns). The runtime uses no System.Linq and has no non-Unity third-party dependency. Measured results, with their conditions, are on the Comparisons pages; this guide states no numbers.
  • Package code style: Allman braces, private instance fields m_camelCase, private static s_camelCase, constants k_camelCase. Match it when adding code to the package; your own game code can follow its own conventions.

Sections: 1 overview, 2 DI, 3 reactive, 4 events, 5 the canonical scene, 6 DO and DON’T, 7 API index, 8 errors, 9 the events decision rule, 10 factories and pooling, 11 async.


1. One package, one lifetime model

You do not stitch a container, a reactive library, a message bus and an async library together. Onity is one API with one mental model:

DI is the spine.             Bind services in a MonoInstaller; the container constructs them with their dependencies.
Events ride the broker.      Every context binds MessageBroker and OnityEventHub; OnityEvent is the Unity shorthand.
Reactive rides both.         Subject<T>, ReactiveProperty<T> and Observe<T>() are the same IOnityObservable<T>.
Async ends with its owner.   OnityTask work takes the scope token or the destroy token; it stops before the services it uses are disposed.
Pools belong to the scope.   BindPooledFactory binds IFactory<T> to spawn and IPool<T> to release; the scope disposes the pool.
Lifetime is one rule.        Every Subscribe returns IDisposable; retain it with AddTo(this), TakeUntilDisable(this), AddTo(scope) or AddTo(bag).

The whole framework in ten lines:

// 1. Register in an installer:           container.Bind<IScoreService>().To<ScoreService>().AsSingle();
// 2. Consume by constructor injection:    public HealthService(ReactiveProperty<int> health, ISubscriber<PlayerDamaged> damage, IOnityScopeLifetime scope) { ... }
// 3. Inject a MonoBehaviour's members:    [Inject] private IReadOnlyReactiveProperty<int> m_health;
// 4. Hold current state:                  container.BindReactiveProperty(initialValue: 100);   // ReactiveProperty<int> + IReadOnlyReactiveProperty<int>
// 5. Observe state (current value first): m_health.Subscribe(value => Show(value)).TakeUntilDisable(this);
// 6. Publish a message:                   OnityEvent.Publish(this, new PlayerDamaged(25));
// 7. Receive it as a filtered stream:     OnityEvent.Observe<PlayerDamaged>(this).Where(m => m.Amount >= 10).Subscribe(ShowHit).TakeUntilDisable(this);
// 8. Tie a service subscription to its scope: damage.Subscribe(OnDamaged).AddTo(scope);
// 9. Run an async loop that ends with the scope: RegenerateAsync(m_scope.Token).Forget();   // async OnityTaskVoid
// 10. Spawn and release from a pool:       HitMarker marker = m_hitMarkers.Create(); ... m_hitMarkerPool.Release(marker);

Why one package instead of four libraries:

Concern Separate libraries Onity
Mental models a container, an observable type, a bus with filters, an async type, each with its own idiom one OnityContainer spine, one IOnityObservable<T>, one OnityTask
Event to stream hand-written adapter from the bus to the reactive library Observe<T>() returns IOnityObservable<T>
Disposal one idiom per library IDisposable plus AddTo(...) everywhere
DI and events wiring a setup call plus per-type registrations the broker and the hub are bound by every context
Async ownership tokens you build and cancel yourself the scope token (IOnityScopeLifetime) and destroy tokens, canceled before disposal
Engine coupling varies an engine-free, scene-free core

2. DI: OnityContainer

OnityContainer (Onity.DI) is sealed, engine-free and parent-scoped, and implements IResolver, IOnityScopeLifetime and IDisposable. It runs in an EditMode test with no scene: using OnityContainer container = new OnityContainer();.

2.1 Binding surface

using Onity.DI;                 // OnityContainer

using OnityContainer container = new OnityContainer();

// Contract -> implementation -> lifetime. The lifetime call registers the binding; Bind and To alone register nothing.
container.Bind<IScoreService>().To<ScoreService>().AsSingle();        // one instance in this scope, shared with children
container.Bind<IPathfinder>().To<AStarPathfinder>().AsTransient();   // a new instance per resolve
container.Bind<IMatchState>().To<MatchState>().AsScoped();           // one instance per resolving scope
container.Bind<IClock>().To<GameClock>().AsSingle().NonLazy();       // created during Build() instead of on first use

// Self-bind shorthand (To defaults to the contract type):
container.Bind<ScoreService>().AsSingle();

// Share ONE instance across the concrete type and all its interfaces (see the trap in 2.7):
container.BindInterfacesAndSelfTo<HealthService>().AsSingle();       // HealthService, IOnityInitializable, ...
container.BindInterfacesTo<HealthService>().AsSingle();              // interfaces only; throws when the type has none

// Identified and conditional bindings (configure before the lifetime):
container.Bind<IClock>().To<ReplayClock>().WithId("replay").AsSingle();
container.Bind<IClock>().To<EditorClock>().WhenInjectedInto<PreviewController>().AsSingle();

// A pre-built instance (caller-owned; null throws OnityBindingException):
container.BindInstance<IGameConfig>(loadedConfig);

// Factories (always bound AsSingle; you author the IFactory<...> class, see 2.5). Each form constrains TFactory
// to the contract it binds: BindFactory<TValue, TFactory>() needs an IFactory<TValue>, the one-parameter form an
// IFactory<TParam, TValue>, the two-parameter form an IFactory<TParam1, TParam2, TValue>:
container.BindFactory<EnemySpawnRequest, Enemy, EnemyFactory>();     // EnemyFactory : IFactory<EnemySpawnRequest, Enemy>
Call Namespace Returns Then
Bind<TContract>() Onity.DI TypeBindingBuilder<TContract> optional To<TConcrete>(), WithId(id), WhenInjectedInto<TConsumer>(); then AsSingle(), AsScoped(), AsTransient() or FromSubContainerResolve(install); optional NonLazy()
Bind(Type contractType) Onity.DI RuntimeTypeBindingBuilder the same with To(Type); accepts open generic definitions
BindInterfacesAndSelfTo<TConcrete>() Onity.DI MultiTypeBindingBuilder optional WithId, WhenInjectedInto; then AsSingle(), AsScoped() or AsTransient(); optional NonLazy()
BindInterfacesTo<TConcrete>() Onity.DI MultiTypeBindingBuilder the same; the concrete type itself is not bound
BindInstance<TContract>(instance), BindInstance<TContract>(instance, id) Onity.DI void registers at once; the instance stays caller-owned
BindFactory<TValue, TFactory>() and the one- and two-parameter forms Onity.DI void binds the factory with BindInterfacesAndSelfTo<TFactory>().AsSingle()
Rebind<TContract>(id = null), Rebind(Type, id = null) Onity.DI a builder replaces the local binding when the new lifetime is selected; works after Build()
Unbind<TContract>(id = null), Unbind(Type, id = null) Onity.DI bool removes the local binding; works after Build()
BindReactiveProperty, BindSubject, DeclareMessage, DeclareAsyncMessage Onity.Composition the primitive one scope-owned primitive under its contracts (2.8, section 4)
BindMessageChannel<T>() Onity.Unity.Messaging void IPublisher<T> and ISubscriber<T> on the scope broker’s channel (section 4)
BindPooledFactory(prefab, parent, defaultCapacity, maxSize), BindPooledFactory(IPool<T>) Onity.Unity.Installers void IPool<T> and IFactory<T> (2.5, section 10)
BindScriptableObject(asset), BindScriptableObject<TContract, TAsset>(asset) Onity.Unity.Installers void member-injects the asset, then binds it as an instance

NonLazy() throws OnityBindingException when it is called before the lifetime, on a conditional binding, or on an open generic binding (the closed type is unknown until resolve).

2.2 Resolve and inject

IScoreService score = container.Resolve<IScoreService>();                 // throws OnityResolveException when unresolvable
object service = container.Resolve(typeof(IScoreService));               // runtime-type form
IClock replay = container.Resolve<IClock>("replay");                      // identified form

if (container.TryResolve<IPathfinder>(out IPathfinder pathfinder)) { }    // false instead of throwing
if (container.TryResolve(typeof(IPathfinder), out object found)) { }
if (container.TryResolve<IClock>("replay", out IClock clock)) { }

bool resolvable = container.CanResolve(typeof(IScoreService));           // no construction
container.Inject(existingObject);                                         // member-inject an object you created

OnityContainer and IResolver always resolve to the active scope; a factory injects IResolver for manual resolves. Resolve once and keep the reference: Resolve inside Update is a lookup per frame for a value injection already handed you (ONITY001).

2.3 [Inject] on constructor, field, property, method and parameter

using Onity.DI;                 // Inject

public sealed class CombatService
{
    private readonly IDamageCalculator m_damage;

    // Constructor injection is preferred. A single [Inject] constructor wins; otherwise the public
    // constructor with the most parameters. A non-public constructor is used only when no public one exists.
    public CombatService(IDamageCalculator damage)
    {
        m_damage = damage;
    }

    [Inject] private IClock m_clock;                       // field injection; private is fine
    [Inject] public IScoreService Score { get; set; }      // property injection; a setter is required
    [Inject] private IClock m_replayClock;                 // add Id = "replay" to select an identified binding

    [Inject]                                                // method injection; runs after fields and properties
    private void Configure(IGameConfig config)              // cannot be generic
    {
    }
}

Member injection runs base class first, then derived class, and within a type fields, then properties, then methods. Static members are never injected. [Inject(Id = ...)] goes on a field, a property, or an individual constructor or method parameter. These throw OnityBindingException: more than one [Inject] constructor, an [Inject] property without a setter, an [Inject] indexer, and a generic [Inject] method. A MonoBehaviour is injected by its context during the context’s Awake, or later by context.InjectGameObject(root) and container.Inject(target); read injected members in Start or later, not in field initializers.

2.4 Child containers, scoped lifetimes, identified and conditional bindings

using OnityContainer parent = new OnityContainer();
parent.Bind<IClock>().To<GameClock>().AsSingle();
parent.Bind<IMatchState>().To<MatchState>().AsScoped();
parent.Build();

using OnityContainer child = new OnityContainer(parent);
child.Bind<IClock>().To<ReplayClock>().AsSingle();     // shadows IClock in the child only
child.Build();

// child.Resolve<IClock>()       -> ReplayClock
// parent.Resolve<IClock>()      -> GameClock
// child.Resolve<IMatchState>()  -> one child-owned MatchState on repeated calls
// parent.Resolve<IMatchState>() -> a separate parent-owned MatchState
  • A child inherits the parent’s bindings; a child binding shadows the parent’s only inside the child. Disposing the child does not dispose the parent; disposing the parent cancels the child’s scope token.
  • AsScoped() is one instance per resolving scope, built with that scope’s dependencies and disposed with it. VContainer’s Lifetime.Scoped maps to it (the Lifetime enum in Onity.Core stays { Singleton, Transient }; AsScoped() is a builder lifetime).
  • WithId(object id) registers a second binding of the same contract under a value-compared id; consume it with [Inject(Id = ...)], Resolve<T>(id) or TryResolve<T>(id, out T). A missing id does not construct a service implicitly.
  • WhenInjectedInto<TConsumer>() restricts a binding to one consumer type and its derived types. A matching conditional binding wins over the unconditional one in the same scope; two matching conditional bindings throw OnityResolveException; an unconditional binding in a child scope shadows conditional bindings in an ancestor.
  • FromSubContainerResolve(install) replaces To<C>(): the installer runs once per requesting scope in a child container, must bind the exported contract there, and the requesting scope forwards ticks to that child and disposes it.
  • Unbind and Rebind change the local scope only, also after Build(). A removed singleton stops ticking but stays owned; a rebound tickable stops the old instance and starts the replacement.

2.5 Factories (runtime arguments)

There is no container.Instantiate<T>(args) and no fluent factory body. To pass a runtime value into a constructed object, author an IFactory<...> (Onity.Factory) and bind it with BindFactory; the container constructs the factory and injects its collaborators, so only runtime values travel through Create:

using Onity.Factory;            // IFactory<TParam, TValue>

public sealed class EnemyFactory : IFactory<EnemySpawnRequest, Enemy>
{
    private readonly IClock m_clock;                       // injected into the factory

    public EnemyFactory(IClock clock)
    {
        m_clock = clock;
    }

    public Enemy Create(EnemySpawnRequest request)
    {
        return new Enemy(request.Id, request.Level, m_clock);
    }
}

// Registration and use (fragment):
// container.BindFactory<EnemySpawnRequest, Enemy, EnemyFactory>();
// Enemy elite = container.Resolve<IFactory<EnemySpawnRequest, Enemy>>().Create(new EnemySpawnRequest("elite", 5));

BindFactory facts: where TFactory : class, IFactory<...>; it registers BindInterfacesAndSelfTo<TFactory>().AsSingle(), so the factory resolves as IFactory<...> and as its own type, one instance per scope, created on first resolve; it returns void, so there is no AsTransient() or NonLazy() form. Two parameters is the largest arity; wrap more values in a struct. A container-built class cannot receive a per-call value (an id, a level, a position) through its constructor: the container only supplies bound types, and resolving it throws OnityResolveException (8.4). Inject the factory instead and call Create(value).

Which factory or pool API:

You need Use Registers Notes
A new plain object per call from 0, 1 or 2 runtime arguments your IFactory<...> class and BindFactory<...>() IFactory<...> and the factory type (one shared instance) no pooling; you write Create
Reusable prefab instances, no arguments BindPooledFactory(prefab, parent, defaultCapacity, maxSize) in a MonoInstaller IPool<TComponent> (instance) and IFactory<TComponent> (PooledFactory<TComponent> singleton) defaults 16 and 512; no prewarm, fixed-size or name option; the scope disposes the pool it creates (10.4)
A prefab pool with prewarm, fixed capacity, a create hook or a diagnostics name new PrefabComponentPool<T>(...), then BindPooledFactory(pool) the same two bindings caller-owned: pool.AddTo(container) or dispose it yourself (10.4)
A pool you already built, any item type BindPooledFactory(IPool<T>) the same two bindings caller-owned; the container disposes it only after pool.AddTo(container)
Pooled spawn with 1 or 2 runtime arguments new PooledFactory<TParam, T>(pool, initialize) or the two-parameter form, bound with BindInstance<IFactory<TParam, T>>(...) what you bind needs an IParameterizedPool<T>; initialize runs before the get hooks and activation (10.2)
Reusable plain C# objects, no GameObject new OnityObjectPool<T>(...) (T : class), optionally BindPooledFactory(pool) what you bind reset in actionOnGet and actionOnRelease; IPoolHooks is not called (10.3)

BindPooledFactory lives in Onity.Unity.Installers; Onity.Pooling references UnityEngine for PrefabComponentPool<T>; the engine-free Onity.DI and Onity.Factory do not. Recipes, call order, documented behaviors and lifetime rules: section 10.

2.6 Build and async startup

container.RegisterBuildCallback(resolver => resolver.Resolve<IGameLoopRunner>().Start());               // runs in Build()
container.RegisterBuildCallbackAsync(async (resolver, token) => await resolver.Resolve<ISaveLoader>().PrimeAsync(token));

container.Build();                                // sync callbacks, Initialize(); idempotent
await container.BuildAsync(cancellationToken);   // Build(), async callbacks, then InitializeAsync() one at a time; result cached

Build callbacks cannot be registered after the build is finalized (OnityBindingException). Each async step starts on the context the build started on, the main thread in a Unity context. The token the callbacks and initializers receive is the scope’s LifetimeToken, linked with the caller’s token when one is passed; disposing the container mid-run ends BuildAsync as canceled, and a canceled or faulted run is not cached, so the next call resumes at the first incomplete step. Dispose() cancels LifetimeToken first, then disposes installed child scopes, scoped instances and owned singletons in reverse registration order. Scope tokens and async startup in a scene: 11.4.

2.7 Documented behaviors (test-locked): DO and DON’T

Behavior DO / DON’T
Implicit transients An unbound concrete class resolves as a new instance per resolve. DON’T rely on it for shared state; bind it AsSingle().
Unbound abstractions An unbound interface, abstract class or open generic definition throws OnityResolveException. DO bind them.
Last binding wins Binding the same contract twice replaces the first binding; there is no duplicate-binding error. DO use it to override; DON’T expect a conflict.
Distinct singletons Bind<IFoo>().To<C>() plus Bind<IBar>().To<C>() create two C instances. DO use BindInterfacesAndSelfTo<C>().AsSingle() to share one.
Constructor selection A single [Inject] constructor wins; otherwise the public constructor with the most parameters; a non-public constructor only when no public one exists. DON’T add a second [Inject] constructor.
Circular dependency Constructor and member cycles throw OnityResolveException at resolve time with the full chain. DO break the cycle with a factory or IResolver.
Open generics DO bind Bind(typeof(IRepository<>)).To(typeof(Repository<>)).AsSingle() and resolve closed forms such as IRepository<PlayerProfile>; on IL2CPP preserve the closed types. NonLazy() is not supported on an open generic binding.
Collection injection DO request IEnumerable<T>, IReadOnlyList<T>, IReadOnlyCollection<T>, IList<T>, ICollection<T>, List<T> or T[] to receive every explicit binding of T from this scope and its ancestors, ancestors first. A plain Resolve<T>() returns the last binding.
Conditional and identified bindings DO configure WithId and WhenInjectedInto before the lifetime. Two matching conditional bindings throw; a local unconditional binding shadows ancestor conditions; a missing id does not construct anything.
Post-build changes Unbind and Rebind work after Build(); new bindings belong in installers before it, and build callbacks throw afterwards.
Statics [Inject] on a static member is ignored. DON’T use it.

2.8 Copy-paste installer recipe

The canonical scene’s installer (section 5), plus the ScriptableObject form:

using Onity.Composition;        // BindReactiveProperty
using Onity.DI;                 // OnityContainer
using Onity.Unity.Installers;   // MonoInstaller, BindPooledFactory, BindScriptableObject
using Onity.Unity.Messaging;    // BindMessageChannel
using UnityEngine;

public sealed class GameInstaller : MonoInstaller
{
    [SerializeField] private GameConfig m_config;            // a ScriptableObject that implements IGameConfig
    [SerializeField] private HitMarker m_hitMarkerPrefab;
    [SerializeField] private Transform m_hitMarkerRoot;

    public override void InstallBindings(OnityContainer container)
    {
        // Member-inject the asset, then bind it as the read-only contract (caller-owned).
        container.BindScriptableObject<IGameConfig, GameConfig>(m_config);

        // ReactiveProperty<int> and IReadOnlyReactiveProperty<int>; the scope disposes it.
        container.BindReactiveProperty(initialValue: 100);

        // IPublisher<PlayerDamaged> and ISubscriber<PlayerDamaged> on the scope's message broker,
        // the same channel OnityEvent and OnityEventHub use.
        container.BindMessageChannel<PlayerDamaged>();

        // IFactory<HitMarker> to spawn and IPool<HitMarker> to release; the scope disposes the pool.
        container.BindPooledFactory(m_hitMarkerPrefab, m_hitMarkerRoot, defaultCapacity: 8, maxSize: 32);

        // The container constructs it, runs Initialize() at Build() and disposes it with the scope.
        container.BindInterfacesAndSelfTo<HealthService>().AsSingle();
    }
}

MessageBroker (with IMessageBroker) and OnityEventHub are bound by every context, so a service injects either with no installer line. BindMessageChannel<T>() is only needed to inject IPublisher<T> or ISubscriber<T> directly, and it resolves IMessageBroker at bind time; in a bare container without a context, bind MessageBroker first or use DeclareMessage<T>() (section 4.4, Recipe E).


3. Reactive: Onity.Reactive and the Onity.Unity.Reactive bridges

Push-based and hot by default. The one contract is IOnityObservable<T>: Subject<T>, ReactiveProperty<T>, every operator and Observe<T>() return it. Observer<T> is public delegate void Observer<T>(T value); the Subscribe(Action<T>) you normally write is an extension that stores the action in the subscription node, which the source invokes directly. A plain subscription allocates one object (the node, which is also the IDisposable you receive); steady-state OnNext and SetValue allocate nothing. OnityObserver<T> is the abstract observer-object form with OnNextCore, OnErrorCore, OnCompletedCore and OnDisposed; gameplay code rarely needs it.

3.1 Primitives

using System;
using Onity.Reactive;           // Subject<T>, ReactiveProperty<T>, CompositeDisposable, AddTo
using UnityEngine;

// Subject<T>: a multicast source. OnNext allocates nothing in steady state.
Subject<int> damage = new Subject<int>();
IDisposable token = damage.Subscribe(amount => Debug.Log(amount));
damage.OnNext(10);
token.Dispose();
damage.Dispose();                                    // OnNext and Subscribe throw ObjectDisposedException afterwards

// ReactiveProperty<T>: a value plus change notification. Equal values are skipped (built-in DistinctUntilChanged).
ReactiveProperty<int> health = new ReactiveProperty<int>(100);
health.Value = 90;                                   // notifies only when the value changed
bool changed = health.SetValue(90);                  // false: same value, nothing published
IDisposable current = health.Subscribe(value => Debug.Log(value));                          // logs 90 now, then each change
IDisposable changes = health.Subscribe(value => Debug.Log(value), emitCurrentValue: false);  // changes only
IReadOnlyReactiveProperty<int> readOnly = health;    // hand this to consumers: Value plus Subscribe, no operators
current.Dispose();                                   // every Subscribe result is kept and disposed by its owner
changes.Dispose();

// CompositeDisposable: a lifetime bag for plain C# owners.
CompositeDisposable bag = new CompositeDisposable();
health.Subscribe(value => { }).AddTo(bag);
bag.Clear();                                         // dispose everything, keep the bag
bag.Dispose();                                       // final; a later Add disposes the item at once

// A custom source: the function receives the observer and returns the disposable that ends the subscription.
IOnityObservable<int> healthStream = new OnityObservable<int>(observer => readOnly.Subscribe(observer));

IReadOnlyReactiveProperty<T> declares only Value and Subscribe(observer, emitCurrentValue = true); it does not implement IOnityObservable<T>, so the operators do not apply to it. Inject the ReactiveProperty<T> itself where Where or another operator is needed, or wrap it once as above. ReactiveProperty<T>.Dispose() does not notify observers.

3.2 Synchronous operators (OnityObservableExtensions)

Each returns IOnityObservable<T>, allocates one sink node when it is subscribed and forwards each value without allocating (Where followed by Select is fused into one sink):

Where(Predicate<T>), Select(Func<TSource, TResult>), DistinctUntilChanged(IEqualityComparer<T> = null), Skip(int), SkipWhile(Predicate<T>), Take(int), TakeWhile(Predicate<T>), StartWith(T), Scan<TState>(seed, Func<TState, T, TState>), Pairwise() -> IOnityObservable<OnityPair<T>> (Previous, Current), Buffer(int count) -> IOnityObservable<IReadOnlyList<T>>, Merge(params IOnityObservable<T>[]), CombineLatest<T1, T2, TResult>(other, selector) (two sources), Sample<TSignal>(sampler), TakeUntilCancellation(CancellationToken), Subscribe(Action<T>), Subscribe(Action<T>, Action<Exception>, Action<OnityResult>), FirstAsync(CancellationToken) -> Task<T>, ToTask(this IOnityObservable<Unit>, CancellationToken) -> Task.

Factories on the static OnityObservable: FromEvent<T>(addHandler, removeHandler), Return<T>(value), Empty<T>(). There is no Never, Create or Defer, and no Window, Zip, Switch, Concat, Publish, Share or RefCount on IOnityObservable<T>.

// Fragment, in a service that holds ReactiveProperty<int> m_health and an injected IOnityScopeLifetime scope:
m_health.Where(value => value <= 0)
        .Subscribe(_ => m_events.Publish(new PlayerDied()))
        .AddTo(scope);

The core operators carry no error channel: onError is raised only by sources that report faults (OnityTask.ToObservable(), IOnityAsyncEnumerable<T>.AsObservable()), and onCompleted runs when the subscription is disposed. TakeUntil(ct) and TakeUntilCancellation(ct) stop the stream silently.

3.3 Time and async operators (OnityObservableAsyncExtensions and the thread-pool operators)

Each time operator takes an optional OnityTimeProvider. Without one it waits on OnityTimeProvider.System (wall-clock Task.Delay); in gameplay pass one of OnityTimeProviders (Onity.Unity.Reactive: UpdateScaled, UpdateUnscaled, UpdateRealtime and the Fixed* and Late* forms) so the wait follows a Unity loop and time mode. In EditMode tests subclass OnityTimeProvider and override DelayAsync; no manual or fake provider ships.

  • Debounce(TimeSpan dueTime, OnityTimeProvider = null): emit the last value after a quiet window.
  • Throttle(TimeSpan interval, OnityTimeProvider = null): emit the first value, then drop values until the interval elapses (leading edge). ThrottleLast(TimeSpan interval, OnityTimeProvider = null): emit the latest value once per interval (trailing edge). UniRx’s Throttle corresponds to Debounce.
  • Buffer(TimeSpan timeSpan, OnityTimeProvider = null): the values gathered in each window.
  • TakeUntil(CancellationToken) and TakeUntil(Task): stop on a signal; TakeUntilCancellation(token) is the synchronous form.
  • SelectAwait(Func<T, CancellationToken, ValueTask<TResult>>) and WhereAwait(Func<T, CancellationToken, ValueTask<bool>>): sequential async projection and filter, one value at a time, run inside Task.Run; values arrive on the thread that completed the delegate. A delegate that throws ends that subscription without notifying the subscriber.
  • ObserveOnThreadPool(): re-post values onto one worker in source order. SelectOnThreadPool(selector, maxConcurrency = 0) (also with a token-taking selector): a CPU-bound projection on up to maxConcurrency workers (0 is the processor count); results emit as workers finish, so pass 1 when order matters. An exception on the worker goes to OnityObservableExceptionHandler.
  • ObserveOn(OnityFrameProvider) queues values onto a frame provider’s next tick; ObserveOnMainThread() (Onity.Unity.Reactive) is ObserveOn(OnityFrameProviders.Update), and the required hop before a subscriber touches UnityEngine after SelectAwait, WhereAwait or a thread-pool operator. Delay(float seconds, bool useUnscaledTime = false) delays each value with a Unity timer.

3.4 Unity bridges: frame loops, timers, lifetime

using System.Threading;
using Onity.DI;                 // [Inject], IOnityScopeLifetime, AddTo(IOnityScopeLifetime)
using Onity.Reactive;           // Subscribe, CompositeDisposable, AddTo(CompositeDisposable)
using Onity.Unity.Reactive;     // OnityUnityObservable, AddTo(Component), TakeUntilDestroy, TakeUntilDisable
using UnityEngine;

public sealed class FrameLoops : MonoBehaviour
{
    [Inject] private IOnityScopeLifetime m_scope;                 // the scope of the context above this component

    private readonly CompositeDisposable m_bag = new CompositeDisposable();
    private readonly CancellationTokenSource m_stop = new CancellationTokenSource();

    private void Start()
    {
        // Frame loops are IOnityObservable<Unit> (Unit lives in Onity.Core): one value per frame from a shared
        // pump component. The lifetime helpers extend the IDisposable that Subscribe returns, so they go AFTER it.
        OnityUnityObservable.EveryUpdate().Subscribe(_ => Tick()).AddTo(this);                  // disposed when this Component is destroyed (same as TakeUntilDestroy)
        OnityUnityObservable.EveryFixedUpdate().Subscribe(_ => Tick()).TakeUntilDisable(this);  // disposed when this Behaviour is disabled, and on destroy
        OnityUnityObservable.EveryLateUpdate().Subscribe(_ => Tick()).TakeUntilDestroy(this);
        OnityUnityObservable.EveryUpdate(m_stop.Token).Subscribe(_ => Tick()).AddTo(m_bag);     // stops when the token is canceled; Onity.Reactive bag

        // Timers: one Unit after two seconds (useUnscaledTime overload); the tick count, starting at 1, every second.
        OnityUnityObservable.Timer(2f).Subscribe(_ => Debug.Log("two seconds")).AddTo(m_scope);  // Onity.DI: disposed with the scope
        OnityUnityObservable.Interval(1f).Subscribe(tick => Debug.Log(tick)).AddTo(this);        // IOnityObservable<int>
    }

    private void OnDestroy()
    {
        m_stop.Cancel();
        m_stop.Dispose();
        m_bag.Dispose();
    }

    private void Tick()
    {
    }
}

The Unity helpers take a Component or Behaviour; pass this from a MonoBehaviour. There is no AddTo(GameObject) overload. On first use they add an OnityLifetimeNotifier component to the owner, which disposes the registered subscriptions from its OnDisable and OnDestroy. The EveryUpdate(OnityUnityThreadMode ...) overloads add a job boundary around each frame signal (SingleThread, JobMultiThread, BurstJobMultiThread, DotsEventDriven); observers still run on the main thread. For a service that ticks for the life of its scope, implement IOnityTickable instead of subscribing to EveryUpdate().

3.5 Gameplay recipes

// Recipe A: a threshold on current state. ReactiveProperty replays its value, so this fires at once when already dead.
// Fragment of HealthService's constructor (ReactiveProperty<int> health, OnityEventHub events, IOnityScopeLifetime scope):
health.Where(value => value <= 0)
      .Subscribe(_ => events.Publish(new PlayerDied()))
      .AddTo(scope);                             // unsubscribed when the scope ends
// Recipe B: tick every frame until this Behaviour is disabled. Fragment of HitMarker.OnEnable:
OnityUnityObservable.EveryUpdate()
    .Subscribe(_ => Fade())                       // one step of the fade per frame
    .TakeUntilDisable(this);                      // disposed on disable, and on destroy
// Recipe C: debounce in scaled game time. Fragment of HealthHud.OnEnable:
OnityEvent.Observe<PlayerDamaged>(this)
          .Debounce(TimeSpan.FromSeconds(2), OnityTimeProviders.UpdateScaled)
          .Subscribe(_ => HideHitMarkers())
          .TakeUntilDisable(this);
// Recipe D: await the first matching value (reactive -> Task). Fragment of a service that holds OnityEventHub m_events:
public Task WaitForDeathAsync(CancellationToken ct)
{
    return m_events.Observe<PlayerDied>().FirstAsync(ct);   // completes on the first PlayerDied, then unsubscribes
}
// For OnityTask code: m_events.Observe<PlayerDied>().FirstOnityTask(ct)  (Onity.Unity.Async)
// Recipe E: Input System reactive bridge (requires ENABLE_INPUT_SYSTEM).
using Onity.Reactive;           // Subscribe
using Onity.Unity.Input;        // PerformedAsObservable, StartedAsObservable, CanceledAsObservable
using Onity.Unity.Reactive;     // TakeUntilDisable
using UnityEngine;
using UnityEngine.InputSystem;

public sealed class FireControl : MonoBehaviour
{
    [SerializeField] private InputActionReference m_fire;

    private void OnEnable()
    {
        m_fire.action.PerformedAsObservable()
              .Subscribe(_ => Fire())
              .TakeUntilDisable(this);
    }

    private void Fire()
    {
    }
}

OnityReactiveInputPlayer (Onity.Unity.Input) routes an InputActionAsset into button, Vector2, float and long-press streams (GetButtonObservable, GetVector2Observable, GetFloatObservable, GetLongPressObservable, GetLongPressProgressObservable) with a context stack; the long-press streams exist only on this type.


4. Events: Onity.Messaging and the Onity.Unity.Messaging bridges

Typed pub/sub over the scope’s broker. MessageChannel<T> delivers one message type: Publish allocates nothing in steady state, a handler may unsubscribe from inside a publish pass, and the channel throws after Dispose(). Publish and subscribe on the Unity main thread; the broker locks its channel table, never the publish path. Handlers run in subscription order until an unsubscribe outside a publish pass moves the last handler into the freed slot, so the order is not a priority contract.

4.1 Surface

using System;
using System.Collections.Generic;
using Onity.Messaging;          // IMessageBroker, IPublisher<T>, ISubscriber<T>, MessageChannelDiagnostics
using UnityEngine;

// IMessageBroker: the source of typed channels, one per message type, created on first request.
IPublisher<PlayerDamaged> publisher = broker.GetPublisher<PlayerDamaged>();
ISubscriber<PlayerDamaged> subscriber = broker.GetSubscriber<PlayerDamaged>();

publisher.Publish(new PlayerDamaged(10));
IDisposable token = subscriber.Subscribe(message => Debug.Log(message.Amount));

// Broker-level convenience (MessageBrokerExtensions):
broker.Publish(new PlayerDamaged(10));
IDisposable token2 = broker.Subscribe<PlayerDamaged>(message => Debug.Log(message.Amount));

// Diagnostics into a caller-supplied list (MessageBroker only):
List<MessageChannelDiagnostics> diagnostics = new List<MessageChannelDiagnostics>(8);
broker.GetDiagnostics(diagnostics);          // each entry: MessageType and SubscriberCount
int channels = broker.ChannelCount;

MessageHandler<TMessage> is public delegate void MessageHandler<TMessage>(TMessage message). Keyed routing ships as KeyedMessageChannel<TKey, TMessage> (IKeyedPublisher, IKeyedSubscriber; its members take a key first: channel.Subscribe(1, handler), channel.Publish(1, message)), and awaited delivery as AsyncMessageChannel<TMessage> (IAsyncPublisher.PublishAsync(message, ct), IAsyncSubscriber.Subscribe(handler), sequential), with PublishOnityTask and SubscribeOnityTask (Onity.Unity.Async) as the OnityTask forms. The broker does not create keyed or async channels; construct them, or use DeclareAsyncMessage<T>(). Not shipped: buffered or replayed messages, handler priority, request-response. Model “the current value for a late subscriber” as a ReactiveProperty<T>.

A handler that throws propagates out of Publish (or faults PublishAsync), and the remaining handlers for that message do not run; this also holds for observers of a stream from Observe<T>(), which run inside the channel’s handler. Catch inside the handler when one listener must not break the others.

4.2 Reactive bridge: Observe

broker.Observe<T>(), subscriber.Observe<T>() (OnityMessageReactiveExtensions, Onity.Unity.Messaging), OnityEventHub.Observe<T>() and OnityEvent.Observe<T>() return IOnityObservable<T>, so a message flows into the operator chain without an adapter. The hub caches one stream per message type.

using Onity.Reactive;           // Where, Select, Subscribe
using Onity.Unity.Messaging;    // Observe<T> on IMessageBroker and ISubscriber<T>
using Onity.Unity.Reactive;     // AddTo
using UnityEngine;              // Debug

broker.Observe<PlayerDamaged>()
      .Where(message => message.Amount > 0)
      .Select(message => message.Amount)
      .Subscribe(amount => Debug.Log($"Took {amount}"))
      .AddTo(this);

4.3 OnityEvent and OnityEventHub

OnityEvent (Onity.Unity) is the static shorthand for MonoBehaviour code. The no-owner members use the default context: the most recently activated SceneContext, then ProjectContext.Instance, then any other active context. The owner members use the context on the owner component or its nearest parent, with the default context as fallback; use them inside a GameObjectContext. Without any active context they throw InvalidOperationException.

using System;
using Onity.Reactive;           // IOnityObservable<T>
using Onity.Unity;              // OnityEvent
using Onity.Unity.Messaging;    // OnityEventHub

OnityEvent.Publish(new PlayerDamaged(10));                                   // default context
OnityEvent.Publish(this, new PlayerDamaged(10));                             // nearest context above this component
IDisposable token = OnityEvent.Subscribe<PlayerDamaged>(this, OnDamaged);    // also disposed when this component is destroyed
IOnityObservable<PlayerDamaged> stream = OnityEvent.Observe<PlayerDamaged>(this);
OnityEventHub hub = OnityEvent.GetEventHub(this);                            // TryGetEventHub returns false instead of throwing

OnityEventHub (Onity.Unity.Messaging) is the scoped facade a service injects: Publish<TMessage>(message), Subscribe<TMessage>(handler) -> IDisposable and Observe<TMessage>() -> IOnityObservable<TMessage>. It wraps the scope’s broker, implements no interfaces, and every context binds it as itself. IMessageBroker offers the same calls when one service handles several message types.

4.4 Event recipes

// Recipe A: publish a typed message from Unity code through the nearest context (the canonical DamageZone).
using Onity.Unity;              // OnityEvent
using UnityEngine;

public readonly struct PlayerDamaged
{
    public readonly int Amount;

    public PlayerDamaged(int amount)
    {
        Amount = amount;
    }
}

public sealed class DamageZone : MonoBehaviour
{
    [SerializeField] private int m_damage = 25;

    private void OnTriggerEnter(Collider other)
    {
        OnityEvent.Publish(this, new PlayerDamaged(m_damage));   // the nearest context's channel
    }
}
// Recipe B: subscribe in OnEnable, dispose in OnDisable, so a re-enabled component does not subscribe twice.
using System;
using Onity.Unity;              // OnityEvent
using UnityEngine;

public sealed class DamageFlash : MonoBehaviour
{
    private IDisposable m_subscription;

    private void OnEnable()
    {
        m_subscription = OnityEvent.Subscribe<PlayerDamaged>(this, OnDamaged);   // also disposed on destroy
    }

    private void OnDisable()
    {
        m_subscription?.Dispose();
        m_subscription = null;
    }

    private void OnDamaged(PlayerDamaged message)
    {
        // Flash the screen.
    }
}
// Recipe C: direction-only injection (needs BindMessageChannel<PlayerDamaged>() in the installer).
// The canonical HealthService takes the subscriber side and ties the subscription to its scope.
using System;
using Onity.DI;                 // IOnityScopeLifetime, AddTo(scope)
using Onity.Messaging;          // ISubscriber<T>
using Onity.Reactive;           // ReactiveProperty<T>

public sealed class HealthService
{
    private readonly ReactiveProperty<int> m_health;

    public HealthService(ReactiveProperty<int> health, ISubscriber<PlayerDamaged> damage, IOnityScopeLifetime scope)
    {
        m_health = health;
        damage.Subscribe(OnDamaged).AddTo(scope);     // unsubscribed when the scope ends
    }

    private void OnDamaged(PlayerDamaged message)
    {
        m_health.SetValue(Math.Max(0, m_health.Value - message.Amount));
    }
}
// Recipe D: broker-direct, engine-free (tests and plain services).
using System;
using Onity.Messaging;          // MessageBroker, Publish, Subscribe

using MessageBroker broker = new MessageBroker();
IDisposable token = broker.Subscribe<PlayerDamaged>(message => { /* handle */ });
broker.Publish(new PlayerDamaged(10));
token.Dispose();

Recipe E, two ways to bind IPublisher<T> and ISubscriber<T>: container.BindMessageChannel<T>() (Onity.Unity.Messaging) binds the scope broker’s own channel, so OnityEvent, OnityEventHub and the injected interfaces share one channel; it resolves IMessageBroker at bind time, which a context binds before installers run. container.DeclareMessage<T>() (Onity.Composition) creates a standalone MessageChannel<T> the broker never sees and binds it as MessageChannel<T>, IPublisher<T> and ISubscriber<T>; the scope disposes it. Use DeclareMessage for a channel private to the services that inject it, or as the stand-in for the broker in a scene-free test (section 5), never for a channel that OnityEvent must reach.


5. End-to-end: the canonical scene

One scene scope owns the player’s health. DamageZone publishes PlayerDamaged; HealthService (constructor-injected) lowers the health and regenerates it on an async loop that ends with the scope; HealthHud shows the health and spawns a pooled HitMarker that it releases after a delay; one installer binds all of it. Getting Started builds the same files step by step.

// PlayerDamaged.cs: the message. Plain C#, nothing from Onity.
public readonly struct PlayerDamaged
{
    public readonly int Amount;

    public PlayerDamaged(int amount)
    {
        Amount = amount;
    }
}
// GameInstaller.cs: the scene's bindings.
using Onity.Composition;        // BindReactiveProperty
using Onity.DI;                 // OnityContainer
using Onity.Unity.Installers;   // MonoInstaller, BindPooledFactory
using Onity.Unity.Messaging;    // BindMessageChannel
using UnityEngine;

public sealed class GameInstaller : MonoInstaller
{
    [SerializeField] private HitMarker m_hitMarkerPrefab;
    [SerializeField] private Transform m_hitMarkerRoot;

    public override void InstallBindings(OnityContainer container)
    {
        // ReactiveProperty<int> and IReadOnlyReactiveProperty<int>; the scope disposes it.
        container.BindReactiveProperty(initialValue: 100);

        // IPublisher<PlayerDamaged> and ISubscriber<PlayerDamaged> on the scope's message broker,
        // the same channel OnityEvent and OnityEventHub use.
        container.BindMessageChannel<PlayerDamaged>();

        // IFactory<HitMarker> to spawn and IPool<HitMarker> to release; the scope disposes the pool.
        container.BindPooledFactory(m_hitMarkerPrefab, m_hitMarkerRoot, defaultCapacity: 8, maxSize: 32);

        // The container constructs it, runs Initialize() at Build() and disposes it with the scope.
        container.BindInterfacesAndSelfTo<HealthService>().AsSingle();
    }
}
// HealthService.cs: a plain C# service. The container builds it; the scope owns its subscription and its loop.
using System;
using System.Threading;
using Onity.DI;                 // IOnityInitializable, IOnityScopeLifetime, AddTo(scope)
using Onity.Messaging;          // ISubscriber<T>
using Onity.Reactive;           // ReactiveProperty<T>
using Onity.Unity.Async;        // OnityTask, OnityTaskVoid

public sealed class HealthService : IOnityInitializable
{
    private const int k_maxHealth = 100;
    private const float k_regenerationInterval = 1f;

    private readonly ReactiveProperty<int> m_health;
    private readonly IOnityScopeLifetime m_scope;

    public HealthService(ReactiveProperty<int> health, ISubscriber<PlayerDamaged> damage, IOnityScopeLifetime scope)
    {
        m_health = health;
        m_scope = scope;
        damage.Subscribe(OnDamaged).AddTo(scope);     // unsubscribed when the scope ends
    }

    public void Initialize()
    {
        RegenerateAsync(m_scope.Token).Forget();      // the token is canceled before the scope disposes its services
    }

    private void OnDamaged(PlayerDamaged message)
    {
        m_health.SetValue(Math.Max(0, m_health.Value - message.Amount));
    }

    private async OnityTaskVoid RegenerateAsync(CancellationToken token)
    {
        while (!token.IsCancellationRequested)
        {
            await OnityTask.Delay(k_regenerationInterval, token);
            m_health.SetValue(Math.Min(k_maxHealth, m_health.Value + 1));
        }
    }
}
// HealthHud.cs: a thin MonoBehaviour. Unity constructs it, so the context injects members.
using System.Threading;
using Onity.DI;                 // Inject
using Onity.Factory;            // IFactory<T>
using Onity.Pooling;            // IPool<T>
using Onity.Reactive;           // IReadOnlyReactiveProperty<T>, Where
using Onity.Unity;              // OnityEvent
using Onity.Unity.Async;        // OnityTask, OnityTaskVoid, GetScopeCancellationToken
using Onity.Unity.Reactive;     // TakeUntilDisable
using UnityEngine;

public sealed class HealthHud : MonoBehaviour
{
    private const float k_hitMarkerSeconds = 0.5f;

    // Unity constructs MonoBehaviours, so the context injects members instead of a constructor.
    [Inject] private IReadOnlyReactiveProperty<int> m_health;
    [Inject] private IFactory<HitMarker> m_hitMarkers;
    [Inject] private IPool<HitMarker> m_hitMarkerPool;

    private void OnEnable()
    {
        m_health.Subscribe(value => Debug.Log($"Health: {value}"))   // emits the current value first
                .TakeUntilDisable(this);

        OnityEvent.Observe<PlayerDamaged>(this)                       // the channel HealthService subscribes to
                  .Where(message => message.Amount >= 10)
                  .Subscribe(ShowHit)
                  .TakeUntilDisable(this);
    }

    private void ShowHit(PlayerDamaged message)
    {
        HitMarker marker = m_hitMarkers.Create();                     // taken from the pool
        marker.Show(message.Amount);
        ReleaseLaterAsync(marker, this.GetScopeCancellationToken()).Forget();
    }

    private async OnityTaskVoid ReleaseLaterAsync(HitMarker marker, CancellationToken token)
    {
        await OnityTask.Delay(k_hitMarkerSeconds, token);             // ends with the scope
        m_hitMarkerPool.Release(marker);                              // back to the pool, exactly once
    }
}
// HitMarker.cs: the pooled prefab. PrefabComponentPool<T> activates it, then calls OnPoolGet; OnPoolRelease, then deactivates.
using Onity.Pooling;            // IPoolHooks
using UnityEngine;

public sealed class HitMarker : MonoBehaviour, IPoolHooks
{
    public void Show(int amount)
    {
        // Position the marker and display the amount.
    }

    public void OnPoolGet()
    {
        // Reset per-use state when the pool hands this instance out.
    }

    public void OnPoolRelease()
    {
        // Clear references when the instance returns to the pool.
    }
}
// DamageZone.cs: the publisher.
using Onity.Unity;              // OnityEvent
using UnityEngine;

public sealed class DamageZone : MonoBehaviour
{
    [SerializeField] private int m_damage = 25;

    private void OnTriggerEnter(Collider other)
    {
        OnityEvent.Publish(this, new PlayerDamaged(m_damage));   // the nearest context's channel
    }
}

Scene wiring: create an empty GameObject SceneContext, add the SceneContext component (search for it in Add Component; there is no Onity submenu) and the GameInstaller component, drag GameInstaller into the context’s Installers list, assign the HitMarker prefab and a root transform, and place HealthHud and DamageZone under the SceneContext object so the context injects them in Awake. The context builds the container in Awake (which runs HealthService.Initialize), injects the hierarchy, runs the async build in Start, pumps the lifecycle ticks, and disposes the container in OnDestroy, which cancels the scope token first.

The service runs without a scene. DeclareMessage<T> stands in for the broker channel a context would bind, and binding the container as IOnityScopeLifetime gives the service the same scope it gets from a context:

// HealthServiceTests.cs: an EditMode test.
using NUnit.Framework;
using Onity.Composition;        // BindReactiveProperty, DeclareMessage
using Onity.DI;
using Onity.Messaging;
using Onity.Reactive;

public sealed class HealthServiceTests
{
    [Test]
    public void Damage_lowers_health()
    {
        using OnityContainer container = new OnityContainer();
        ReactiveProperty<int> health = container.BindReactiveProperty(initialValue: 100);
        MessageChannel<PlayerDamaged> damage = container.DeclareMessage<PlayerDamaged>();  // standalone channel; a context would bind the broker's
        container.BindInstance<IOnityScopeLifetime>(container);                              // what a context binds for you
        container.BindInterfacesAndSelfTo<HealthService>().AsSingle();
        container.Build();                                                                    // constructs HealthService and runs Initialize()

        damage.Publish(new PlayerDamaged(30));

        Assert.AreEqual(70, health.Value);
    }   // Dispose cancels the scope token, which ends the regeneration loop
}

5.1 Context scoping: project versus scene

Put project-scope services on the ProjectContext prefab, not on a SceneContext. Anything that must live for the whole session and survive scene loads (catalogs, saves, currency, inventory, settings, audio, scene flow) belongs in an installer on the auto-loaded ProjectContext. Per-scene collaborators (a match’s rules, presentation and spawn factories, per-screen controllers) belong on that scene’s SceneContext. A SceneContext is created on every scene load, so a session-wide singleton bound there is rebuilt per scene and does not persist. A scene installer depends on project services without rebinding them; the parent chain resolves them.

Context Lifetime Execution order Parent it resolves Use for
ProjectContext one persistent instance (ProjectContext.Instance, DontDestroyOnLoad) -10000 none session-wide services
SceneContext rebuilt per scene load -9800 its Project Context field, else ProjectContext.Instance per-scene services
GameObjectContext lives with its GameObject subtree -9700 the nearest OnityContext above it (Find Parent In Hierarchy), else ProjectContext.Instance (Fallback To Project Context) a sub-scope for one prefab or subtree

Every context binds the container, IResolver, IOnityScopeLifetime, itself, MessageBroker with its interfaces and OnityEventHub, runs the MonoInstaller components in its Installers list, builds, and member-injects the MonoBehaviours under its own GameObject (Auto Inject Hierarchy, on by default). Run Async Build Callbacks (on by default) runs BuildAsync in Start. ProjectContext is loaded before the first scene by ProjectContextBootstrap from Resources/Onity/ProjectContext when none exists; create the prefab once with Onity/Contexts/Create ProjectContext Prefab (it is saved at Assets/Resources/Onity/ProjectContext.prefab), then add the project installers to its Installers list. A missing prefab is silent and leaves ProjectContext.Instance null.

ProjectContext (Assets/Resources/Onity/ProjectContext.prefab)
    Installers: [ProjectInstaller]                 // persists across scenes
SceneContext
    Installers: [GameInstaller]                    // per scene; resolves the project services above

6. DO and DON’T

DO:

  • DO put domain logic in plain C# services with constructor injection; keep MonoBehaviours thin, with [Inject] members.
  • DO finish every binding with AsSingle(), AsScoped() or AsTransient() (or FromSubContainerResolve); Bind<T>() and To<C>() alone register nothing.
  • DO retain every Subscribe result: TakeUntilDisable(this) for subscriptions made in OnEnable, AddTo(this) for ones that live until destroy, AddTo(scope) in a scope service, AddTo(compositeDisposable) elsewhere.
  • DO use BindInterfacesAndSelfTo<C>().AsSingle() to share one instance across a concrete type and its interfaces.
  • DO model current state as a ReactiveProperty<T> (bound with BindReactiveProperty) and past-tense notifications as messages (section 9).
  • DO give every async loop a token from its owner: IOnityScopeLifetime.Token in a service, GetScopeCancellationToken() or GetCancellationTokenOnDestroy() in a component. Use async OnityTaskVoid or Forget(handler) for fire-and-forget work.
  • DO pass an OnityTimeProvider (for example OnityTimeProviders.UpdateScaled) to Debounce, Throttle, ThrottleLast and Buffer in gameplay.
  • DO assign OnityObservableExceptionHandler.Handler at startup (for example Debug.LogException); without it a subscriber exception inside Subject<T>.OnNext is swallowed.
  • DO spawn reusable objects through a pool (BindPooledFactory: IFactory<T> to spawn, IPool<T> to release; OnityObjectPool<T> for plain C#) instead of Instantiate and Destroy in gameplay loops.
  • DO use AsScoped() for one instance per resolving scope, and WithId or WhenInjectedInto for a second implementation of one contract.
  • DO change scenes through one IOnitySceneService bound on the ProjectContext with container.BindSceneService(...), and check the awaited OnitySceneResult: Busy, InvalidTarget and ReadinessTimedOut are results, not exceptions. The destination reads its enter data with OnitySceneTransitionStore.TryGetEnterData<T>(gameObject.scene, out T) while it installs.

DON’T:

  • DON’T add bindings after Build(); installers run before it. Only Unbind and Rebind change a built scope, and build callbacks throw after the build.
  • DON’T Resolve<T>() inside Update, FixedUpdate or LateUpdate; inject once and keep the reference.
  • DON’T new a service that has dependencies; bind it and let the container construct it (ONITY006).
  • DON’T expect two separate Bind<I>().To<C>() calls to share one instance.
  • DON’T hand-build registries when collection injection fits; bind each implementation and request a collection shape.
  • DON’T resolve an open generic definition; bind the open definition and resolve a closed form.
  • DON’T add a second [Inject] constructor, a setterless [Inject] property, an [Inject] indexer or a generic [Inject] method; each throws OnityBindingException.
  • DON’T apply operators to an IReadOnlyReactiveProperty<T>; it is not an IOnityObservable<T>. Inject the ReactiveProperty<T> or wrap it.
  • DON’T expect OnityEvent or OnityEventHub to reach a DeclareMessage<T> channel; use BindMessageChannel<T> for a shared channel.
  • DON’T touch UnityEngine members after SelectAwait, WhereAwait or a thread-pool operator without ObserveOnMainThread() first; DON’T publish to a broker or Subject<T> from a background thread.
  • DON’T let a message handler throw unless every later handler for that message may be skipped; the exception propagates out of Publish.
  • DON’T release a pooled item twice, use it after Release, or Destroy it; release it exactly once (10.5). DON’T assume the container disposes a pool you built; BindPooledFactory(prefab, ...) pools are scope-owned, pools you pass in are yours until pool.AddTo(container) (10.4).
  • DON’T bind the scene service, OnityCoveredSceneTransition or OnityScenePreloader on a SceneContext: the scene that asks is the one a Single load unloads. DON’T unload by scene name when two instances can exist; pass the Scene an OnitySceneResult returned. DON’T start the next scene load from a State callback; start it from Completed or after the awaited result.
  • DON’T write async void; DON’T await a pooled OnityTask twice without Preserve().
  • DON’T use System.Linq in package code; write plain loops and avoid allocations in hot paths.
  • DON’T call an API that is not in this guide or the source on the assumption of Zenject, VContainer, R3, UniRx, MessagePipe or UniTask parity: no Instantiate(args), no fluent factory bodies, no Window, Zip, Switch, Concat, Publish, Share or RefCount on IOnityObservable<T> (the async streams have Zip, Concat, Publish and Queue), no buffered or replay messages, no handler priority, no request-response.

7. Public-API index (per module)

Onity.Core (engine-free)

  • Unit (readonly struct; Unit.Default), Lifetime (enum: Singleton, Transient), DisposableAction (an IDisposable wrapper; DisposableAction.Empty).

Onity.DI (engine-free)

  • OnityContainer (IResolver, IOnityScopeLifetime, IDisposable; new OnityContainer(OnityContainer parent = null)): Bind<T>(), Bind(Type), BindInterfacesAndSelfTo<T>(), BindInterfacesTo<T>(), BindInstance<T>(instance), BindInstance<T>(instance, id), BindFactory<TValue, TFactory>() and the one- and two-parameter forms, Rebind<T>(id = null), Rebind(Type, id = null), Unbind<T>(id = null), Unbind(Type, id = null), Resolve<T>(), Resolve<T>(id), Resolve(Type), Resolve(Type, id), TryResolve<T>(out T), TryResolve<T>(id, out T), TryResolve(Type, out object), TryResolve(Type, id, out object), Inject(object), CanResolve(Type), RegisterBuildCallback(Action<IResolver>), RegisterBuildCallbackAsync(Func<IResolver, Task>) and (Func<IResolver, CancellationToken, Task>), Build(), BuildAsync(CancellationToken = default), Tick(), FixedTick(), LateTick(), LifetimeToken, IsDisposed, Dispose(), PushBindingSource(string), TryGetBindingSource(...), TryGetLocalBindingSource(...), GetDiagnostics(), GetBindingDiagnostics(List<OnityBindingDiagnostics>); static DiagnosticsCollectionEnabled, IsCompiledActivationSupported.
  • IResolver: Resolve<T>(), Resolve<T>(id), Resolve(Type), Resolve(Type, id), TryResolve<T>, TryResolve<T>(id, out T), TryResolve(Type, ...), Inject.
  • TypeBindingBuilder<TContract> (To<TConcrete>(), WithId(id), WhenInjectedInto<TConsumer>(), AsSingle(), AsScoped(), AsTransient(), FromSubContainerResolve(install), NonLazy()); RuntimeTypeBindingBuilder (the same with To(Type)); MultiTypeBindingBuilder (WithId, WhenInjectedInto, AsSingle(), AsScoped(), AsTransient(), NonLazy()).
  • InjectAttribute ([Inject], [Inject(Id = ...)]; targets Constructor, Field, Property, Method, Parameter).
  • IOnityScopeLifetime (Token, IsDisposed), OnityScopeLifetimeExtensions.AddTo(disposable, scope).
  • IOnityInitializable, IOnityAsyncInitializable (ValueTask InitializeAsync(CancellationToken)), IOnityTickable, IOnityFixedTickable, IOnityLateTickable.
  • OnityGenerateActivatorAttribute ([OnityGenerateActivator], for the source generator).
  • OnityResolveException, OnityBindingException; diagnostics structs OnityContainerDiagnostics, OnityBindingDiagnostics, OnityBindingSourceInfo.

Onity.Factory (engine-free)

  • IFactory<out TValue> (TValue Create()), IFactory<in TParam, out TValue> (TValue Create(TParam param)), IFactory<in TParam1, in TParam2, out TValue> (TValue Create(TParam1 param1, TParam2 param2)). Bound with OnityContainer.BindFactory<...>(); the shipped implementations are the PooledFactory<...> adapters in Onity.Pooling.

Onity.Reactive (engine-free)

  • IOnityObservable<T> (Subscribe(Observer<T>), Subscribe(OnityObserver<T>)); Observer<T> (delegate void(T)); OnityObserver<T> (abstract observer object); OnityResult (struct).
  • Subject<T> (Subscribe, OnNext, Dispose); ReactiveProperty<T> (Value, SetValue(T) -> bool, Subscribe(observer, emitCurrentValue = true), Dispose; constructor (T initialValue = default, IEqualityComparer<T> comparer = null)); IReadOnlyReactiveProperty<T> (Value, Subscribe(observer, emitCurrentValue = true); not an IOnityObservable<T>).
  • CompositeDisposable (Add, Remove, Clear, Count, Dispose); OnityDisposableExtensions.AddTo(this IDisposable, CompositeDisposable).
  • OnityObservable<T> (new OnityObservable<T>(Func<Observer<T>, IDisposable> subscribe)) and the static OnityObservable: FromEvent<T>, Return<T>, Empty<T>.
  • OnityObservableExtensions: Where, Select, DistinctUntilChanged, Skip, SkipWhile, Take, TakeWhile, StartWith, Scan, Pairwise, Merge, CombineLatest, Sample, Throttle, Buffer(count), Buffer(timeSpan), ObserveOn, ObserveOnThreadPool, SelectOnThreadPool, Subscribe(Action<T>), Subscribe(onNext, onError, onCompleted), TakeUntilCancellation, FirstAsync, ToTask.
  • OnityObservableAsyncExtensions: Debounce, ThrottleLast, TakeUntil(CancellationToken), TakeUntil(Task), SelectAwait, WhereAwait.
  • OnityTimeProvider (abstract; OnityTimeProvider.System), OnityFrameProvider (abstract), OnityObservableExceptionHandler.Handler (static Action<Exception>, no-op by default), OnityObservableTracker (EnableTracking, EnableStackTrace, GetSnapshot, ClearDisposed, ClearAll), OnityReactiveException (declared; not thrown by the shipped operators).

Onity.Messaging (engine-free)

  • IMessageBroker (GetPublisher<T>(), GetSubscriber<T>()); IPublisher<T> (Publish(T)); ISubscriber<T> (Subscribe(MessageHandler<T>) -> IDisposable); MessageHandler<T> (delegate void(T)).
  • MessageBroker (IMessageBroker, IDisposable; ChannelCount, GetDiagnostics(List<MessageChannelDiagnostics>)); MessageChannel<T> (IPublisher<T>, ISubscriber<T>, IDisposable; SubscriberCount); MessageChannelDiagnostics (MessageType, SubscriberCount).
  • KeyedMessageChannel<TKey, TMessage> (IKeyedPublisher<TKey, TMessage>, IKeyedSubscriber<TKey, TMessage>, IDisposable; Publish(key, message), Subscribe(key, handler), KeyCount, GetSubscriberCount(key)).
  • AsyncMessageChannel<T> (IAsyncPublisher<T>, IAsyncSubscriber<T>, IDisposable; PublishAsync(message, ct) -> ValueTask, Subscribe(Func<T, CancellationToken, ValueTask>), SubscriberCount).
  • MessageBrokerExtensions: Publish<T>(this IMessageBroker, T), Subscribe<T>(this IMessageBroker, MessageHandler<T>).
  • OnityMessagingException (declared; the shipped channels throw the standard exceptions in 8.3).

Onity.Composition (engine-free)

  • BindReactiveProperty<T>(initialValue) -> ReactiveProperty<T> (binds ReactiveProperty<T> and IReadOnlyReactiveProperty<T>), BindSubject<T>() -> Subject<T>, DeclareMessage<T>() -> MessageChannel<T> (binds MessageChannel<T>, IPublisher<T>, ISubscriber<T>; a standalone channel), DeclareAsyncMessage<T>() -> AsyncMessageChannel<T> (binds AsyncMessageChannel<T>, IAsyncPublisher<T>, IAsyncSubscriber<T>). The scope disposes each primitive. BindAsyncReactiveProperty<T>(initialValue) is the Onity.Unity.Async counterpart (11.4).

Onity.Pooling (references UnityEngine; only PrefabComponentPool uses engine types)

  • IPool<T> (T Get(), void Release(T item), void Clear()); IParameterizedPool<T> : IPool<T> (T Get<TParam>(TParam param, Action<T, TParam> initialize), T Get<TParam1, TParam2>(TParam1 param1, TParam2 param2, Action<T, TParam1, TParam2> initialize); initialize runs before the get hooks and activation); IPoolHooks (OnPoolGet(), OnPoolRelease(); called only by PrefabComponentPool<T>).
  • OnityObjectPool<T> (T : class; IParameterizedPool<T>, IDisposable, IOnityPoolDiagnosticsSource): (Func<T> createFunc, Action<T> actionOnGet = null, Action<T> actionOnRelease = null, Action<T> actionOnDestroy = null, bool collectionCheck = false, int defaultCapacity = 16, int maxSize = 1024, string diagnosticsName = null, int initialSize = 0, bool fixedSize = false); Get(), Get<TParam>(...), Get<TParam1, TParam2>(...), TryGet(out T item), TryGet<TParam>(TParam param, Action<T, TParam> initialize, out T item), TryGet<TParam1, TParam2>(TParam1 param1, TParam2 param2, Action<T, TParam1, TParam2> initialize, out T item) (false, with a null item, only when a fixed-size pool has every item checked out), Release(T), Prewarm(int count), Clear(), Dispose(), GetDiagnosticsSnapshot(); int CountAll, CountActive, CountInactive (created, checked out, waiting).
  • PrefabComponentPool<TComponent> (TComponent : Component; the same interfaces and members): (TComponent prefab, Transform parent = null, int defaultCapacity = 16, int maxSize = 512, string diagnosticsName = null, int initialSize = 0, bool fixedSize = false, Action<TComponent> actionOnCreate = null); actionOnCreate runs once per new clone, under parent and still inactive, before its first Awake and OnEnable.
  • PooledFactory<TValue> ((IPool<TValue> pool)), PooledFactory<TParam, TValue> ((IParameterizedPool<TValue> pool, Action<TValue, TParam> initialize)), PooledFactory<TParam1, TParam2, TValue> ((IParameterizedPool<TValue> pool, Action<TValue, TParam1, TParam2> initialize)); a null argument throws ArgumentNullException.
  • OnityPoolDiagnosticsRegistry (Register, Unregister, GetSnapshots(List<OnityPoolDiagnosticsSnapshot>)), IOnityPoolDiagnosticsSource (GetDiagnosticsSnapshot()), OnityPoolDiagnosticsSnapshot (PoolName, PoolType, ItemType, CountAll, CountActive, CountInactive, GetCount, ReleaseCount, IsDisposed).

Onity.Unity (UnityEngine)

  • OnityEvent (Onity.Unity): Publish<T>(message), Publish<T>(owner, message), Subscribe<T>(handler), Subscribe<T>(owner, handler), Observe<T>(), Observe<T>(owner), GetEventHub(), GetEventHub(owner), TryGetEventHub(out hub), TryGetEventHub(owner, out hub).
  • Contexts (Onity.Unity.Contexts): OnityContext (abstract; Container, LifetimeToken, IsReady, ReadyTask, WaitReadyAsync(ct), InjectGameObject(root)), ProjectContext (Instance), SceneContext, GameObjectContext, ProjectContextBootstrap (ResourcePath).
  • Installers (Onity.Unity.Installers): MonoInstaller (abstract; InstallBindings(OnityContainer)); BindScriptableObject<TContract>(asset), BindScriptableObject<TContract, TAsset>(asset); BindPooledFactory<TComponent>(prefab, parent = null, defaultCapacity = 16, maxSize = 512), BindPooledFactory<TValue>(IPool<TValue> pool) (each binds IPool<T> as an instance and IFactory<T> as a PooledFactory<T> singleton; the scope disposes the pool the prefab overload creates; a null prefab or pool throws OnityBindingException); BindUiResolverBridge().
  • Messaging (Onity.Unity.Messaging): OnityEventHub (Publish<T>, Subscribe<T>, Observe<T>()); OnityMessageReactiveExtensions.Observe<T>() on IMessageBroker and ISubscriber<T>; OnityMessageBindingExtensions.BindMessageChannel<T>(this OnityContainer); OnityEventComponentExtensions (Publish, Subscribe, Observe, GetEventHub on Component).
  • Reactive (Onity.Unity.Reactive): OnityUnityObservable.EveryUpdate / EveryFixedUpdate / EveryLateUpdate (plus CancellationToken and OnityUnityThreadMode overloads), Timer(float, useUnscaledTime = false), Interval(float, useUnscaledTime = false); OnityUnityObservableExtensions.ObserveOnMainThread(), ObserveOnMainThread(OnityUnityFrameProvider), Delay<T>(seconds, useUnscaledTime = false); ReactiveLifetimeExtensions.AddTo(this IDisposable, Component), TakeUntilDestroy(this IDisposable, Component), TakeUntilDisable(this IDisposable, Behaviour); OnityFrameProviders (Update, FixedUpdate, LateUpdate); OnityTimeProviders (UpdateScaled, UpdateUnscaled, UpdateRealtime, FixedScaled, FixedUnscaled, FixedRealtime, LateScaled, LateUnscaled, LateRealtime); the timers OnityCountdownTimer, OnityIntervalTimer, OnityStopwatchTimer (IOnityTimer); OnityLifetimeNotifier.
  • Input (Onity.Unity.Input, with ENABLE_INPUT_SYSTEM): InputAction.StartedAsObservable(ct), PerformedAsObservable(ct), CanceledAsObservable(ct); OnityReactiveInputPlayer (GetButtonObservable, GetVector2Observable, GetFloatObservable, GetLongPressObservable, GetLongPressProgressObservable, PushContext, PopContext, SetContext, ClearContexts).
  • UI Toolkit presenters (Onity.Unity.UI): OnityUiResolverBridge, OnityUiPresenter<TView>, IOnityUiPresenter, OnityUiPresenterFactory, OnityUiServiceLocator, OnityUiInjectAttribute.
  • Physics (Onity.Unity.Physics): OnityNonAllocPhysics, OnityRaycastCommandBatch. Scene flow (Onity.Unity.SceneFlow): OnitySceneFlow, OnitySceneFlowProfile, OnitySceneFlowStateId, OnitySceneFlowStateMachine, OnitySceneTransitionStore, OnitySceneInitiator, OnityBootstrapSceneInitiator, OnityLoadingSceneInitiator, OnityLoadingView, IOnitySceneEnterData; covered transitions and preloading (guide: Lifecycle and Scopes, Scene transitions): OnityCoveredSceneTransition (RunAsync(change, bool showCover, ct), RunAsync(change, IOnitySceneCover, ct) returning Task<bool>, false when another change runs), IOnitySceneCover, OnitySceneCoverView (derive and implement ApplyProgress), OnityScreenFadeView (color or image, OnityScreenFade.uxml), OnityIrisCoverView (circle wipe, Center, OnityIrisCover.uxml), IOnitySceneReadiness, OnityActiveSceneReadiness, OnitySceneRevealPolicy, OnityScenePreloader (PrepareAsync, TryStartAsync, static TryGetPrepareData<T>(Scene, out T), LoadStep), IOnityPreparedScene, OnitySceneScopes.TryFind(Scene, out SceneContext), OnitySceneVisuals.Hide(Scene); the scene service (guide: Lifecycle and Scopes, The scene service): IOnitySceneService / OnitySceneService (LoadAsync, UnloadAsync(Scene) returning OnityTask<OnitySceneResult>, ActiveScene, IsBusy, State, Completed), bound once on the ProjectContext with container.BindSceneService(profile, defaultCover, preloader, revealPolicy); OnitySceneRequest (Single, Additive(scene, data, setActive), WithCover, WithoutCover, WithDefaultCover, WithPreparedScene), OnitySceneCoverMode, OnitySceneResult, OnitySceneResultStatus, OnitySceneOperationState, OnitySceneOperationPhase, and OnitySceneTransitionStore.TryGetEnterData<T>(Scene, out T) for the destination’s enter data. A busy service returns Busy instead of queueing; a readiness timeout is never Succeeded.
  • Async (Onity.Unity.Async; the full index is 11.7): OnityTask, OnityTask<T>, OnityTaskVoid, OnityPlayerLoopTiming, OnityTaskPlayerLoop, OnityYieldAwaitable, OnityDelayType, OnityPlayerLoopTimer, OnityTimeoutController, OnityTaskCompletionSource(<T>), OnityAutoResetTaskCompletionSource(<T>), OnityAsyncLazy(<T>), OnityTaskScheduler, OnityTaskTracker, IOnityAsyncEnumerable<T>, OnityAsyncEnumerable, OnityAsyncEnumerableLinq, OnityChannel, OnityAsyncReactiveProperty<T>, the triggers (Onity.Unity.Async.Triggers), UnityEvent, UI Toolkit and optional uGUI (Onity.Unity.UGUI) events.
  • DOTS (Onity.DOTS, with ONITY_ENTITIES): the Onity system groups, OnityDotsIntEventBridge, OnityDotsIntEventAsync, OnityDotsSessionBridge, OnityDotsPoolEntityUtils, OnityPoolableTag, OnityPooledEventTag.

8. Error to fix

What the runtime throws and how to fix it. DI uses the dedicated OnityResolveException and OnityBindingException. Reactive and messaging declare OnityReactiveException and OnityMessagingException, but the shipped code throws the standard .NET exceptions listed in 8.3; do not catch only the Onity types. A subscriber that throws inside Subject<T>.OnNext is routed to OnityObservableExceptionHandler.Handler (a no-op until you assign one) and the other subscribers are still notified; a message handler that throws propagates out of Publish and skips the remaining handlers.

8.1 OnityResolveException (resolve and inject)

Message contains Cause Fix
Could not resolve service type '<T>'. It is an unbound interface. (or ...an unbound abstract type., ...an open generic type definition and cannot be resolved directly.) Resolving an unbound interface, abstract class or open generic definition, or a concrete class with such a dependency. Bind<T>().To<Impl>().AsSingle() (or another lifetime). Abstractions never auto-resolve; only unbound concrete classes do, as transients.
Circular dependency detected while creating '<T>'. Resolution chain: ... A constructor or member-injection cycle, detected at resolve time. Inject IFactory<...> or IResolver on one side and resolve lazily, or split the type.
Failed to instantiate '<T>' using constructor '<ctor>'. Error: <inner> The selected constructor threw, or a parameter was unresolvable (a runtime value such as an int or string parameter). Read the inner error; fix the constructor, bind the missing type, or move the runtime value into a factory (2.5).
Cannot resolve a null service type. Resolve(null), TryResolve(null, ...) or CanResolve(null). Pass a real Type.
Cannot inject into a null target. Inject(null). Pass the instance to inject into.
Container has already been disposed. Resolve or inject after Dispose(). Resolve before disposing; never reuse a disposed scope.
Multiple conditional bindings for '<T>' with identifier '<id>' match '<consumer>'. Two WhenInjectedInto bindings of one contract both match the consumer. Keep one matching condition per consumer, or shadow with an unconditional binding in the child scope.

8.2 OnityBindingException (binding and configuration)

Message contains Cause Fix
Cannot bind a null instance. BindInstance<T>(null). Pass a non-null instance.
Implementation '<Impl>' does not satisfy contract '<C>'. To<Impl>() where Impl is not assignable to the contract. Use an implementation of the contract.
Implementation type '<Impl>' must be a concrete class. The To<> target is abstract or an interface. Point To<> at a concrete class.
Type '<Impl>' does not implement any interfaces. BindInterfacesTo<Impl>() on a type without interfaces. Use BindInterfacesAndSelfTo<Impl>(), or add an interface.
Contract type list cannot be empty. / Contract type cannot be null. A binding built with no or null contracts. Use the public Bind* entry points.
Type '<T>' has no accessible constructor... No constructor the container can call. Add one; a public constructor is preferred.
Type '<T>' contains multiple [Inject] constructors. More than one [Inject] constructor. Mark exactly one, or none (the public constructor with the most parameters is then selected).
[Inject] property '<P>' on '<T>' must have a setter. [Inject] on a get-only property. Add a (private) setter, or inject a field.
[Inject] property '<P>' on '<T>' cannot be an indexer. [Inject] on an indexer. Inject a non-indexed property, a field or a method parameter.
[Inject] method '<M>' on '<T>' cannot be generic. A generic [Inject] method. Make it non-generic with resolvable parameter types.
Build callbacks cannot be registered after container build has been finalized. RegisterBuildCallback or RegisterBuildCallbackAsync after Build(). Register callbacks in the installer, before Build().
Call AsSingle, AsScoped, or AsTransient before NonLazy. / Select a lifetime or FromSubContainerResolve before NonLazy. NonLazy() before the lifetime call. Call the lifetime first.
NonLazy requires an unconditional binding. NonLazy() on a WhenInjectedInto binding. Drop NonLazy() on the conditional binding.
NonLazy is not supported for open generic bindings; the closed type is unknown until resolve. NonLazy() on Bind(typeof(IRepo<>)). Resolve the closed form where it is needed.
Cannot bind a null ScriptableObject instance. BindScriptableObject with an unassigned asset. Assign the asset on the installer.

8.3 Reactive and messaging (standard .NET exceptions)

Exception Cause Fix
ObjectDisposedException (Subject<T>, ReactiveProperty<T>, MessageChannel<T>, MessageBroker) OnNext, SetValue, Subscribe or Publish after Dispose(). Tie subscriptions to a lifetime (AddTo, TakeUntilDisable); stop publishing to a disposed source.
ArgumentNullException A null source, handler, predicate, selector, key, broker or owner component. Pass non-null delegates and sources.
ArgumentOutOfRangeException A negative count (Skip, Take, Buffer) or a non-positive interval or time span. Pass a count of 0 or more, and a positive interval.
InvalidOperationException from OnityEvent No active ProjectContext, SceneContext or GameObjectContext. Add a context to the scene, or inject OnityEventHub instead of using the static shortcut.
OperationCanceledException (FirstAsync, ToTask, TakeUntil, PublishAsync, the OnityTask bridges) The token was canceled before the value arrived. Normal cancellation, not a failure. Catch it where the flow starts; do not treat it as an error.
A handler’s own exception out of Publish A message handler threw; later handlers were skipped. Catch inside the handler when the others must still run.
Silence instead of an exception A subscriber threw inside Subject<T>.OnNext and OnityObservableExceptionHandler.Handler is unassigned. Assign a handler at startup (Debug.LogException).

8.4 Factories and pooling

Factory and pool resolution failures reuse OnityResolveException and OnityBindingException; the pool classes throw standard .NET exceptions.

Exception and message contains Cause Fix
OnityResolveException: Could not resolve service type '<IFactory type>'. It is an unbound interface. An IFactory<...> was injected but no BindFactory<...>() or BindPooledFactory(...) ran in this scope or an ancestor. Bind it in an installer of the same or a parent context.
OnityResolveException: Could not resolve service type '<IPool type>'. It is an unbound interface. (also IParameterizedPool<T>) IPool<T> was injected but nothing bound it. BindPooledFactory binds IPool<T> only, never IParameterizedPool<T>. Call BindPooledFactory(prefab, ...) or BindPooledFactory(pool); for IParameterizedPool<T> add BindInstance<IParameterizedPool<T>>(pool).
OnityResolveException: Failed to instantiate '<YourType>' using constructor '<YourType>(System.Int32)'. Error: Type 'System.Int32' has no accessible constructor... (a string parameter reports Failed to instantiate 'System.String'...) A container-built class takes a runtime value as a constructor parameter. Move the value into an IFactory<TParam, TValue> and call Create(value).
OnityBindingException: Cannot bind pooled factory with a null prefab. / Cannot bind pooled factory with a null pool. BindPooledFactory received an unassigned or destroyed prefab, or a null pool. Assign the prefab on the installer, or pass a constructed pool.
InvalidOperationException: Item has already been returned to the pool. A second Release of an inactive item in an OnityObjectPool<T> created with collectionCheck: true. PrefabComponentPool<T> never throws this. Release each item exactly once; clear the stored release callback in OnPoolRelease or actionOnRelease.
InvalidOperationException: Fixed-size pool has no available items. / Fixed-size prefab pool has no available items. A fixedSize: true pool has all maxSize items checked out. Release items, raise maxSize, or drop fixedSize; where running out is expected, call TryGet and handle false.
ObjectDisposedException (object name: the pool’s diagnostics name) from Get, TryGet, Release, Clear, Prewarm or PooledFactory.Create The pool was disposed: by your code, by its scope ending (BindPooledFactory(prefab, ...) pools are scope-owned), or you hold a pool from a finished scope. Return checked-out items before the scope ends; resolve from a live scope.
ArgumentOutOfRangeException: Pool capacity must be positive and initialSize must be within maxSize. (or from Prewarm) maxSize <= 0, an initialSize outside 0..maxSize, or a Prewarm count outside that range. Fix the capacity arguments; Prewarm(count) is a target total.
ArgumentNullException (createFunc, prefab, pool, initialize) A null create delegate, prefab, pool or initializer reached a pool or PooledFactory constructor or Get. Pass non-null arguments.

The unbound-interface wording is locked by OnityErrorMessageTests.cs; the pool exception types by PoolingTests.cs. The strings come from OnityContainer.cs, the binding builders, OnityObjectPool.cs, PrefabComponentPool.cs and MonoInstaller.cs.


9. Events decision rule: message, ReactiveProperty or direct call

Three ways for one part of the game to tell another that something happened. Pick by the shape of the information, not by habit:

Use When API Why
Message (OnityEvent, OnityEventHub, IPublisher<T> + ISubscriber<T>) A transient, fire-and-forget notification with zero or more decoupled listeners that only care about future occurrences (PlayerDamaged, PlayerDied, WaveStarted). OnityEvent.Publish(this, new PlayerDamaged(25)); / events.Subscribe<PlayerDamaged>(OnDamaged).AddTo(scope); Sender and receivers never reference each other. A late subscriber misses earlier messages by design; there is no replay or buffer.
ReactiveProperty<T> (shared through DI with BindReactiveProperty) Current state a new listener must know at once (health, score, current wave, connection status). container.BindReactiveProperty(initialValue: 100); / m_health.Subscribe(Show).TakeUntilDisable(this); Subscribing emits the current value first, then every real change; equal values are skipped. It is the only “replay the current value” primitive.
Direct service call (a constructor-injected interface) A command or query with exactly one owner, a return value, ordering, or a synchronous result (damage.Calculate(hit), save.Write(slot)). public CombatService(IDamageCalculator damage) { m_damage = damage; } A message or stream cannot return a value or guarantee a single handler.

Decision flow:

  1. Need a return value, or must exactly one thing handle it? Call the service. Stop.
  2. Is it the current value of some state a fresh subscriber must see right now? ReactiveProperty<T>. Stop.
  3. Otherwise (a past-tense notification, fan-out to unknown listeners, late subscribers may miss it): publish a message.

Any message type becomes a stream with Observe<T>(), so Where and Select apply to messages as they apply to a ReactiveProperty<T>; the canonical HealthService folds PlayerDamaged messages into the health property, and views subscribe to the property. Do not model current state with messages (new listeners would miss it), and do not model a one-shot command with a result as a property. Keyed routing is KeyedMessageChannel<TKey, TMessage>; buffered or replayed messages and request-response are not shipped. “The last message for a late subscriber” is a ReactiveProperty<T>.


10. Factories and pooling: Onity.Factory and Onity.Pooling

Contracts: IFactory<...> (Onity.Factory, engine-free), IPool<T>, IParameterizedPool<T> and IPoolHooks (Onity.Pooling). Implementations: OnityObjectPool<T> (plain C# items, T : class) and PrefabComponentPool<T> (prefab clones), both IParameterizedPool<T> and IDisposable; PooledFactory<...> adapts a pool to IFactory<...>. Choose the API with the table in 2.5. There is no MemoryPool<T> or PlaceholderFactory equivalent beyond these types, and ECS entity pooling (Onity.DOTS: OnityPoolableTag, OnityDotsPoolEntityUtils) is a separate system, not an IPool<T>.

10.1 Prefab pool: BindPooledFactory

container.BindPooledFactory(prefab, parent, defaultCapacity, maxSize) (Onity.Unity.Installers) creates a PrefabComponentPool<TComponent> and registers two bindings: IPool<TComponent> (the pool, as an instance) and IFactory<TComponent> (a PooledFactory<TComponent> singleton whose Create() is pool.Get()). Inject the factory to spawn and the pool to release. The canonical HitMarker (section 5) is the smallest form; the projectile below adds a stored release callback so the item can despawn itself.

// Recipe A: installer. Defaults are defaultCapacity 16 and maxSize 512.
using Onity.DI;                 // OnityContainer
using Onity.Unity.Installers;   // MonoInstaller, BindPooledFactory
using UnityEngine;

public sealed class ProjectileInstaller : MonoInstaller
{
    [SerializeField] private Projectile m_projectilePrefab;
    [SerializeField] private Transform m_projectileRoot;           // optional parent; keep it as long-lived as the pool

    public override void InstallBindings(OnityContainer container)
    {
        container.BindPooledFactory(m_projectilePrefab, m_projectileRoot, defaultCapacity: 32, maxSize: 256);   // the scope disposes this pool (10.4)
        container.Bind<ProjectileSpawner>().AsSingle();
    }
}
// Recipe B: spawner. IFactory<T> spawns, IPool<T> releases.
using System;
using Onity.Factory;            // IFactory<T>
using Onity.Pooling;            // IPool<T>
using UnityEngine;

public sealed class ProjectileSpawner
{
    private readonly IFactory<Projectile> m_factory;
    private readonly IPool<Projectile> m_pool;
    private readonly Action<Projectile> m_despawn;                 // cached once; a method-group conversion allocates each time

    public ProjectileSpawner(IFactory<Projectile> factory, IPool<Projectile> pool)
    {
        m_factory = factory;
        m_pool = pool;
        m_despawn = Despawn;
    }

    public Projectile Spawn(Vector3 position, Vector3 velocity)
    {
        Projectile projectile = m_factory.Create();                // active; OnPoolGet has already run
        projectile.transform.position = position;
        projectile.Launch(velocity, m_despawn);
        return projectile;
    }

    public void Despawn(Projectile projectile)
    {
        m_pool.Release(projectile);                                // exactly once per Spawn
    }
}
// Recipe C: pooled component. IPoolHooks carries the reset logic.
using System;
using Onity.Pooling;            // IPoolHooks
using UnityEngine;

public sealed class Projectile : MonoBehaviour, IPoolHooks
{
    private Action<Projectile> m_despawn;
    private Vector3 m_velocity;

    public void Launch(Vector3 velocity, Action<Projectile> despawn)
    {
        m_velocity = velocity;
        m_despawn = despawn;
    }

    public void OnHit()
    {
        m_despawn?.Invoke(this);                                   // no-op once released: guards a double release
    }

    public void OnPoolGet()
    {
        // Runs after SetActive(true). Setup that needs the live object belongs here.
    }

    public void OnPoolRelease()
    {
        // Runs before SetActive(false). Reset all per-use state here.
        m_velocity = Vector3.zero;
        m_despawn = null;
    }
}

Call order:

Operation PrefabComponentPool<T> OnityObjectPool<T>
Get() / factory.Create() take an inactive clone, else instantiate one (inactive); SetActive(true); IPoolHooks.OnPoolGet() take an inactive item, else createFunc(); actionOnGet
Get(param, initialize) / PooledFactory<TParam, ...>.Create(param) take or instantiate; initialize(item, param); SetActive(true); OnPoolGet() take or create; initialize(item, param); actionOnGet
Release(item) OnPoolRelease(); SetActive(false); keep it, or destroy it when maxSize inactive items are already held actionOnRelease; keep it, or actionOnDestroy when maxSize inactive items are already held

10.2 Parameterized pooled spawn

BindPooledFactory(prefab) binds only the zero-argument IFactory<T>. For Create(position), choose by when the component must see the argument:

  • A wrapper IFactory<TParam, T> over IPool<T> works with BindPooledFactory(prefab) as it is: you set state after Get() returns, so activation, OnEnable and OnPoolGet have already run without it.
  • PooledFactory<TParam, T> (and the two-parameter form) over an IParameterizedPool<T>: initialize(item, param) runs before activation and the get hooks on every use, including a clone’s first OnEnable. BindPooledFactory binds only IPool<T>, so build the pool yourself and bind the factory with BindInstance.
// Recipe D: wrapper factory (state is applied after activation).
using Onity.Factory;            // IFactory<TParam, TValue>
using Onity.Pooling;            // IPool<T>
using UnityEngine;

public sealed class ProjectileAtPositionFactory : IFactory<Vector3, Projectile>
{
    private readonly IPool<Projectile> m_pool;

    public ProjectileAtPositionFactory(IPool<Projectile> pool)
    {
        m_pool = pool;
    }

    public Projectile Create(Vector3 position)
    {
        Projectile projectile = m_pool.Get();                      // already active: OnEnable and OnPoolGet have run
        projectile.transform.position = position;
        return projectile;
    }
}

// In the installer (Recipe A), next to the BindPooledFactory call:
// container.BindFactory<Vector3, Projectile, ProjectileAtPositionFactory>();     // IFactory<Vector3, Projectile>
// Recipe E: PooledFactory adapter (state is applied before activation and OnPoolGet). The pool is built here, so tie it to the scope.
using Onity.DI;                 // OnityContainer, AddTo(scope)
using Onity.Factory;            // IFactory<TParam, TValue>
using Onity.Pooling;            // PrefabComponentPool<T>, PooledFactory<TParam, TValue>
using Onity.Unity.Installers;   // MonoInstaller, BindPooledFactory
using UnityEngine;

public sealed class ProjectileInstaller : MonoInstaller
{
    [SerializeField] private Projectile m_projectilePrefab;
    [SerializeField] private Transform m_projectileRoot;

    public override void InstallBindings(OnityContainer container)
    {
        PrefabComponentPool<Projectile> pool = new PrefabComponentPool<Projectile>(
            m_projectilePrefab, m_projectileRoot, defaultCapacity: 32, maxSize: 256);
        pool.AddTo(container);                                          // caller-built pool: disposed with the scope only because of this (10.4)
        container.BindPooledFactory(pool);                              // IPool<Projectile> and IFactory<Projectile>
        container.BindInstance<IFactory<Vector3, Projectile>>(
            new PooledFactory<Vector3, Projectile>(
                pool,
                (projectile, position) => projectile.transform.position = position));   // runs before activation and OnPoolGet
    }
}

// Consumer: inject IFactory<Vector3, Projectile> and call Create(position).

With the adapter, OnPoolGet already sees the state initialize set, so do not clear those fields in OnPoolGet; reset them in OnPoolRelease (10.5).

10.3 Plain C# pool: OnityObjectPool

Use new OnityObjectPool<T>(...) for items that are not GameObjects (T : class). Parameters, with the real names and defaults:

Parameter Default Meaning
createFunc required creates an item when none is inactive (null throws ArgumentNullException)
actionOnGet null runs on every Get after the item is taken (never during prewarm)
actionOnRelease null runs on every Release, before the item is retained or destroyed; if it throws, the pool is unchanged and the release can be retried
actionOnDestroy null runs for items the pool drops: overflow above maxSize, Clear(), Dispose()
collectionCheck false true makes a duplicate Release of an inactive item throw (by reference, in the Editor and in players)
defaultCapacity 16 initial capacity of the inactive stack (it doubles up to maxSize)
maxSize 1024 most inactive items retained; the total cap when fixedSize is true; must be above 0
diagnosticsName null label in snapshots and the ObjectDisposedException object name; null gives OnityObjectPool<ItemTypeName>
initialSize 0 items created in the constructor without running hooks; 0..maxSize
fixedSize false true makes Get() throw InvalidOperationException when all maxSize items are checked out

PrefabComponentPool<T> takes (prefab, parent = null, defaultCapacity = 16, maxSize = 512, diagnosticsName = null, initialSize = 0, fixedSize = false, actionOnCreate = null); its default diagnosticsName is PrefabComponentPool<T>:<prefab name>. actionOnCreate runs once for each new clone (prewarmed ones too), under parent and still inactive, so before its first Awake and OnEnable; if it throws, the clone is destroyed and the exception propagates. Use it for setup a clone needs once; createFunc is that place in OnityObjectPool<T>. Prewarm(count) on either pool raises the created total to count (a target, not an increment) without running get or release hooks. TryGet(out item) and its one- and two-parameter forms return false, with a null item, when a fixed-size pool has every item checked out, where Get throws. CountAll, CountActive and CountInactive read the created, checked-out and waiting counts without allocating.

// Recipe F: a service that owns a plain pool, using every constructor option.
using System;
using Onity.Pooling;            // OnityObjectPool<T>

public sealed class PathNodePool : IDisposable
{
    private readonly OnityObjectPool<PathNode> m_pool;

    public PathNodePool()
    {
        m_pool = new OnityObjectPool<PathNode>(
            createFunc: () => new PathNode(),
            actionOnGet: node => node.Reset(),                          // every Get, after the item is taken
            actionOnRelease: node => node.Clear(),                      // every Release, before it is retained
            actionOnDestroy: node => node.Free(),                       // overflow above maxSize, Clear(), Dispose()
            collectionCheck: true,                                      // default false; true makes a duplicate Release throw
            defaultCapacity: 64,
            maxSize: 1024,
            diagnosticsName: "PathNodePool");
    }

    public PathNode Rent()
    {
        return m_pool.Get();
    }

    public void Return(PathNode node)
    {
        m_pool.Release(node);                                           // exactly once per Rent; never touch the node afterwards
    }

    public void Dispose()
    {
        m_pool.Dispose();
    }
}

// Binding choices (fragments):
// container.Bind<PathNodePool>().AsSingle();                      // the service owns the pool; the container disposes the IDisposable singleton
// IPool<PathNode> shared = new OnityObjectPool<PathNode>(() => new PathNode(), collectionCheck: true);
// container.BindPooledFactory(shared);                            // IPool<PathNode> and IFactory<PathNode>; caller-owned (10.4)

10.4 Lifetime and disposal

  • Creation: BindPooledFactory(prefab, ...) constructs the pool while the installer runs. No clone exists yet: the first Get or Prewarm instantiates clones under a hidden inactive root, then parents each one to parent.
  • Visibility follows the binding’s scope: IPool<T> is bound as an instance and PooledFactory<T> as a singleton in the container that ran the installer. Child scopes resolve the same pool and factory; a child that calls BindPooledFactory for the same T shadows them only inside that child. Two binds for one T in the same container: the last wins, and a pool that BindPooledFactory(prefab, ...) created is still disposed with the scope. A ProjectContext pool is shared by every scene scope; a SceneContext pool only by its scene.
  • Disposal follows who created the pool. BindPooledFactory(prefab, ...) creates its pool, so the scope owns it: OnityContainer.Dispose() (a context calls it from OnDestroy) cancels LifetimeToken first, which disposes the pool, so it leaves OnityPoolDiagnosticsRegistry and its inactive clones and hidden root are destroyed. A pool you build and pass in, with BindPooledFactory(pool) or BindInstance, stays caller-owned: the container never disposes bound instances, so the pool outlives the scope and stays registered until you dispose it. Singleton services that implement IDisposable are disposed by the container, after the pool.
  • Tie your own pool to the scope: bind it with BindPooledFactory(pool) and call pool.AddTo(container) (Onity.DI; an OnityContainer is an IOnityScopeLifetime; Recipe E), call pool.Dispose() when the scope ends, or hold it inside an IDisposable service bound with AsSingle() (Recipe F). To end a pool that BindPooledFactory(prefab, ...) created before its scope ends, cast the resolved IPool<T> to IDisposable; the scope’s later disposal then does nothing.
  • Dispose() is idempotent. It destroys inactive items only (actionOnDestroy; for prefab clones Destroy in play mode and DestroyImmediate otherwise, plus the hidden root), unregisters the pool, and leaves checked-out items untouched, so return them first: a scope-owned pool is disposed before the scope’s singletons, so a service’s own Dispose() can no longer release into it. Afterwards Get, TryGet, Release, Clear, Prewarm and every PooledFactory.Create throw ObjectDisposedException, and snapshots report zero counts with IsDisposed == true.
  • Parent lifetime: keep parent (and the scene it lives in) at least as long-lived as the pool. Unity destroys clones together with their parent, and PrefabComponentPool<T> does not check for destroyed items (10.5).

10.5 Documented behaviors: DO and DON’T

Evidence names tests (PoolingTests, EditMode unless marked PlayMode; * is a name stem) or source when only the code supports the row.

Behavior DO / DON’T Evidence
Release exactly once DO release every rented item once. With collectionCheck: true, a second Release of an inactive item throws InvalidOperationException (Item has already been returned to the pool.) in the Editor and in players. It compares references and rejects the call before the release hook runs or any counter changes. OnityObjectPool_CheckedReturns_*
Duplicate release is unchecked by default DON’T count on detection: collectionCheck defaults to false, and PrefabComponentPool<T> has no duplicate check. A repeated release then retains the same instance twice, so two later Get() calls return it. DO guard in the item (clear the stored release callback in OnPoolRelease, call it with ?.Invoke). source
Use after release DON’T read, write or keep a reference to an item after Release: the next Get may hand it to someone else, the prefab pool has deactivated it, and an item above maxSize (or still inactive at Clear() or Dispose()) is destroyed. OnityObjectPool_ReusesReleasedInstances, PrefabComponentPool_GetAndRelease_*
Release only what the pool issued DON’T release null or an item from another pool. Release does not validate origin: a released null can come back from a later Get(), and CountActive can go negative. source
Reset in hooks DO reset per-use state in OnPoolRelease or actionOnRelease (once per use, before deactivation), not in spawn code. Use OnPoolGet or actionOnGet only for setup that needs the live item and must not overwrite state a parameterized initialize already set. Prewarm (initialSize, Prewarm) runs no get hooks and suppresses release hooks. OnityObjectPool_PrewarmAndFixedCapacity_*, PrefabComponentPool_PrewarmAndParameterizedGet_*
One-time setup DO put setup an instance needs once (references it keeps for life, buffers it reuses) in createFunc or in the prefab pool’s actionOnCreate, not in a rent-all-then-release loop. actionOnCreate runs once per clone, prewarmed ones included, under parent and still inactive, so before its first Awake and OnEnable; a throwing actionOnCreate destroys that clone and propagates. PrefabComponentPool_ActionOnCreate*; PlayMode ActionOnCreate_RunsOnceBeforeFirstAwakeAndOnEnable
IPoolHooks scope DON’T put IPoolHooks on a plain item and expect OnityObjectPool<T> to call it: that pool runs only actionOnGet and actionOnRelease. PrefabComponentPool<T> calls IPoolHooks on the pooled component. source
Failing hooks A throwing get hook or parameterized initialize returns the item to the pool, skips the release hook, does not count the get, and rethrows. A throwing release hook leaves the item checked out: retry the same Release. *_GetHookFailure_*, TwoParameterPooledFactory_Recovers*, OnityObjectPool_InitializerFailure_*, OnityObjectPool_CheckedReturns_TrackRetriesOverflowAndClear
Never destroy a pooled item DON’T Destroy or DestroyImmediate a pooled instance, or destroy its parent or scene while the pool lives: release it. The pool keeps its reference, PrefabComponentPool<T>.Get has no destroyed-item check, and a clone destroyed while checked out never leaves CountActive. source
Capacity above maxSize maxSize caps retained inactive items. A release above it runs actionOnRelease, then actionOnDestroy, and CountAll drops. With fixedSize: true, maxSize is the total cap and Get() throws InvalidOperationException (Fixed-size pool has no available items. / Fixed-size prefab pool has no available items.) when every item is checked out. DO call TryGet where running out is expected: it returns false at that point without running hooks or initialize, instead of throwing; don’t keep your own active counter beside the pool, read CountActive. OnityObjectPool_CheckedReturns_TrackRetriesOverflowAndClear, OnityObjectPool_PrewarmAndFixedCapacity_*, *_TryGet_ReturnsFalseOnlyWhenFixedPoolIsExhausted
Clear() Clear() destroys inactive items only. Checked-out items stay active and tracked: both pools subtract only the destroyed ones from CountAll, so items checked out across a Clear() still count toward a fixed size. OnityObjectPool_Clear_*, OnityObjectPool_Counts_*, PrefabComponentPool_ClearWithCheckedOutInstance_*
Prefab activation PrefabComponentPool<T> runs SetActive(true) then OnPoolGet on Get, and OnPoolRelease then SetActive(false) on Release, so OnEnable and OnDisable run on every use. Clones start inactive: OnEnable first fires on the first Get, after any initialize. DON’T read runtime parameters in Awake. PrefabComponentPool_GetAndRelease_*; PlayMode ActivePrefab_FirstParameterizedGet_InitializesBeforeOnEnable
No container injection DON’T expect [Inject] members on a pooled clone to be filled: the prefab pool only instantiates it. Pass dependencies in on spawn (as Launch(...) does in Recipe C) or call IResolver.Inject(clone) yourself. source
Threading DON’T share a pool across threads. Get and Release take no lock (the diagnostic counters use Interlocked only so the Editor monitor can read them), and the prefab pool touches Unity objects. source
Pool ownership The scope disposes the pool BindPooledFactory(prefab, ...) creates. DO pool.AddTo(container) (or Dispose() it) for a pool you build and bind with BindPooledFactory(pool) or BindInstance: the container never disposes it. DON’T Release into a pool whose scope has ended: it throws ObjectDisposedException. BindPooledFactory_ContainerDispose_*, BindPooledFactory_CallerSuppliedPoolAddedToScope_*; PlayMode Destroying*Context_DisposesPoolCreatedByBindPooledFactory

10.6 Allocation and diagnostics

  • The EditMode pooling tests lock in that the warmed rent and return path of OnityObjectPool<T> (default and collectionCheck: true, through Get and TryGet), all three PooledFactory forms and a hook-less PrefabComponentPool<T> (Get and TryGet) records no GC.Alloc events. That covers the warmed path only: a Get that finds no inactive item runs createFunc or instantiates, Prewarm creates items, and the inactive stack grows (doubling, up to maxSize) once defaultCapacity is exceeded. Cache the delegates you pass in; converting a method group allocates each time. Measured comparisons with their conditions: DI vs VContainer and Zenject.
  • pool.GetDiagnosticsSnapshot() returns PoolName, PoolType, ItemType, CountAll, CountActive, CountInactive, GetCount, ReleaseCount and IsDisposed; OnityPoolDiagnosticsRegistry.GetSnapshots(list) lists every registered pool, and Onity/Tools/Pool Monitor reads that registry. Gameplay code that needs a count reads the CountAll, CountActive and CountInactive properties of the pool itself.

11. Async: OnityTask (Onity.Unity.Async)

OnityTask and OnityTask<T> replace UniTask. Use them for every Unity-facing async flow; keep Task only where a plain .NET API already returns it. Namespaces: Onity.Unity.Async (tasks, timings, composition, streams, channels, UI events) and Onity.Unity.Async.Triggers (lifecycle and MonoBehaviour message triggers).

Assembly references (CS0012): an asmdef that calls Onity.Unity.Async extension methods must reference Onity.Reactive as well as Onity.Unity (and Onity.Messaging for the messaging bridges). AsOnityAsyncEnumerable, BindTo, ToOnityTask and WaitAsync have overloads that use Onity.Reactive types, so without the reference the compiler reports CS0012 even when you call a different overload. An asmdef whose code touches IOnityObservable<Unit> (for example OnityUnityObservable.EveryUpdate()) must also reference Onity.Core, where Unit lives. The uGUI extensions also need Onity.Unity.UGUI and UnityEngine.UI.

11.1 Rules: DO and DON’T

Behavior DO / DON’T
Single consumer Pooled tasks (cancelable waits, timed waits, delays, WaitUntil, Unity operations, suspended async OnityTask methods) are single-consumer. DO await once; DO Preserve() once before sharing. DON’T read IsCompleted or Status after consuming: it throws InvalidOperationException.
Stateless waits In Play, NextFrame(), DelayFrames(n), NextFixedFrame(), NextLateFrame() and Yield() without a cancelable token rent nothing and may be awaited by any number of consumers. With a token they are pooled and single-consumer.
Yield() Returns OnityYieldAwaitable (converts implicitly to OnityTask). DO call .ToOnityTask() before an OnityTask extension (Yield().ToOnityTask().Timeout(1f)). DON’T write Yield(default); it is ambiguous. Use Yield(cancellationToken: ct).
Ownership DO give every loop and long wait a token from its owner: this.GetCancellationTokenOnDestroy() or this.GetScopeCancellationToken() in a component, IOnityScopeLifetime.Token in a scope service. DON’T start an unowned while (true) loop.
Fire and forget DO task.Forget(handler) or an async OnityTaskVoid method. DON’T write async void. Unobserved faults go to OnityTaskScheduler, which drops OperationCanceledException by default.
Threads Continuations run on the thread that completes the awaited operation. DO await OnityTask.SwitchToMainThread(ct) after RunOnThreadPool(..., returnToMainThread: false), SwitchToThreadPool or a worker producer before touching Unity APIs.
Timings OnityPlayerLoopTiming.Update, FixedUpdate and LateUpdate run after the script callbacks. UniTask’s PlayerLoopTiming.Update and FixedUpdate are UpdateBegin and FixedUpdateBegin.
Seconds OnityTask.Delay(2) waits two seconds. There is no integer-milliseconds overload; use Delay(TimeSpan.FromMilliseconds(ms)).
Composition WhenAll(a, b) with typed arguments returns a tuple (even for the same type); pass an array for T[]. WhenAny(a, b) typed returns (winArgumentIndex, result1, result2). WhenAny never cancels losers.
Context flow Off by default (UniTask semantics). Set OnityTask.FlowExecutionContext = true once at startup only if code needs AsyncLocal<T> across awaits.
Bursts The defaults keep 128 runners per async method (RunnerPoolCapacity) and 256 sources per source type (SourcePoolCapacity). For methods that run in bursts of thousands, raise both at startup; each operation above the cap allocates.
Null literals Select(null), Where(null), SelectAwait(null), WhereAwait(null) and ForEachAsync(null) on streams, AsOnityTask(null, ct) and new OnityChannelClosedException(null) are ambiguous: cast to the intended type.
Edit Mode Explicit-timing waits, Yield() and the timed overloads require an active Play or player session on the main thread. The float-seconds Delay, the token-only WaitUntil and WaitWhile, and the default frame waits also run in Edit Mode.

11.2 Which async primitive

You need Use
Wait frames, time or a condition OnityTask.NextFrame(ct), DelayFrames(n, ct), Delay(seconds, ct), WaitUntil(predicate, ct); timed forms take an OnityPlayerLoopTiming and OnityDelayType
A detached method async OnityTaskVoid or task.Forget(handler)
Complete from a callback, many awaiters OnityTaskCompletionSource<T>
Pooled one-shot handoff, one awaiter OnityAutoResetTaskCompletionSource<T>
A repeating callback on a timing OnityPlayerLoopTimer.StartNew(...)
A sequence of values pulled with await foreach IOnityAsyncEnumerable<T>: OnityAsyncEnumerable.Create, the timing streams, the LINQ operators
Producer and consumer with backpressure OnityChannel.CreateBounded<T>(capacity)
Current value plus async change notification OnityAsyncReactiveProperty<T>; for an existing ReactiveProperty<T>, WaitAsync and WaitUntilAsync
The next message from the bus subscriber.ReceiveAsync(ct); a buffered stream: ReceiveAllAsync(capacity, overflow); an async handler with backpressure: asyncSubscriber.SubscribeQueued(handler, capacity, token)
Work that ends with its DI scope IOnityScopeLifetime.Token, disposable.AddTo(scope); awaited startup: IOnityAsyncInitializable
MonoBehaviour messages triggers: GetAsync<Name>Trigger().<Message>Async(ct)
UI input UI Toolkit button.OnClickAsync(ct), field.OnValueChangedAsync(ct); uGUI the same names (Onity.Unity.UGUI)
Show a stream in UI stream.BindTo(textElement), stream.BindTo(target, (target, value) => ..., ct)
Synchronous fan-out to observers not async: Subject<T> or ReactiveProperty<T> (section 3), or a message (section 4)

11.3 Recipes

// Recipe A: a component flow owned by its destroy token.
using System;
using System.Threading;
using Onity.Unity.Async;        // OnityTask, Forget, OnityDelayType, OnityPlayerLoopTiming
using UnityEngine;

public sealed class DoorOpener : MonoBehaviour
{
    [SerializeField] private Transform m_door;

    private void Start()
    {
        OpenAsync(destroyCancellationToken).Forget(Debug.LogException);
    }

    private async OnityTask OpenAsync(CancellationToken ct)
    {
        await OnityTask.Delay(TimeSpan.FromSeconds(1), OnityDelayType.DeltaTime, OnityPlayerLoopTiming.Update, ct);
        while (m_door.localPosition.y < 2f)
        {
            m_door.localPosition += Vector3.up * Time.deltaTime;
            await OnityTask.NextFrame(ct);
        }
    }
}
// Recipe B: a scope service that waits on state and messages; ends with its context.
using System.Threading;
using Onity.DI;                 // IOnityInitializable, IOnityScopeLifetime
using Onity.Messaging;          // ISubscriber<T>
using Onity.Reactive;           // IReadOnlyReactiveProperty<T>
using Onity.Unity.Async;        // WaitUntilAsync, ReceiveAsync, OnityTask, OnityTaskVoid

public sealed class WaveDirector : IOnityInitializable
{
    private readonly IReadOnlyReactiveProperty<int> m_health;
    private readonly ISubscriber<PlayerDied> m_playerDied;
    private readonly IOnityScopeLifetime m_scope;

    public WaveDirector(IReadOnlyReactiveProperty<int> health, ISubscriber<PlayerDied> playerDied, IOnityScopeLifetime scope)
    {
        m_health = health;
        m_playerDied = playerDied;
        m_scope = scope;
    }

    public void Initialize()
    {
        RunAsync(m_scope.Token).Forget();
    }

    private async OnityTaskVoid RunAsync(CancellationToken token)
    {
        await m_health.WaitUntilAsync(value => value == 100, token);   // completes at once when already at full health
        PlayerDied message = await m_playerDied.ReceiveAsync(token);   // the next message; no subscription to dispose
        await OnityTask.Delay(2f, token);
    }
}
// Recipe C: a save queue: publishers wait only while 8 requests are pending.
using System.Threading;
using Onity.DI;                 // IOnityInitializable, IOnityScopeLifetime
using Onity.Messaging;          // IAsyncSubscriber<T>
using Onity.Unity.Async;        // SubscribeQueued, OnityTask

public sealed class SaveQueue : IOnityInitializable
{
    private readonly IAsyncSubscriber<SaveRequested> m_requests;
    private readonly IOnityScopeLifetime m_scope;

    public SaveQueue(IAsyncSubscriber<SaveRequested> requests, IOnityScopeLifetime scope)
    {
        m_requests = requests;
        m_scope = scope;
    }

    public void Initialize()
    {
        m_requests.SubscribeQueued(WriteAsync, 8, m_scope.Token);       // the scope token ends the subscription
    }

    private async OnityTask WriteAsync(SaveRequested request, CancellationToken ct)
    {
        await OnityTask.SwitchToThreadPool(ct);
        // Write the file.
    }
}
// Recipe D: triggers, UI Toolkit events and binding.
using System.Threading;
using Onity.Reactive;           // ReactiveProperty<T>
using Onity.Unity.Async;        // OnityTask, AsLatestAsyncEnumerable, BindTo, OnClickAsync
using Onity.Unity.Async.Triggers;   // GetAsyncTriggerEnterTrigger
using UnityEngine;
using UnityEngine.UIElements;

public sealed class Pickup : MonoBehaviour
{
    [SerializeField] private UIDocument m_hud;
    private readonly ReactiveProperty<int> m_coins = new ReactiveProperty<int>(0);

    private void Start()
    {
        RunAsync(destroyCancellationToken).Forget(Debug.LogException);
    }

    private async OnityTask RunAsync(CancellationToken ct)
    {
        Label coinsLabel = m_hud.rootVisualElement.Q<Label>("coins");
        m_coins.AsLatestAsyncEnumerable().BindTo(coinsLabel, ct);           // TextElement text, latest value

        Button collect = m_hud.rootVisualElement.Q<Button>("collect");
        await collect.OnClickAsync(ct);

        Collider other = await this.GetAsyncTriggerEnterTrigger().OnTriggerEnterAsync(ct);
        m_coins.Value += 1;
    }
}
// Recipe E: composition (fragments).
(Texture2D icon, string title) = await OnityTask.WhenAll(LoadIconAsync(ct), LoadTitleAsync(ct));   // tuple
int[] scores = await OnityTask.WhenAll(scoreTasks);                                               // array in, array out
(bool hasResultLeft, int score) = await OnityTask.WhenAny(scoreTask, OnityTask.Delay(5f, ct));      // typed vs untyped

await foreach (OnityWhenEachResult<int> each in OnityTask.WhenEach(scoreTasks).WithCancellation(ct))
{
    if (each.IsCompletedSuccessfully)
    {
        Debug.Log(each.Result);
    }
}
// Recipe F: timings and timers (fragments).
await OnityTask.Yield(OnityPlayerLoopTiming.PreLateUpdate);                       // the next PreLateUpdate drain
await OnityTask.WaitForFixedUpdate();                                             // after the physics step
await OnityTask.WaitUntil(this, self => self.m_isReady, OnityPlayerLoopTiming.LateUpdate, ct);   // no closure

OnityPlayerLoopTimer timer = OnityPlayerLoopTimer.StartNew(
    TimeSpan.FromSeconds(1), periodic: true, OnityDelayType.UnscaledDeltaTime,
    OnityPlayerLoopTiming.Update, ct, state => ((Spawner)state).Spawn(), this);
timer.Dispose();                                                                  // stops it

11.4 DI, reactive and messaging integration

API What it does
IOnityScopeLifetime (Token, IsDisposed) The injectable scope lifetime; every context binds it. container.LifetimeToken and OnityContext.LifetimeToken are the same token (before Awake creates the container, the context’s destroy token). Dispose() cancels it before disposing services.
disposable.AddTo(scope) (Onity.DI) Disposes when the scope ends, at once if it already ended.
component.GetScopeCancellationToken() The nearest context’s LifetimeToken, else the component’s destroy token. Main thread only; cache it.
IOnityAsyncInitializable.InitializeAsync(CancellationToken) Awaited by BuildAsync after the async build callbacks, one at a time in registration order, on the build’s context; the token is the scope token.
context.IsReady, ReadyTask, WaitReadyAsync(ct) Readiness including the async initializers; WaitReadyAsync resumes on the main thread, and its token cancels that wait only.
container.DeclareAsyncMessage<T>(), container.BindAsyncReactiveProperty(initial) One scope-owned AsyncMessageChannel<T> or OnityAsyncReactiveProperty<T> bound to its contracts.
property.WaitAsync(ct), WaitUntilAsync(predicate, ct) The next accepted change, or the first match, of an IReadOnlyReactiveProperty<T>; pooled, one shared subscription per property.
property.AsLatestAsyncEnumerable(), ToAsyncReactiveProperty(ct), asyncProperty.BindTo(reactiveProperty, ct) A conflating stream; an async property view; a write-back into a ReactiveProperty<T>.
subscriber.ReceiveAsync(ct), ReceiveAsync(predicate, ct) The next (matching) message from an ISubscriber<T>.
subscriber.ReceiveAllAsync(capacity, OnityBufferOverflow) A buffered stream; Fault (default), DropOldest, DropNewest.
asyncSubscriber.SubscribeQueued(handler, capacity, lifetimeToken) A bounded queue to one ordered handler; PublishAsync waits only while the queue is full.

ReactiveProperty<T>.Dispose() does not complete pending waits; pass the scope token. A BindPooledFactory(prefab, ...) pool is disposed with its scope too (10.4).

11.5 UniTask names that change

UniTask Onity
UniTaskVoid, UniTaskCompletionSource, AutoResetUniTaskCompletionSource OnityTaskVoid, OnityTaskCompletionSource, OnityAutoResetTaskCompletionSource
PlayerLoopTiming.Update / FixedUpdate OnityPlayerLoopTiming.UpdateBegin / FixedUpdateBegin
DelayType, PlayerLoopTimer, TimeoutController OnityDelayType, OnityPlayerLoopTimer, OnityTimeoutController
UniTask.Delay(int ms) OnityTask.Delay(TimeSpan.FromMilliseconds(ms))
UniTaskAsyncEnumerable, IUniTaskAsyncEnumerable<T> OnityAsyncEnumerable, IOnityAsyncEnumerable<T>
ToUniTask(...) on an AsyncOperation AsOnityTask(...)
ToUniTaskAsyncEnumerable() ToOnityAsyncEnumerable(); from an IOnityObservable<T>: AsOnityAsyncEnumerable(capacity)
ct.ToUniTask() ct.ToOnityTask()
AsyncReactiveProperty<T> OnityAsyncReactiveProperty<T>
Channel.CreateSingleConsumerUnbounded<T>() OnityChannel.CreateUnbounded<T>()
UniTaskScheduler, TaskPool.SetMaxPoolSize OnityTaskScheduler, OnityTask.SourcePoolCapacity

Everything else keeps UniTask’s member name on the Onity type. The full map is From UniTask (Documentation~/Migration/From-UniTask.md in the package).

11.6 Error to fix (async)

Exception or diagnostic Cause Fix
InvalidOperationException: The OnityTask source is no longer valid. Pooled OnityTask instances can be awaited only once. A pooled task was awaited, read or bridged after it was consumed. Await once and keep the result, or Preserve() before sharing.
InvalidOperationException: OnityTask supports only one native awaiter. Two consumers awaited the same pooled task. Preserve() once and share the returned task.
InvalidOperationException: Onity timing waits require Unity's main thread. / Onity timing waits require an active Play/player session. An explicit-timing or timed wait created off the main thread, or one of those waits or Yield() used outside Play. Create them on the main thread in Play (SwitchToMainThread first); Edit Mode tools use the float-seconds Delay, WaitUntil and the default frame waits.
ArgumentException from WhenAll or WhenAny The same single-consumer task was passed twice. Pass each task once; Preserve() to use one result twice.
InvalidOperationException: The property is already publishing a value. Set it after that publication has returned. An OnityAsyncReactiveProperty<T> was set from a continuation of its own publication. Set it after the publication returns (for example after Yield()).
OnityChannelClosedException A read from a completed, drained channel, or a write or Complete after completion. Use ReadAllAsync or Completion to end consumers; complete once.
TimeoutException Timeout(...) won before the producer. Use TimeoutWithoutException for a flag; the producer keeps running unless you pass a source to cancel.
OperationCanceledException The token was canceled. Normal, not a failure. Catch it where the flow starts (catch (OperationCanceledException) when (ct.IsCancellationRequested)).
CS0012 (a type defined in an assembly that is not referenced: Onity.Reactive) The asmdef calls an Onity.Unity.Async extension without referencing Onity.Reactive. Add Onity.Reactive (and Onity.Messaging for the messaging bridges) to the asmdef references.
CS0012 (a type defined in an assembly that is not referenced: Onity.Core) The asmdef touches IOnityObservable<Unit> (for example OnityUnityObservable.EveryUpdate()) without referencing Onity.Core, where Unit lives. Add Onity.Core to the asmdef references.
CS0121 (ambiguous call) on Yield(default) or a null delegate Several overloads accept the argument. Use a named argument, or cast null to the delegate type.

11.7 Public-API index (Onity.Unity.Async)

  • OnityTask statics: CompletedTask, FromResult, FromException(<T>), FromCanceled(<T>), FromTask, Create, Defer, Never, Lazy, Void, Action, UnityAction, Yield, NextFrame, DelayFrame, DelayFrames, NextFixedFrame, NextLateFrame, WaitForFixedUpdate, WaitForEndOfFrame, Post, Delay, DelayUnscaled, WaitForSeconds, WaitUntil, WaitWhile, WaitUntilCanceled, WaitUntilValueChanged, WhenAll, WhenAny, WhenEach, SwitchToMainThread, ReturnToMainThread, SwitchToThreadPool, SwitchToTaskPool, SwitchToSynchronizationContext, ReturnToSynchronizationContext, ReturnToCurrentSynchronizationContext, RunOnThreadPool, ToCoroutine, LoadScene, LoadSceneAdditive, LoadSceneAsync, ActivateScene, UnloadScene, Send, GetJson, PostJson; settings FlowExecutionContext, RunnerPoolCapacity, SourcePoolCapacity.
  • Task members: Status, IsCompleted, IsCompletedSuccessfully, IsCanceled, IsFaulted, AsTask(), Preserve(), Forget(handler = null), GetAwaiter(), AsUnitTask(), AsOnityTask() (typed), implicit OnityTask<T> to OnityTask and to ValueTask.
  • Extensions: Timeout, TimeoutWithoutException, AttachExternalCancellation, SuppressCancellationThrow, ContinueWith, Unwrap, ToAsyncLazy, Forget(handler, handleExceptionOnMainThread), AsValueTask, AsOnityTask (on Task, ValueTask, AsyncOperation, JobHandle, AsyncGPUReadbackRequest, Awaitable), AsAssetOnityTask, AwaitForAllAssets, AsAssetBundleOnityTask, AsWebRequestOnityTask, AsInstancesOnityTask, WaitAsync (JobHandle), ToCancellationToken, ToOnityTask (token, observable, IEnumerator), WaitUntilCanceled (token), AddTo(ct), RegisterWithoutCaptureExecutionContext, IsOperationCanceledException, GetCancellationTokenOnDestroy, RegisterRaiseCancelOnDestroy, GetScopeCancellationToken, ToObservable, FirstOnityTask, PublishOnityTask, SubscribeOnityTask, ReceiveAsync, ReceiveAllAsync, SubscribeQueued, WaitAsync / WaitUntilAsync / AsLatestAsyncEnumerable / ToAsyncReactiveProperty (reactive properties), BindAsyncReactiveProperty (container), StartAsyncCoroutine, WithCancellation.
  • Types: OnityTaskVoid, OnityTaskStatus, OnityPlayerLoopTiming, OnityTaskPlayerLoop (Initialize, InitializeAll, IsInjected, DumpCurrentPlayerLoop, AddAction, AddContinuation, IsMainThread, MainThreadId, UnitySynchronizationContext), IOnityPlayerLoopItem, OnityYieldAwaitable, OnityDelayType, OnityPlayerLoopTimer, OnityTimeoutController, OnityTaskCompletionSource(<T>), OnityAutoResetTaskCompletionSource(<T>), OnityAsyncLazy(<T>), OnityWhenEachResult<T>, OnityTaskScheduler, OnityTaskTracker (IsEnabled), OnityProgress, OnityUnityWebRequestException, OnityCancellationTokenEqualityComparer, OnityBufferOverflow.
  • Streams: IOnityAsyncEnumerable<T>, IOnityAsyncEnumerator<T>, IOnityAsyncWriter<T>, OnityAsyncEnumerable (Create, Empty, Return, Range, Repeat, Never, Throw, Merge, EveryUpdate, Timer, Interval, TimerFrame, IntervalFrame, EveryValueChanged), OnityAsyncEnumerableLinq (UniTask’s LINQ operators), OnityAsyncEnumerableExtensions (Select, Where, Take, SelectAwait, WhereAwait, ForEachAsync, FirstAsync, ToArrayAsync, WithCancellation, AsOnityAsyncEnumerable, AsAsyncEnumerable, AsObservable, ToOnityAsyncEnumerable, Subscribe, SubscribeAwait), IOnityConnectableAsyncEnumerable<T>, IOnityOrderedAsyncEnumerable<T>, IOnityLookup<TKey, T>, IOnityGrouping<TKey, T>, BindTo.
  • Channels: OnityChannel (CreateBounded, CreateUnbounded), OnityChannel<T> (Reader, Writer), OnityChannelReader<T> (TryRead, ReadAsync, WaitToReadAsync, Completion, ReadAllAsync), OnityChannelWriter<T> (TryWrite, WriteAsync, TryComplete, Complete), OnityChannelClosedException.
  • Async reactive properties: OnityAsyncReactiveProperty<T>, OnityReadOnlyAsyncReactiveProperty<T>, IOnityAsyncReactiveProperty<T>, IOnityReadOnlyAsyncReactiveProperty<T>, ToReadOnlyAsyncReactiveProperty.
  • Triggers (Onity.Unity.Async.Triggers): GetAsync<Name>Trigger() for Awake, Start, Enable, Disable, Destroy and 55 MonoBehaviour messages, AwakeAsync, StartAsync, OnDestroyAsync, OnityAsyncTriggerBase<T>, OnityTriggerEvent<T>, IOnityTriggerHandler<T>, IOnityAsyncOneShotTrigger.
  • UI events: UnityEvent OnInvokeAsync, OnInvokeAsAsyncEnumerable, GetAsyncEventHandler; UI Toolkit OnClickAsync, OnEventAsync<TEvent>, OnValueChangedAsync and their ...AsAsyncEnumerable forms, BindTo(TextElement); uGUI (Onity.Unity.UGUI, only with com.unity.ugui) OnClickAsync, OnValueChangedAsync, OnEndEditAsync, the EventSystems triggers, BindTo(Text), BindTo(Selectable).

The task-oriented narrative is Async with OnityTask; the measured comparison is OnityTask vs UniTask.