# Session services and cross-scene state

Hands-on walkthrough: [Tutorials — First session services](/docs/tutorials/first-session-services.md).

Cross-scene state lives on the **session**, not on immortal scene actors. This page documents the implemented model: lifetimes, `GameSubsystem` hooks, scene load APIs (including polish + additive layers), Kawa session scripts, and editor Play/Stop rules.

## Lifetimes

| Layer | Owner | Survives scene unload? | Examples |
|---|---|---|---|
| **Session** | `GameSubsystem` / `GameHost` | Yes | HUD chrome, progress, audio façade, session Kawa script |
| **Scene** | `World` entities + layers on `GameplayAllocator` | Base replace wipes all; additive layers unload selectively | Level geometry, props, placed lights |
| **Frame** | World arena / frame allocators | No | Scratch strings, temporary lists |

`World.allocator` is a chunked `GameplayAllocator` (16 MiB growth quantum, size-class freelists). Spawn/destroy recycles in-process; the OS is touched only when a chunk is exhausted. Scene unload does not return chunks to the OS — the next scene reuses them. The World struct itself and chunk pages come from the session platform allocator. The gameplay heap is single-threaded (game thread only, enforced in Debug); `World.shared_allocator` is the thread-safe parent heap for memory the render thread or job workers touch — render-command payloads, the command queue, the frame swap and surface-UI mailboxes, the material cache, parallel worker buffers.

Rule of thumb:

- Needs a transform in the level → **scene entity**.
- Needs memory or UI that must outlive a level → **session service**.

Do **not** put “GameManager” or global HUD entities in every scene JSON.

## Architecture

```text
SessionCore
 ├─ GameHost (type-erased GameSubsystem)   ← session services
 ├─ World
 │   ├─ entities + scene_layers[]
 │   ├─ scene_manager (queue, status, progress, errors)
 │   └─ script_backend (actor scripts + optional session script)
 └─ content_root + scene_path (primary / replace target)
```

## Scene load API

### Modes

| Mode | API | Behavior |
|---|---|---|
| **Load** | `world.requestSceneLoad(scene: SceneRef, opts: SceneLoadOptions) !SceneLoadHandle` | The one entry point. `opts.mode = .replace` (default): sole content after a multi-frame job — A stays live through spawn, unload A + commit B in the finalize frame, session `scene_path` updated. `opts.mode = .additive`: merge as a **layer** keyed by stem or explicit `instance_key`, keeping existing content |
| **Present** | `world.presentSceneLayer(key)` | Open the gate on a `gated` / `deadline` / `manual` layer. False if unknown or already presented |
| **Re-prioritize** | `world.setSceneLayerPriority(key, priority)` | Move queued work for one layer; discards nothing already decoded |
| **Unload layer** | `world.requestSceneUnloadLayer(key: []const u8)` | Despawn that layer’s actors; release layer material path retains / bundles (`key` is the load stem, same as `SceneRef.stem()`) |
| **Reload** | `world.requestSceneReload()` | Re-load current `scene_path` (replace) |
| **Cancel** | `world.cancelSceneLoad(handle)` | Cancel this queued or preparing request. Returns `cancelled`, `committed` (too late), or `invalid`; active rollback drains across ticks |
| **Observe** | `world.pollSceneLoad(handle)` | Request state, phase, progress, paths, requirement failures, degraded readiness and cleanup status |
| **Activate request** | `world.activateSceneLoad(handle)` | Queue activation of this prepared request; recheck its identity and readiness at execution and commit |
| **Release observation** | `world.releaseSceneLoad(handle)` | Invalidate all copies of the handle without cancelling work; the World removes the record after work drains |

```zig
pub const SceneLoadOptions = struct {
    instance_key: ?[]const u8 = null,     // independent additive copy
    required_assets: []const AssetRef = &.{}, // copied with admission
    allow_degraded: bool = false,        // explicit missing/corrupt asset fallbacks
    mode: SceneLoadMode = .replace,      // replace | additive
    present: ScenePresent = .immediate,  // immediate | gated | deadline | manual
    deadline_ms: u32 = 0,                // .deadline only; measured from the request
    priority: u16 = 0,                   // dispatch order, low first
    residency_urgency: ?ResidencyUrgency = null,
};
```

### Request lifetime and commit

Admission copies the scene identity, instance key and extra asset references before it supersedes queued work. A failure returns an error and preserves the existing queue. No IO or dependency traversal runs at admission. A World admits at most 4,096 retained request records; each request accepts at most 4,096 extra references and 1 MiB of copied path bytes. Capacity refusal is explicit, with no truncation.

```zig
const world = hi.world();
const load = world.requestSceneLoad(hi.SceneRef.must("scenes/menu"), .{
    .mode = .additive,
    .instance_key = "menus/left",
    .present = .manual,
    .required_assets = &.{ hi.AssetRef.must(.audio, "audio/menu_music") },
}) catch return;
// Retain `load` in World-scoped state across callbacks.
const status = world.pollSceneLoad(load);
if (status.state == .prepared) _ = world.activateSceneLoad(load);
// When no longer needed: cancel explicitly, then release observation.
_ = world.cancelSceneLoad(load);
_ = world.releaseSceneLoad(load);
```

`queued → loading → prepared → committed` is the gated path; immediate loads skip `prepared`. `failed` and `cancelled` are terminal outcomes, but `cleanup_pending` remains true until actor rollback and detached workers drain. Retain observation to inspect that cleanup; releasing it never blocks. Handles include World identity and monotonic request identity, so stale, released and foreign handles cannot affect a later job or a reused layer key.

Prepared actors are excluded from gameplay lookup/query APIs. They have initialized component rows and GPU preparation, but no gameplay `Awake`, `Start`, enable or destruction hooks. Before the commit boundary, cancellation or preparation failure preserves the outgoing scene and its path. Metadata allocations finish before that boundary. Commit starts activation; cancellation then returns `committed`. An error in gameplay activation remains a failed outcome with `committed = true`, since gameplay effects cannot be rolled back transactionally.

Every typed requirement in the cooked dependency closure joins the load ticket, excluding session-owned shader packages and deferred partition cells. Missing or corrupt requirements fail a request by default. `allow_degraded = true` permits existing consumer fallbacks and records `failed_assets` / `degraded`; it does not hide admission, planning, structural-document or activation failures. A deadline may bypass GPU readiness, but cannot bypass required asset failure. Requirement availability and consumer construction remain distinct: scripts and other consumers still validate their cooked bytes when attached.

Each tracked request has its own outcome and cancellation; identical tracked submissions do not fold. A loaded key rejects a new request with `SceneLayerExists`. Set a scene-stem-shaped `instance_key` (excluding `main`, `::` and NUL) to create independent copies: actor IDs and internal parent, camera, joint and prefab-root references become `key::authored_id`. External parent references and game-owned strings in component payloads remain unchanged. `setSceneLoadPriority(handle, priority)` updates queue/job metadata and the residency ticket without discarding decoded data. Release retained handles after inspection; unobserved operations remove their outcomes automatically when they finish.

Scene document reads, scene/live decode warmup and manual asset acquisition share a store-owned worker envelope: 64 MiB of estimated cooked work and at most 16 active workers. Estimates come from `assets.shingraph`; absent metadata uses a conservative 64 KiB minimum. An oversized asset runs alone, guaranteeing progress. Capacity defers work without waiting on another worker or marking content failed. The store exposes active/peak bytes, completed byte charges and deferrals. These are cooked-work estimates, not a decoded-memory measurement. Existing shared decode flights suppress duplicate cold decodes, while render staging retains its own byte admission and fence-owned resource lifetimes.

### Public SDK services and ownership

Gameplay enters through `hi.world()`, `hi.assets()` and `hi.packs()`:

| Service | Responsibility | Retained values |
|---|---|---|
| `hi.assets()` → `?hi.Assets` | Asynchronous asset dependency acquisition, polling, priority, cancellation and release | `hi.AssetRequest`; polling returns `hi.AssetRequestStatus` / `hi.AssetRequestState` |
| `hi.packs()` → `?hi.Packs` | Explicit storage holds, labels and pack inspection | `hi.PackHandle`; `state(handle)` returns `hi.PackOpenState`, `stat` returns `hi.PackStatus` |
| `hi.world()` → `hi.World` | Scene loading, actor/prefab instantiation and removal, World-owned prefab definitions | `hi.SceneLoadHandle`, `hi.PrefabHandle` and `hi.ActorRef` |

Asset requests and pack handles belong to the calling World. They survive scene changes within that World and expire when it or its game module is torn down. Prefab definitions also belong to the World, so `retainPrefab` / `releasePrefab` stay on `world`. A spawned instance has its own lifetime: release of the definition, acquisition request, or explicit pack hold does not despawn it.

Acquire bound services in the current callback; retain handles across callbacks, not service wrappers or borrowed contexts. The control APIs run on the game thread; acquisition work runs on workers. An absent optional service means unsupported, not successful loading. Check `AssetRequest.none` / `PackHandle.none` at registration and check `succeeded()` before consuming an acquired set.

Raw host tables, version constants and binding functions live under `hi.abi` for engine integration and test hosts. There is no public `hi.host_api` or `hi.residency()` entry point, and no legacy `ResidencyTicket` / `ResidencyStatus` aliases. Particle control follows the same gameplay convention through `hi.particle()`.

### Warming assets without a scene (`hi.assets()`)

```zig
const r = hi.assets() orelse return;   // null when the host has no asset store
const t = r.request(&.{
    hi.AssetRef.must(.texture, "textures/skin_gold"),
    hi.AssetRef.must(.audio,   "audio/vo/mission_02"),
}, 0);
…
if (r.poll(t).succeeded()) spawnTheCutscene();
_ = r.release(t);   // drops the ticket and the retains
```

For sets a scene cannot describe: a skin bought mid-match, a VO bank for the next mission, or props a cutscene needs before its trigger. Registration copies and validates every reference. Worker jobs expand the cooked dependency graph, acquire its packs, decode textures/models/audio/fonts, and retain cooked bytes for document/script/shader kinds. There is no 256-reference truncation. `isComplete()` includes failed and cancelled requests; `succeeded()` requires successful acquisition of the whole set. A CPU-warm document is not a parsed consumer instance, and GPU preparation remains the consumer's responsibility.

Lower numerical priority dispatches first; `setPriority` affects pending worker chunks. Four chunks may run concurrently, with up to sixteen asset acquires per chunk. A pending cook parks on a store ticket instead of occupying a worker. `cancel(t)` ends interest; `release(t)` also invalidates the handle. In-flight workers drain safely and other owners keep their holds.

Poll across callbacks; never block the game thread. Sets belong to the calling **World**, survive scene changes within it, and are released on World/module teardown. Copied handles alias the same request; request another set for independent ownership. Another World cannot poll or release the handle.

For manual prefab use: acquire a `.prefab` reference, wait for `succeeded()`, call `hi.world().retainPrefab` and `spawnPrefab`, then release the warm-up ticket. The prefab handle and spawned consumers have their own lifetimes: `releasePrefab` drops the definition hold and `try despawnPrefabInstance` removes the complete live subtree. If collecting that subtree fails to allocate, it returns an error before teardown and leaves the instance intact. An invalid root is a successful no-op. Neither operation releases another owner’s request. Ordinary scene loading performs dependency and pack discovery automatically.

### Presentation (`ScenePresent`)

| Policy | Behaviour |
|---|---|
| `immediate` | Actors appear as their assets land. Default, and what the editor's authoring viewport always uses |
| `gated` | No gameplay until the layer is GPU-ready — no `Awake`, `Start`, render, scripts, physics, audio, camera, or `onSceneLayerLoaded` |
| `deadline` | Gated, but presents degraded once `deadline_ms` elapses. One slow asset must not hold a player at a loading screen |
| `manual` | Gated, and the gate only opens on `presentSceneLayer` after `.gpu`. Early reveal requests return false. `onSceneLayerReady(key)` fires once when ready. |

### Owning the moment of the swap

`gated` answers *"don't show a half-built scene"*. `manual` answers *"don't show
it **yet**"* — a different question, and the one a transition asks. A menu that
finishes loading before the splash animation ends must not appear early; a level
preloaded during play must not swap itself in the moment it becomes resident.

For every other policy, readiness and presentation are the same instant, so
`onSceneLayerLoaded` describes both. `manual` separates them, which is why it
has its own hook: `onSceneLayerReady(key)` fires once when the layer reaches
`.gpu`, and `onSceneLayerLoaded(key)` still fires at presentation.

`presentSceneLayer` checks readiness both when the SDK request is queued and
again when its deferred command executes. A hot reload that introduces new
pending CPU/GPU work between those moments therefore keeps the layer withheld.
Only `deadline` may intentionally reveal below `.gpu`, and only after its
authored timeout.

The following subsystem excerpt assumes game-owned `splash` and `fade` controllers, plus file-scope `hi` and `std` imports. Start the preload from your loading-flow setup after the initial scene request; a later replace request clears queued additive work.

```zig
const menu_layer = "scenes/menu";

pub const Game = struct {
    /// Which World the fields below describe. One subsystem instance serves
    /// every World — in the editor the authored World and the Play copy are
    /// attached at the same time — so per-World state must be keyed, never
    /// assumed to be a singleton.
    world: hi.WorldScope.Id = 0,
    menu_ready: bool = false,
    shown: bool = false,

    pub fn beginMenuPreload(self: *Game, scope: hi.WorldScope) void {
        // `role` skips work the editor's authoring World does not need: it is
        // attached for the whole session but never ticks.
        if (scope.role == .editor) return;
        self.world = scope.id;
        self.menu_ready = false;
        self.shown = false;
        self.splash.begin();
        // Loads behind the splash; the running scene keeps its frame budget.
        const load = hi.world().requestSceneLoad(
            hi.SceneRef.must(menu_layer),
            .{ .mode = .additive, .present = .manual },
        ) catch return;
        _ = hi.world().releaseSceneLoad(load); // this example observes layer hooks
    }

    /// Resident and uploaded — but deliberately not shown.
    pub fn onSceneLayerReady(self: *Game, key: []const u8) void {
        if (std.mem.eql(u8, key, menu_layer)) self.menu_ready = true;
    }

    pub fn onTick(self: *Game) void {
        self.splash.advance(hi.world().deltaTime());
        if (self.shown or !self.menu_ready or !self.splash.finished()) return;
        // Cheap: a flag fold plus `Start`, no decode or upload left to do.
        self.shown = hi.world().presentSceneLayer(menu_layer);
        self.fade.start();
    }
};
```

Polling works just as well — `sceneLayerReadiness(key).stage == .gpu` — for a
game that would rather not carry a callback.

Presentation is cheap by construction: the layer's actors are already spawned,
bound and uploaded, so activation runs `Awake`, flips effective-active and runs `Start`
plus scripts. The load itself ran budgeted in the background against the scene
that was still playing, so nothing here competes with the running frame.

A gated request's prepared actors **exist** without gameplay `Awake`; they do not tick, draw, collide or receive messages. Everything is released by one `presentSceneLayer` — there is no per-subsystem "am I presented" check, and game code cannot tell which policy it was loaded under because the same hooks fire either way.

**Edit mode always presents immediately** regardless of what the scene asked; **Play mode honours the policy**, so a designer testing a gated level sees what a player sees.

Scene identity is an extension-free stem (`SceneRef.must("scenes/messaging")` — not `.json` / `.shinscene`).

Loads use a **priority queue**; engine-internal untracked submissions deduplicate, while public tracked requests retain independent identity. A whole-world replace/reload clears the backlog. Applied from `SessionCore.tick`, not mid-entity-update.

Up to **four ops run concurrently** (`World.scene_jobs`), under these scheduling rules:

- **Same layer key serializes.** Two ops on one key never overlap and never reorder — an eviction completes before the load that reuses its key.
- **A whole-world op runs alone.** Replace / reload / build target the `main` key and barrier the pool in both directions.
- **Dispatch order is `(priority, submission)`.** Lower priority value runs sooner; a streaming grid passes the cell distance. `world.setSceneLayerPriority(key, p)` re-orders the backlog without discarding anything already decoded.
- **Spawning and evicting serialize.** Document read, store decode and material parse — where a cold load spends its time — run concurrently across the pool. The world-mutating tail does not: the entity batch and the entity/layer lists are shared, so one job spawns or evicts at a time and the others wait holding their retains. Gameplay is **not** held for either: spawn batches nest, so a `spawn` from game code or a script during a cell load commits on its own frame, and an eviction works on stable handles precisely so ticks can run between slices. Only a whole-world `replace` holds entity/script/physics, and only past preload.

Frame cost does not scale with the pool: one frame budget is divided across the running jobs, so concurrency buys load latency, never frame time.

Two cancellation shapes, both automatic:

| Situation | What happens |
|---|---|
| Cell requested, then unloaded before it was ever dispatched | The two ops annihilate in the queue |
| Cell unloaded while its load is still in flight | The load is cancelled rather than queued behind — no cell is spawned only to be destroyed next tick |

A streaming driver retains one handle per pending cell, cancels that handle when the cell leaves range, and releases observation after completion. Engine-internal untracked requests still fold an already-loaded key.

Per-layer progress (the aggregate snapshot cannot describe four jobs at once): `world.sceneLayerLoadStatus(key)` → `{ kind, load_phase, progress, priority }`. The editor's **Residency → Scene jobs** section shows the same thing plus queue depth and slot occupancy.

### Status / progress / errors

```zig
const snap = world.sceneLoadSnapshot();
// snap.status (SDK): idle | loading | ready | failed
//   Internal host queue uses pending/loading; HostApi maps both → SDK .loading,
//   and idle + a loaded primary path → .ready.
// snap.mode: replace | additive
// snap.load_phase: none | queued | document | store | materials | instantiate  (incoming job)
// snap.progress: 0..1 — the whole-world job if there is one, else the slowest cell
// snap.last_error: message when status == failed
// snap.generation: bumps on queue/complete/fail/cancel/ready-stage change
// snap.ready_stage: none | entities | assets | gpu | all  (**live world only**)
// snap.pending_assets / snap.pending_gpu: live-world counters
```

Types: `hi.SceneLoadSnapshot`, `SceneLoadStatus`, `SceneLoadMode`, `SceneLoadPhase`, `SceneReadyStage`, `LayerReadiness`.

**Do not** treat `ready_stage == .all` as “the scene being loaded is ready” while `status == .loading` — that stage is still the live world (often still fully ready during preload of the next scene).

### Readiness stages (show-the-scene gate)

Progressive highest-completed gate. Use this (not load-queue `status == ready`) to decide when the level is safe to show.

| Stage | Int | Scope | Meaning |
|---|---|---|---|
| `none` | 0 | layer + world | No loaded layers / cleared after unload |
| `entities` | 1 | layer + world | Actors spawned; loaded hooks wait for presentation when the layer is gated |
| `assets` | 2 | layer + world | Soft-pending meshes/models/materials, particle systems and their sprites resolved. Failed/missing refs use error cube / pink and **do not** block |
| `gpu` | 3 | **per-layer terminal** | Mesh + texture creates drained; map slots GPU-ready (or failed → defaults); material pipelines built; particle programs' kernels built and sprites uploaded; map rebinds pushed for visible primitives. Soft-missing / failed refs and failed builds do **not** block |
| `all` | 4 | **world composite only** | Every loaded layer is at `gpu` (plus shared skybox GPU when set) |

**Per-layer** (`world.sceneLayerReadiness(key)` → `LayerReadiness`):

- `stage` — highest completed for that layer (`none`…`gpu`; never reports `all`)
- `pending_assets` / `pending_gpu` — coarse counters for progress UI

**World composite** (`sceneLoadSnapshot()`):

- `ready_stage` — min across layers; becomes `all` when every layer is at `gpu`
- `pending_assets` / `pending_gpu` — sums (+ shared create-queue inflight)
- `generation` — bumps on queue/complete/fail/cancel **and** ready-stage change

**Typical loading screen:**

```zig
const load = world.requestSceneLoad(hi.SceneRef.must("scenes/messaging"), .{}) catch return;
_ = world.releaseSceneLoad(load);
// …each tick…
const snap = world.sceneLoadSnapshot();
if (snap.status == .failed) { /* show error */ }
else if (snap.status == .ready and snap.ready_stage == .all) { /* hide overlay, enable gameplay */ }
```

**Hooks** (fire when a layer or world stage changes, including regressions on hot-reload soft-pending):

- Zig: `onSceneReadyStage(key, stage)` — empty `key` = world composite
- Kawa: `on_scene_ready_stage(key, stage)` — `stage` is the int above

**Not** readiness: `runtime.graphics.scene_ready` is opaque TLAS readiness for ray tracing, unrelated to level load.

### Streaming (world partition)

A partitioned scene ([Scenes and gameplay — World partition](/docs/systems/scenes-and-gameplay.md#world-partition-authoring)) loads its base layer as the whole-world load and streams cells around a focus afterwards, so `ready_stage == .all` can be true while the ground the player stands on is still arriving. The gate for that is the streaming snapshot:

```zig
const stream = world.streamSnapshot();
// stream.active      — false for an unpartitioned scene (no gate, counts zero)
// stream.warming_up  — true until the first ring around the focus is resident and every layer is at gpu
// stream.focus       — the focus cell (hi.CellCoord)
// stream.authored / resident / pending / failed — cell counts
if (stream.warming_up) { /* keep the loading screen; show resident / (resident + pending) */ }

world.setStreamFocus(.{ x, y, z }); // ring around the player instead of the camera
world.setStreamFocus(null);         // back to the primary camera
```

A cell that fails to load does not hold the gate shut: it drops out of `pending`, counts under `failed`, and the grid retries it with backoff. Types: `hi.StreamSnapshot`, `hi.CellCoord`.

### Kawa

```kawa
Scene.reload();
Scene.load("scenes/messaging");
Scene.load_additive("scenes/messaging");
// Presentation policy is an optional second argument, deadline_ms a third.
// An unknown name refuses the load rather than silently using `immediate`.
Scene.load_additive("scenes/menu", "manual");
Scene.load("scenes/level", "deadline", 4000);
Scene.present_layer("scenes/menu");  // bool — opens a manual/gated gate
Scene.unload_layer("scenes/messaging");
Scene.cancel_load();  // bool
Scene.ready_stage();              // 0…4 world composite
Scene.layer_ready_stage(key);     // 0…3 per-layer (terminal = gpu)
Scene.stream_warming_up();        // bool — partitioned world's first ring not yet resident and GPU-ready
Scene.set_stream_focus(x, y, z);  // ring the streaming grid around a point; no args = camera
```

Script hooks mirror the Zig ones: `on_layer_loaded(key)` at presentation, and
`on_layer_ready(key)` when a `manual` layer reaches its gate.

### Layer rules (additive)

- Layer key is the stem used to request the load (e.g. `scenes/messaging`).
- **Entity `scene_id`s must be unique across all layers** — conflict → load fails.
- Materials: path-keyed `MaterialCache` retain/release; shared paths refcount across layers; unload releases that layer’s retains.
- Store assets (textures / meshes / models / audio / blobs): `AssetStore` retain/release; materials hold texture retains; primitives hold engine-mesh retains; `releaseBundle` does not wipe decoded assets still referenced. Full map: [Asset residency](/docs/systems/asset-residency.md).
- Full scene replace is **overlap-safe**: `beginReplacePreload` holds the incoming scene’s materials/meshes while the old scene unloads, so shared assets are not free+redecode mid-transition.
- Primary camera: additive load sets primary only if none is set yet.
- Parent links may target actors in earlier layers.

### Layer / partition membership (runtime)

Every entity has optional **`layer_key`** (partition id). `null` means **loose** — not destroyed by additive unload (survives layer teardown; see [Migration](/docs/guides/migration.md#scenes-and-levels)).

| API | Behaviour |
|---|---|
| Scene load / additive load | Authored actors attach to that layer’s key |
| Game: `hi.world().spawn` with `SpawnDesc.layer = .loose` (default) | Loose |
| Host: `world.spawnLoose(desc)` / `spawnByArchetypeWith(desc, .{})` | Loose (default) |
| Host: `world.spawnInLayer(desc, key)` | Requires loaded layer; dies with that unload |
| Host: `world.spawnInherited(desc, from)` | Copies `from`’s membership |
| `world.attachEntityToLayer(entity, key)` | Bind live entity to a loaded layer |
| `world.detachEntityFromLayer(entity)` | Make loose |
| `requestSceneUnloadLayer(key)` | Destroys **all** entities with that `layer_key`, then releases layer material paths / bundles. Queued, budgeted, and serialized against other ops on the same key |

Hierarchy still does **not** cascade destroy: a loose child of a layer actor is only detached when the parent dies.

`World.loadSceneImmediate` / `unloadSceneLayerImmediate` are the unbudgeted primitives underneath, named so they cannot be reached for by accident: no queue, no pool slot, no frame spreading, and the caller owns the render fence and physics park. They are for intra-job rollback and for tests that need a scene live before the next statement — never for anything a player or an editor user triggers.

### Replace vs additive flow

**Replace** (multi-frame `SceneLoadJob`, live-A deferred unload):

1. Queue the stem; next tick starts the job (`layer_key` is always `"main"`). Session `scene_path` updates when instantiate begins.  
2. Document + budgeted planning/material identity preparation while **scene A stays live** (preload still simulates). `gated` / `deadline` / `manual` also parse materials and finish bulk store warming here; `immediate` also warms bulk geometry and materials before activation, while allowing GPU preparation to finish progressively.
3. Budgeted spawn of B with `deferred_unload` — A still renders.  
4. Same frame as finalize: `onSceneWillUnload` / session script hook → unload A → commit B → `onSceneLoaded`.  
5. Readiness → `entities` (then `assets` / `gpu` / world `all` as binds complete). Under `immediate`, geometry is prioritized in bounded post-presentation batches, so structure appears before the full texture set. Failed load before the swap leaves A running.

**Additive:**

1. Resolve + parse  
2. Instantiate into live world as new layer  
3. No session path change, no full unload hooks  
4. `onSceneLayerLoaded(key)` / session `on_layer_loaded(key)` (`key` = request path)  
5. Layer readiness advances `entities` → `assets` → `gpu`; world `all` only when **every** layer is at `gpu`

**Unload layer:**

1. Resolve pending key; unknown layer → error  
2. `onSceneLayerWillUnload(key)` / session `on_layer_will_unload(key)`  
3. Destroy layer entities + release layer resources  
4. Recompute readiness (world may leave `all` if other layers remain)

Full **replace** still fires only `onSceneWillUnload` / `onSceneLoaded` for load hooks (not per-layer loaded hooks); readiness still tracks the `"main"` layer.

## GameSubsystem (Zig)

Optional game export. Sample: `hikari-games/example/src/session.zig` (HUD in `session_ui.zig`).

| Hook | When |
|---|---|
| `onLaunch` | Session/game host created; register session-wide providers here |
| `onWorldAttach(WorldScope)` | A World has content registries and its AssetStore bound, before scene actors instantiate. Runs for the initial world and each disposable Play world |
| `onWorldDetach(WorldScope)` | HostApi is still bound to the World immediately before its final teardown |
| `onTick` | Each Play frame (after world tick). Always runs under host pause and load hold so HUD does not blank. World entities/scripts/physics are already frozen; session-level sim (day cycle, etc.) must integrate with `world.deltaTime()` (0 while paused), not wall clock. Chrome uses `unscaledDeltaTime` / wall time |
| `onPlayEnded` | Editor Stop / in-editor Quit-from-Play, before the world switch. **Not** mid-play `Scene.load` — clear play-ephemeral HUD here (open sheets, pause menus), not in `onSceneWillUnload` |
| `onSceneWillUnload` | Before a full-world replace, and once for the disposable Play world at Stop |
| `onSceneLoaded(SceneInfo)` | After a successful **replace** load, including construction of the Play world. Stop does not load the retained editor world again |
| `onSceneLayerLoaded(key)` | After successful **additive** presentation |
| `onSceneLayerReady(key)` | A manual layer reached GPU readiness; explicitly present it when ready to reveal |
| `onSceneLayerWillUnload(key)` | Before **additive** layer unload |
| `onSceneReadyStage(key, stage)` | When layer/world readiness changes (`SceneReadyStage`; empty `key` = world) |
| `onTerminate` / `destroy` | Session shutdown, after every attached World has detached |

`SceneInfo`: `path` (absolute), `name` (basename without extension).  
Layer `key`: the path string used when requesting additive load / unload (not re-resolved absolute).

Sample HUD: Prev/Next Scene (replace), Reload, load status (only while loading, failed, or still streaming), Settings, Quit. Additive load/unload is demonstrated by `scenes/streaming_hallway`'s stream driver rather than by HUD buttons, so the HUD stays to session-level actions.

**Streaming stress demo:** `scenes/streaming_hallway` + `stream_cell_00`…`05` — freecam/player travel along world **+Z** (layout axis; look remains −Z), sliding-window additive load/unload (unique vs shared materials). See `hikari-games/example/scenes/streaming_hallway.README.md`.

## Kawa session scripts

Not bound to an actor. Attached via `hi.world().attachSessionScript(AssetRef)` (host `script_backend.attachSession`). Survives scene unload (bytecode kept when actor caches clear).

| Kawa fn | When |
|---|---|
| `start` | After attach |
| `update(dt, total_time)` | Each Play frame |
| `on_scene_will_unload` | Before **replace** unload |
| `on_scene_loaded(path)` | After **replace** load |
| `on_layer_loaded(key)` | After successful **additive** presentation |
| `on_layer_ready(key)` | A manual layer reached GPU readiness |
| `on_layer_will_unload(key)` | Before **additive** layer unload |
| `on_scene_ready_stage(key, stage)` | Readiness change (`stage`: 0=`none`, 1=`entities`, 2=`assets`, 3=`gpu`, 4=`all`; empty `key` = world) |
| `destroy` | Session script teardown |

Sample: `hikari-games/example/assets/scripts/session.kawa`. `Actor.*` is nil in session scope.

## Editor Play / Stop / Quit

| Host | In-game Quit | Stop |
|---|---|---|
| Standalone | Closes window | n/a |
| Editor | Stops Play (no window close) | Rebinds the retained **document** world |

Play may replace/add layers. Stop clears pending ops, fires `onPlayEnded`, resets runtime UI interaction, and switches to the retained authored world (scene A), not the last play path (C).

### Play-world retirement (engine)

Stop is an immediate presentation switch followed by invisible, budgeted cleanup:

| Step | Mechanism |
|---|---|
| Enter Play | Serialize the authored document, then build a disposable Play world with its own physics backend **across ticks** on the normal scene-load budget. The retained editor world/backend pair stays parked and on screen; the click never blocks |
| Stop fence | Stop physics/audio, fire `onPlayEnded` and one `onSceneWillUnload` for Play |
| Immediate switch | Rebind renderer, physics, HostApi, panels, input, and diagnostics to the retained editor world before Stop returns |
| Invisible retirement | Budgeted Play-entity despawn; drain its dedicated physics resources; transfer only release commands to the active render queue; destroy Play after renderer/material holds drain |

Authoring is available as soon as Stop returns. A Play click during retirement captures the document and enters the cancellable **Starting…** state; construction begins after the previous copy releases its renderer borrows and Play allocator. The click never forces destruction or waits synchronously for the render thread.

At the world switch, **transfer queued GPU releases** rather than clearing the queue — those entities are already gone, so a dropped release strands its residency row for the process. A retirement always hands releases to the *authored* world (`session.editor_world orelse session.world`), never to a Play world that will itself retire. Play-owned allocations (the disposable world, its scripting driver, and its physics backend) go through `SessionCore.play_allocator`, begun before Play construction and released only after retirement. Do not route them through the permanent Platform/Physics heaps: Zig `SafeAllocator` avoids freed-slot reuse, so clean cycles would still grow the page high-water.

After all retiring-world callbacks finish, the material cache retires every entry, including game-retained and upgraded materials. The world and its allocator survive until the last render borrow is released. A slow tail is diagnosed once; neither elapsed time nor a render-thread pause makes live borrows safe to free.

While the request waits or the copy builds, `HostPolicy.isPlayPending` is true and `canAuthor` is false: the document Play will run was captured at the click, so an edit made now would be silently missing from the session about to start. The Play button reads **Starting…** for that window and a second click withdraws the request. Opening another scene or prefab withdraws it too.

Profiler CPU rings and GPU timestamp snapshots carry the producing `World.Id`.
The editor filters them against the active world, resets smoothed detail
histories at a Play/Edit boundary, and clears the live panel while the first
newly scoped frame arrives. Session-only worker timing uses world id `0`, so it
can be reported as shared activity but is never attributed to either world.
File traces include `world_id` on every frame.

### Per-World vs process-global state

Two live Worlds (parked editor + disposable Play) break every process-global singleton. Audit before adding a second of anything. Two examples:

1. `tenkai_init` is process-global while backends are per-World, so the *library* is refcounted (`retainLibrary` / `releaseLibrary` in `physics/tenkai3d_backend.zig`) while each backend keeps its own `tenkai_create_world`. `tenkai_destroy` panics while any world is alive.
2. `Component.id` / `.column` / `.store` are one slot per component per process (`defineComponent`), but both Worlds register the same components. Every world switch re-claims them via `ComponentRegistry.publishGlobals` + `SwarmRegistry.publishGlobals` from `rebindHostApiWorld` (the one choke point) and again after any world is destroyed.

When adding driver or SDK state, decide whether it is per-World or per-process, and make the per-process half refcounted or re-claimable — never guarded by a per-instance flag around a process-global. Game lifecycle hooks are already per-World (`onWorldAttach` / `onWorldDetach` take `WorldScope`); one `GameSubsystem` instance serves both Worlds at once, so per-World game state must key on `scope.id`.

### Global settings revert on Stop

The retained editor world preserves authored actors. Everything a game can change through session-global host APIs is snapshotted on Play enter and replayed on Stop (`scene_ops/play_snapshot.zig`, `SessionCore.play_snapshot`):

| Group | Setters |
|---|---|
| Graphics policy | `render().setGraphics` (tonemap, RT, shadows, reflections, AO, GI + quality, reconstruction, environment lighting, RT hit, AF) |
| Environment | `setSkyboxColor` / `setSkyboxTexture` / `…Secondary` / `setSkyboxBlend` / `setAtmosphere` + the authored clock — one `World.EnvironmentState` capture, the same value type the editor's time-of-day preview scope uses |
| Time | `setTimeScale`, `setFixedDeltaTime` |
| Scalability | `setLodPolicy` / `setLodAutoDistance` / `setMeshLodDistanceScale` / `setMeshLodFadeTime`, `hi.animation().setGlobal*`, `hi.particle().setGlobal*` |
| Debug | buffer visualizer mode, profiler overlay |
| Input contexts | the whole `world.input_contexts` stack |
| Audio mixer | master gain/mute, per-bus gain/mute, duck amounts, reverb mix |

Apply is a **diff** (only knobs that moved are written) and runs once after the retained editor world is rebound.

Not reverted, by design: per-actor state (the retained authored world owns it), input binding overrides (`config://input_bindings.json` is a user preference), `hi.settings()` package values (explicit on-disk store), and game UI scale/density (Edit never draws game UI). Adding a new global runtime setter means adding a field to `PlaySnapshot` — the comptime field-count ratchet fails the build until capture and apply both handle it.

Tutorial: [Play, Edit, and scenes — budgeted Play and Stop](/docs/tutorials/play-edit-and-scenes.md#both-directions-are-budgeted-and-neither-blocks-the-click).

### Session-owned resources

`GameSubsystem` **survives** scene replace and Stop. Use it for data that is expensive to rebuild and does not belong in scene JSON:

| Put on the session | Put in the scene / `onSceneLoaded` only |
|---|---|
| Procedural mesh atlases, shared GPU templates, large lookup tables | Authored actors, per-level props |
| Cross-level progress, audio façade, HUD chrome | Level-local spawn that must die with unload |
| One-time seed of a world-owned swarm store | Per-frame presence / binding that needs live actors |

Pattern:

1. Own heavy buffers / meshes on `GameSubsystem` fields.
2. In `onSceneWillUnload`, drop **actor refs** and visuals that name dead entities; keep the heavy buffers.
3. In `onSceneLoaded`, reuse session data; only rebuild if empty / invalidated.

This is **content hygiene**, not a special engine API — any genre can do it. Scene replace still budgets engine unload/spawn work; it does **not** budget arbitrary game code inside `onSceneLoaded`. Keep that hook light when populations are large.

Sample: `hikari-games/metropolis` keeps city procedural meshes on the subsystem across replace/Stop (optional perf for that project).

## Code map

| Concern | Location |
|---|---|
| Load queue / status | `src/hikari/src/scene/scene_manager.zig` |
| Readiness | `src/hikari/src/scene/scene_readiness.zig` |
| Layers | `src/hikari/src/scene/scene_layer.zig`, `scene/world/scene.zig` |
| Instantiate | `src/hikari/src/scene/scene_loader.zig` |
| Play copy / Stop retirement | `src/hikari/src/runtime_session/scene_ops/play_edit.zig` (`PlayWorldBuild`, `PlayWorldRetirement`) |
| World construction / ownership | `src/hikari/src/runtime_session/session/worlds.zig` |
| Kawa `Scene.*` | `kawa_host.zig` (+ `kawa_host/scene.zig`) / `kawa_backend.zig` |
| Sample | `hikari-games/example/src/session.zig` + `session_ui.zig` |

## Forbidden

- Immortal manager entities for global UI  
- Assuming additive layer ids collide safely without unique `scene_id`s  
- Clearing pending with `catch {}` that drops ownership without free  
- Expecting cancellation after commit to undo gameplay activation, or using a released handle
- Clearing play-ephemeral HUD in `onSceneWillUnload` (use `onPlayEnded`) — unload also runs on mid-play `Scene.load`  
- Reconstructing the authored world on Stop instead of switching to the retained editor world
- Reporting the retiring Play world's counters under the active editor-world identity
- Destroying Play-world materials before render residency has released its holds

See also [Scenes and gameplay](/docs/systems/scenes-and-gameplay.md), [Application lifecycle](/docs/systems/lifecycle.md), [Scripting with Kawa](/docs/guides/scripting-kawa.md).
