Many productions drive Luna from an external state machine: NodeCanvas, PlayMaker, a custom FSM, or plain game code with async flows. Luna needs no dependency on any of them, and none of them needs to own Luna's internals. This page is the ownership contract that makes the combination stable.
| Owner | Owns | Must not own |
|---|---|---|
| Your flow layer (FSM, director, game code) | Application flow: gates, start/retry/next decisions, save and purchase sequencing, waiting for modal and gameplay outcomes | Panel trees, visual focus, transitions, a second copy of the back stack |
| Luna | Destinations, stacks, channels, back semantics, modals, focus, transitions, occlusion, responsive presentation | Progression, wallet truth, gameplay rules, business decisions |
| Your services | Content, progress, currency, persistence: the source of truth the UI renders | Visual tree manipulation, navigation implementation |
The test for any line of code: does it decide, or does it present? Decisions live in the flow layer and call Luna verbs. Presentation lives in views and reads services.
Keep the integration thin: one action per verb, ids from validated fields rather than raw strings where possible (a CatalogKey field constrained to the nav destination catalog gives you a dropdown and a stale-id warning in the inspector).
LunaNavigation.Push("hub.collection");
LunaNavigation.Push("hero.detail", new HeroDetailArgs { HeroId = id });
LunaNavigation.PopBackStack();
LunaNavigation.SwitchChannel("hub.shop");PushAsync<T> is the bridge between a flow graph's wait state and a Luna modal. The task resolves exactly once, with either a value or a dismissal:
var result = await LunaNavigation.PushAsync<int>("confirm.retry", args);
// result.IsDismissed -> closed by Esc / backdrop / cascade
// result.Value -> what the modal passed to Pop<T>Model the outcome as a single enum-like value in your graph (pending, confirmed, declined, dismissed) rather than several booleans whose stale values can fire a transition early. Keep no transition out of the pending state except the awaiter resolving.
Luna already knows the current destination, the active channel, and every stack. Read it; never mirror it:
DestinationChanged / ChannelChanged for flow-level reactions.A mirrored back stack is the single most common integration bug: it disagrees with Luna's after the first cascade or channel restore, and every decision after that is wrong.
NavHost pre-spawns every destination hidden at boot. If an external controller starts its per-view logic in Awake, every screen's logic runs before the player has opened anything.
Gate on the view instead:
view.WhenUILoaded(...).view.Fade.OnFadeInStart and read GetArgs<T>() there. See Typed Destinations.Start Visible views: the host's boot seeding push already runs one real fade cycle, and firing your own double-counts every handler.Defer your logic, never the view itself: views should spawn and bind with the host at boot. A view that binds after its parent's panel has painted is what produces template flashes and click-stealing at startup; in the host-owned flow that window does not exist, because born visibility is applied in the same panel update that builds the tree, before the first paint.
When a tab switches away or an opaque overlay settles, covered views stop rendering but stay logically open on their stacks. Do not pause, close, or reset flow logic from render occlusion; react to actual navigation events instead. See Occlusion.
Luna owns the pause modal: its backdrop, focus, and back behavior. Your flow layer owns the wait state and the resume/retry/quit decision. Your session service owns the actual pause: time scale, input, simulation. Showing a modal alone is not a gameplay pause; wire all three.
Settings
Theme
Light
Contrast
Material
Dark
Dim
Material Dark
System
Sidebar(Light & Contrast only)
Font Family
DM Sans
Wix
Inclusive Sans
AR One Sans
Direction