Skip to content
hikari
RenderingEditorAIToolchainDocumentation
Download

© 2026 Flying Rat Studio.
All rights reserved.

Explore the engineDocumentationContributorsDownloadLicenseBack to top
Documentation / Systems
Browse docs
Overview
Tutorials17
OverviewPlay, Edit, and scenesFirst UI documentFirst game projectFirst entityComponent properties and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides25
OverviewWorking in the editorDevelopment guideGameplay APIActor and component lifecycleThreads, jobs, events, and routinesGameplay AI tasksGame AI: perception and movement playgroundBrain assetsBrain execution for swarm populationsLocalizationGame cursorsChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringChoosing and scaling shadowsTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / Unreal
Systems32
OverviewArchitectureApplication lifecycleFrontends and driversPlatforms and supportSession services and cross-scene stateScenes and gameplayActor communication (hi.actors)Game-facing refsSave / replication wire versionCoordinate space and camera conventionsRenderingRenderer architecture mapFrame governorPipeline compilationSkeletal animationGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitUI documentsAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)Document schemasUI and editorEditor asset hot reloadEditor Project Selector
Language reference3
OverviewAkari GPU ABIAkari language referenceKawa language reference
Engine overview
Start exploring
  • No matching sections. Try fewer words or another topic.
NavigateEnter Openesc Close
Overview
Tutorials17
OverviewPlay, Edit, and scenesFirst UI documentFirst game projectFirst entityComponent properties and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides25
OverviewWorking in the editorDevelopment guideGameplay APIActor and component lifecycleThreads, jobs, events, and routinesGameplay AI tasksGame AI: perception and movement playgroundBrain assetsBrain execution for swarm populationsLocalizationGame cursorsChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringChoosing and scaling shadowsTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / Unreal
Systems32
OverviewArchitectureApplication lifecycleFrontends and driversPlatforms and supportSession services and cross-scene stateScenes and gameplayActor communication (hi.actors)Game-facing refsSave / replication wire versionCoordinate space and camera conventionsRenderingRenderer architecture mapFrame governorPipeline compilationSkeletal animationGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitUI documentsAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)Document schemasUI and editorEditor asset hot reloadEditor Project Selector
Language reference3
OverviewAkari GPU ABIAkari language referenceKawa language reference
Engine overview
Systems25 min read

Session services and cross-scene state

On this page
On this pageLifetimesArchitectureScene load APIModesRequest lifetime and commitPublic SDK services and ownershipWarming assets without a scene (hi.assets())Presentation (ScenePresent)Owning the moment of the swapStatus / progress / errorsReadiness stages (show-the-scene gate)Streaming (world partition)KawaLayer rules (additive)Layer / partition membership (runtime)Replace vs additive flowGameSubsystem (Zig)Kawa session scriptsEditor Play / Stop / QuitPlay-world retirement (engine)Per-World vs process-global stateGlobal settings revert on StopSession-owned resourcesCode mapForbidden Back to top

Hands-on walkthrough: Tutorials — First session services.

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

LayerOwnerSurvives scene unload?Examples
SessionGameSubsystem / GameHostYesHUD chrome, progress, audio façade, session Kawa script
SceneWorld entities + layers on GameplayAllocatorBase replace wipes all; additive layers unload selectivelyLevel geometry, props, placed lights
FrameWorld arena / frame allocatorsNoScratch 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

ModeAPIBehavior
Loadworld.requestSceneLoad(scene: SceneRef, opts: SceneLoadOptions) !SceneLoadHandleThe 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
Presentworld.presentSceneLayer(key)Open the gate on a gated / deadline / manual layer. False if unknown or already presented
Re-prioritizeworld.setSceneLayerPriority(key, priority)Move queued work for one layer; discards nothing already decoded
Unload layerworld.requestSceneUnloadLayer(key: []const u8)Despawn that layer’s actors; release layer material path retains / bundles (key is the load stem, same as SceneRef.stem())
Reloadworld.requestSceneReload()Re-load current scene_path (replace)
Cancelworld.cancelSceneLoad(handle)Cancel this queued or preparing request. Returns cancelled, committed (too late), or invalid; active rollback drains across ticks
Observeworld.pollSceneLoad(handle)Request state, phase, progress, paths, requirement failures, degraded readiness and cleanup status
Activate requestworld.activateSceneLoad(handle)Queue activation of this prepared request; recheck its identity and readiness at execution and commit
Release observationworld.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():

ServiceResponsibilityRetained values
hi.assets() → ?hi.AssetsAsynchronous asset dependency acquisition, polling, priority, cancellation and releasehi.AssetRequest; polling returns hi.AssetRequestStatus / hi.AssetRequestState
hi.packs() → ?hi.PacksExplicit storage holds, labels and pack inspectionhi.PackHandle; state(handle) returns hi.PackOpenState, stat returns hi.PackStatus
hi.world() → hi.WorldScene loading, actor/prefab instantiation and removal, World-owned prefab definitionshi.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)

PolicyBehaviour
immediateActors appear as their assets land. Default, and what the editor's authoring viewport always uses
gatedNo gameplay until the layer is GPU-ready — no Awake, Start, render, scripts, physics, audio, camera, or onSceneLayerLoaded
deadlineGated, but presents degraded once deadline_ms elapses. One slow asset must not hold a player at a loading screen
manualGated, 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:

SituationWhat happens
Cell requested, then unloaded before it was ever dispatchedThe two ops annihilate in the queue
Cell unloaded while its load is still in flightThe 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.

StageIntScopeMeaning
none0layer + worldNo loaded layers / cleared after unload
entities1layer + worldActors spawned; loaded hooks wait for presentation when the layer is gated
assets2layer + worldSoft-pending meshes/models/materials, particle systems and their sprites resolved. Failed/missing refs use error cube / pink and do not block
gpu3per-layer terminalMesh + 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
all4world composite onlyEvery 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) 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_ids 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.
  • 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).

APIBehaviour
Scene load / additive loadAuthored 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).

HookWhen
onLaunchSession/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
onTickEach 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
onPlayEndedEditor 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
onSceneWillUnloadBefore 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 / destroySession 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 fnWhen
startAfter attach
update(dt, total_time)Each Play frame
on_scene_will_unloadBefore 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)
destroySession script teardown

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

Editor Play / Stop / Quit

HostIn-game QuitStop
StandaloneCloses windown/a
EditorStops 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:

StepMechanism
Enter PlaySerialize 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 fenceStop physics/audio, fire onPlayEnded and one onSceneWillUnload for Play
Immediate switchRebind renderer, physics, HostApi, panels, input, and diagnostics to the retained editor world before Stop returns
Invisible retirementBudgeted 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):

GroupSetters
Graphics policyrender().setGraphics (tonemap, RT, shadows, reflections, AO, GI + quality, reconstruction, environment lighting, RT hit, AF)
EnvironmentsetSkyboxColor / setSkyboxTexture / …Secondary / setSkyboxBlend / setAtmosphere + the authored clock — one World.EnvironmentState capture, the same value type the editor's time-of-day preview scope uses
TimesetTimeScale, setFixedDeltaTime
ScalabilitysetLodPolicy / setLodAutoDistance / setMeshLodDistanceScale / setMeshLodFadeTime, hi.animation().setGlobal*, hi.particle().setGlobal*
Debugbuffer visualizer mode, profiler overlay
Input contextsthe whole world.input_contexts stack
Audio mixermaster 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.

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 sessionPut in the scene / onSceneLoaded only
Procedural mesh atlases, shared GPU templates, large lookup tablesAuthored actors, per-level props
Cross-level progress, audio façade, HUD chromeLevel-local spawn that must die with unload
One-time seed of a world-owned swarm storePer-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

ConcernLocation
Load queue / statussrc/hikari/src/scene/scene_manager.zig
Readinesssrc/hikari/src/scene/scene_readiness.zig
Layerssrc/hikari/src/scene/scene_layer.zig, scene/world/scene.zig
Instantiatesrc/hikari/src/scene/scene_loader.zig
Play copy / Stop retirementsrc/hikari/src/runtime_session/scene_ops/play_edit.zig (PlayWorldBuild, PlayWorldRetirement)
World construction / ownershipsrc/hikari/src/runtime_session/session/worlds.zig
Kawa Scene.*kawa_host.zig (+ kawa_host/scene.zig) / kawa_backend.zig
Samplehikari-games/example/src/session.zig + session_ui.zig

Forbidden

  • Immortal manager entities for global UI
  • Assuming additive layer ids collide safely without unique scene_ids
  • 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, Application lifecycle, Scripting with Kawa.

PreviousPlatforms and supportNext Scenes and gameplay

Documentation follows the current engine checkout.

Snapshot 86f12d68Source docs/systems/session-services.md
On this pageLifetimesArchitectureScene load APIModesRequest lifetime and commitPublic SDK services and ownershipWarming assets without a scene (hi.assets())Presentation (ScenePresent)Owning the moment of the swapStatus / progress / errorsReadiness stages (show-the-scene gate)Streaming (world partition)KawaLayer rules (additive)Layer / partition membership (runtime)Replace vs additive flowGameSubsystem (Zig)Kawa session scriptsEditor Play / Stop / QuitPlay-world retirement (engine)Per-World vs process-global stateGlobal settings revert on StopSession-owned resourcesCode mapForbidden Back to top