Skip to content
hikari
RenderingEditorAIToolchainDocumentation
GitHub
hikari

© 2026 Flying Rat Studio.
All rights reserved.

Explore the engineDocumentationContributorsLicenseBack to top
Documentation / Systems
Browse docs
Overview
Tutorials16
OverviewFirst game projectFirst entityAuthored user_data and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesPlay, Edit, and scenesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides16
OverviewDevelopment guideGameplay APIChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / UnrealActor and component lifecycle
Systems28
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 governorGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)UI and editorEditor asset hot reloadEditor Project Selector
Language reference2
OverviewAkari language referenceKawa language reference
Engine overview
Start exploring
  • No matching sections. Try fewer words or another topic.
NavigateEnter Openesc Close
Overview
Tutorials16
OverviewFirst game projectFirst entityAuthored user_data and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesPlay, Edit, and scenesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides16
OverviewDevelopment guideGameplay APIChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / UnrealActor and component lifecycle
Systems28
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 governorGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)UI and editorEditor asset hot reloadEditor Project Selector
Language reference2
OverviewAkari language referenceKawa language reference
Engine overview
Systems19 min read

Session services and cross-scene state

On this page
On this pageLifetimesArchitectureScene load APIModesWarming assets without a scene (hi.host_api.residency())Presentation (ScenePresent)Owning the moment of the swapStatus / progress / errorsReadiness stages (show-the-scene gate)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)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, 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()Drop all queued ops. In-flight jobs are not cancelled
Cancel one layerworld.cancelQueuedSceneLayer(key)Drop queued ops for one key, leaving every other cell scheduled
zig
pub const SceneLoadOptions = struct {
    mode: SceneLoadMode = .replace,      // replace | additive
    present: ScenePresent = .immediate,  // immediate | gated | deadline
    deadline_ms: u32 = 0,                // .deadline only; measured from the request
    priority: u16 = 0,                   // dispatch order, low first
};

Warming assets without a scene (hi.host_api.residency())

Not re-exported as hi.residency() from hikari_game.

zig
const r = hi.host_api.residency() 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).isReady()) 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, props a cutscene needs before its trigger. A set waits and holds — every ref becomes a residency requirement so poll is truthful, and the kinds the store can keep decoded (texture, model, audio, font) are retained until release. Material / script / particle / shader refs are require-only; their consumers retain at use time.

poll is the only read — never block on a set from the game thread. Sets are session-owned, so they survive scene loads, and session teardown releases anything left behind.

Presentation (ScenePresent)

PolicyBehaviour
immediateActors appear as their assets land. Default, and what the editor's authoring viewport always uses
gatedNothing past Awake until the layer is GPU-ready — no render, Start, 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.

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 onWorldAttach(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.
        hi.world().requestSceneLoad(
            hi.SceneRef.must(menu_layer),
            .{ .mode = .additive, .present = .manual },
        );
    }

    /// 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 opening the gate 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 layer's actors exist and have run 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 are a priority queue with dedupe (identical waiting requests fold, keeping the better priority; 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 three 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 load requested for a layer that is already loaded is folded, so a level-triggered driver can re-offer its whole ring every tick.

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 — same moment as onSceneLoaded / onSceneLayerLoaded
assets2layer + worldSoft-pending meshes/models/materials 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); map rebinds pushed for visible primitives. Soft-missing / failed refs 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
world.requestSceneLoad(hi.SceneRef.must("scenes/messaging"), .{});
// …each tick…
const snap = world.sceneLoadSnapshot();
if (snap.status == .failed) { /* show error */ }
else if (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.

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)

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 creates stable stand-ins and leaves material/mesh/texture warming to the live world's worker prefetch.
  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: src/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 load
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 src/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 load
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: src/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 DebugAllocator 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 that shipped as crashes:

  1. tenkai_init is process-global while backends are per-World, so the library is refcounted (retainLibrary / releaseLibrary in physics/tenkai3d/tenkai3d.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.host_api.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: src/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, 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
Samplesrc/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 cancelSceneLoad to abort an in-flight instantiate / unload job
  • 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 cc148c75Source docs/systems/session-services.md
On this pageLifetimesArchitectureScene load APIModesWarming assets without a scene (hi.host_api.residency())Presentation (ScenePresent)Owning the moment of the swapStatus / progress / errorsReadiness stages (show-the-scene gate)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