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
| 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
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 |
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.
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())
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 retainsFor 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.
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
mainkey 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
spawnfrom 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-worldreplaceholds 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
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 countersTypes: 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 reportsall)pending_assets/pending_gpu— coarse counters for progress UI
World composite (sceneLoadSnapshot()):
ready_stage— min across layers; becomesallwhen every layer is atgpupending_assets/pending_gpu— sums (+ shared create-queue inflight)generation— bumps on queue/complete/fail/cancel and ready-stage change
Typical loading screen:
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)— emptykey= world composite - Kawa:
on_scene_ready_stage(key, stage)—stageis 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:
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 cameraA 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
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 = cameraScript 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
MaterialCacheretain/release; shared paths refcount across layers; unload releases that layer’s retains. - Store assets (textures / meshes / models / audio / blobs):
AssetStoreretain/release; materials hold texture retains; primitives hold engine-mesh retains;releaseBundledoes not wipe decoded assets still referenced. Full map: Asset residency. - Full scene replace is overlap-safe:
beginReplacePreloadholds 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).
| 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):
- Queue the stem; next tick starts the job (
layer_keyis always"main"). Sessionscene_pathupdates when instantiate begins. - Document + budgeted planning/material identity preparation while scene A stays live (preload still simulates).
gated/deadline/manualalso parse materials and finish bulk store warming here;immediatealso warms bulk geometry and materials before activation, while allowing GPU preparation to finish progressively. - Budgeted spawn of B with
deferred_unload— A still renders. - Same frame as finalize:
onSceneWillUnload/ session script hook → unload A → commit B →onSceneLoaded. - Readiness →
entities(thenassets/gpu/ worldallas binds complete). Underimmediate, 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:
- Resolve + parse
- Instantiate into live world as new layer
- No session path change, no full unload hooks
onSceneLayerLoaded(key)/ sessionon_layer_loaded(key)(key= request path)- Layer readiness advances
entities→assets→gpu; worldallonly when every layer is atgpu
Unload layer:
- Resolve pending key; unknown layer → error
onSceneLayerWillUnload(key)/ sessionon_layer_will_unload(key)- Destroy layer entities + release layer resources
- Recompute readiness (world may leave
allif 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:
tenkai_initis process-global while backends are per-World, so the library is refcounted (retainLibrary/releaseLibraryinphysics/tenkai3d_backend.zig) while each backend keeps its owntenkai_create_world.tenkai_destroypanics while any world is alive.Component.id/.column/.storeare one slot per component per process (defineComponent), but both Worlds register the same components. Every world switch re-claims them viaComponentRegistry.publishGlobals+SwarmRegistry.publishGlobalsfromrebindHostApiWorld(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.
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:
- Own heavy buffers / meshes on
GameSubsystemfields. - In
onSceneWillUnload, drop actor refs and visuals that name dead entities; keep the heavy buffers. - 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_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(useonPlayEnded) — unload also runs on mid-playScene.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.