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) | 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 |
| 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() | Drop all queued ops. In-flight jobs are not cancelled |
| Cancel one layer | world.cancelQueuedSceneLayer(key) | Drop queued ops for one key, leaving every other cell scheduled |
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.
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 retainsFor 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)
| Policy | Behaviour |
|---|---|
immediate | Actors appear as their assets land. Default, and what the editor's authoring viewport always uses |
gated | Nothing past Awake until the layer is GPU-ready — no render, Start, 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.
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
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 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
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 — same moment as onSceneLoaded / onSceneLayerLoaded |
assets | 2 | layer + world | Soft-pending meshes/models/materials 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); map rebinds pushed for visible primitives. Soft-missing / failed refs 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:
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)— 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.
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
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;immediatecreates stable stand-ins and leaves material/mesh/texture warming to the live world's worker prefetch. - 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: src/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 load |
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 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 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 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) |
destroy | Session script teardown |
Sample: src/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 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:
tenkai_initis process-global while backends are per-World, so the library is refcounted (retainLibrary/releaseLibraryinphysics/tenkai3d/tenkai3d.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.host_api.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: src/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, 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 | src/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
cancelSceneLoadto abort an in-flight instantiate / unload job - 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.