Skip to content
hikari
RenderingEditorAIToolchainDocumentation
Download

© 2026 Flying Rat Studio.
All rights reserved.

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

Scenes and gameplay

On this page
On this pageGame packageEntity authoringChoosing component storageGameplay tags (catalog only)Active / enable trioScene dataCamera transformsParenting and socketsScene packs and asset refsScene environmentRender geometry is one slotExtensible componentsArchetype capabilities vs authored paramsGame subsystem and session servicesProject settingsFocus streamer (open / large worlds)World partition (authoring)Cook splitRuntime driverRuntime geometry (generated visual + collision)Runtime commandsFrame timing and time scaleGameplay kits: motor and camera rigCharacterMotorCameraRig Back to top

Hands-on walkthrough: Tutorials — First entity.

Game package

A game package exports its typed ProjectConfig and generated content_manifest from hikari-games/example/src/root.zig. Zig supplies defaults; optional configs/*.json solo-file overlays win at session boot.

The sample package organizes gameplay under hikari-games/example/src/entities/, scenes under hikari-games/example/scenes/, solo configs (including input actions) under hikari-games/example/configs/, raw assets under hikari-games/example/assets/, and optional game data alongside them.

Entity authoring

defineActor is the public actor-authoring surface. An actor always has a transform. Capabilities and game logic are a tuple of components: engine builtins (hi.ComponentRender, hi.ComponentPhysics, …) and game defineComponent types. Older .behaviour / capability-bool forms are compile errors.

The host always registers engine builtins before game content: core placeables empty / light / camera / visual_zone / decal / fog_volume, plus soft hosts _asset / _script / _audio / _particle / _missing (src/hikari/src/scene/builtin_entities.zig). Games must not redefine those names — add genre variants under a new archetype (e.g. sample fly_camera).

zig
const hi = @import("hikari_game");

const PlayerLogic = hi.defineComponent(.{
    .name = "player_logic",
    .storage = .embedded,
    .data = struct {
        speed: f32 = 5,

        pub fn update(self: *@This(), actor: hi.ActorContext, ctx: *const hi.TickContext) void {
            const id = actor.id;
            _ = .{ self, id, ctx };
        }
    },
});

pub const PlayerEntity = hi.defineActor(.{
    .archetype = "player",
    .components = .{ hi.ComponentRender, hi.ComponentPhysics, PlayerLogic },
});

The authoring contract, scoped callback API, typed fields, and runtime state convention are described in Gameplay API.

Choosing component storage

Actor components default to .attachable; .embedded keeps component data in an actor's fixed payload. defineSwarm creates a separate population with field-wise arrays and no actor identity. Actors can mix attachable and embedded components, including components with empty Data.

See Choosing component storage for a comparison, strengths and limitations, and complete examples for each choice.

Swarm support: defineSwarm(.{ .name = "crowd", .data = CrowdData, .capacity = N }) auto-creates a world-owned multi-field SoA SwarmStore on component register. The world swarms phase (after post_physics) calls optional Data.integrate(store, dt) (preferred bulk path) or per-row Data.update, then drains deferred retirements. Games access the store via hi.swarm(C) (spawn / retire / column / setVisual / setVisibleIndices). Instanced opaque draws reuse shared mesh templates — no Entity / RenderComponent proxy.

Logic components can supply awake, start, onEnable, onDisable, update, lateUpdate, fixedUpdate, onCollision, onAnimationEvent, onMessage, onChanged, onEnvironmentPreview, onDestroy, and optional deinit. A logic component may also declare .script — a Kawa script attached once per component instance with data seeded from that component's fields, alongside or instead of Zig hooks (engine builtins and .swarm components excepted); see Scripting with Kawa. Optional .tick_rate (every frame, every N, or distance_lod) is declared on the component. Distance significance is filled by default: configs/game.json → world.lod_auto_distance is true, so a budgeted sweep writes primary-camera distance for every actor. hi.world().setLodDistance is sticky and wins over auto; setLodAutoDistance(false) is full manual. Tick-rate bands only change rates when world.lod_bands is not the engine default (every band rate 1) — otherwise the tick machinery stays off even if distances are filled (animation cull/rate and particle sleep can still consume those distances). Add an entity source file; the build scans and generates the manifest rather than requiring a hand-maintained registry. With the CPU profiler built in, optional Entity timing measures update, script update, onCollision, and onMessage only (not awake/start/onDestroy); see Application lifecycle.

update runs in the entity tick group (default .update); lateUpdate always runs in .post_update. Optional defineActor .tick_priority orders archetype batches within a group (higher first; tie-break registration). fixedUpdate is opt-in via hi.world().setFixedDeltaTime and runs after post_physics, before update — not a TickGroup. Full ordering: Tick groups.

Cross-actor gameplay traffic uses hi.actors / Actors.* for discovery and messaging: tryFind, e.sendEventTo / sendEventToFrom (self as sender). Typed Zig field access uses target.component(Health).get(.hp) / set(.hp, value), with the same methods for strings and AssetRef; dynamic names use hi.actors / Actors.* field helpers. Zig onMessage and Kawa on_message receive messages (Zig components first, then all script slots). Game modules never hold Entity pointers. Full guide: Actor communication. Same-actor typed siblings: ActorContext.get / require in lifecycle callbacks.

Gameplay tags (catalog only)

Actor tags are not free-form strings. Project catalog configs/gameplay_tags.json (kind: com.hikari.gameplay_tags) is loaded at session boot into the world intern table. addTag / scene "tags": [...] only apply ids already registered; unknown names are rejected (scene apply warns once and skips). Query with hi.actors.withTag("enemy", buf) / Kawa Actors.has_tag.

Editing. The inspector Tags section shows an actor's tags as dismissible chips; the trailing + Add tag chip opens a popup listing catalog ids the actor does not carry, with Manage tags… as its last row. A chip drawn in the destructive colour is a tag the catalog no longer holds — authored data the next scene load would drop.

The catalog itself is edited in File → Project Settings… → Gameplay Tags (src/hikari/src/editor/gameplay_tags/page.zig): add / relabel / remove ids, with the per-tag count of live actors carrying it. Apply or Save rewrites the document and re-syncs the open world (World.reloadGameplayTagsCatalog) — a retired id leaves the catalog and the actors holding it in the same frame, and ids that survive keep their TagId (retired slots are tombstoned, never reused-in-place). Save is refused when the file held entries the page could not represent, so a full-array rewrite can never silently drop them.

Active / enable trio

The actor and component lifecycle guide lists every hook, exact dispatch source, ordering, and component scope.

Three independent switches, AND’d at runtime for effective behaviour:

LevelScene / component fieldHost API (game modules)Effective behaviour
Entityactor "active" (default true)hi.world().setActive / isActiveOff → no Zig/Kawa update, no messages/collisions delivery, not in active/native-update batches. Physics and render also treat the entity as off.
Physics"components": { "physics": { "is_active": … } } (default true; JSON alias "enabled")hi.world().setPhysicsActive / isPhysicsActiveAuthored component switch. Systems use effective = entity active and presented and is_active. Off → no body create/sync; existing Tenkai body is released (re-enable recreates). Forces/velocity no-op when not effective.
Render"components": { "render": { "is_visible": … } } (default true)hi.render().setVisible / isVisibleAuthored visibility. Draw uses effective = entity active and presented and visible and not soft-pending. Off hides draws only; mesh resources stay bound.

isPhysicsActive / isVisible return the component field. Entity isActive is authored active and presented (gated layers set FLAG_UNPRESENTED; host-only Entity.isAuthoredActive is the raw switch). Systems AND with that entity isActive(). Lights skip inactive owners even when their own enabled flag is true.

zig
hi.world().setActive(id, false);           // whole actor off
hi.world().setPhysicsActive(id, false);    // body out of sim; mesh can stay
hi.render().setVisible(id, false);         // hide draw; physics can stay
json
{
  "id": "crate",
  "archetype": "cube",
  "active": false,
  "components": {
    "render": { "is_visible": true, "mesh": "asset://./models/cube", "material": "asset://./materials/cube" },
    "physics": { "body_type": "Dynamic", "is_active": true, "collider_shape": "Box", "collider_box_size": [1, 1, 1] }
  }
}

Editor inspector: Active on the identity header; Enabled under Physics; Visible under Render. Live edit uses ActorMutation.active and component-field paths (physics.is_active, render.is_visible).

world_api_version_current / render_api_version_current bump when these HostApi entries change; rebuild game modules against the host after a bump.

Scene data

Scenes use the flat com.hikari.scene version 1 document. Actors are top-level; pack dependencies are derived from their asset references, and capability and plugin data live under each actor's "components" object. Shinra compiles the validated document into a schema-neutral binary value tree (HSC1) inside the cooked container. The runtime loads that tree only, in every build (no JSON parse). The editor keeps the live document as UTF-8 JSON. On startup, the engine registers the game manifest, loads the configured scene, then instantiates actors by archetype.

Camera transforms

Scene transforms follow the engine-wide coordinate convention: right-handed, +Y-up, with camera forward along local -Z. rotation_euler is [pitch, yaw, roll] in degrees. Consequently, an identity camera placed on positive Z looks toward the origin when the target has the same X/Y; placing that camera on negative Z requires yaw 180.

For an unparented camera aimed at a world-space point, let delta = target - position:

text
pitch = degrees(atan2(delta.y, sqrt(delta.x^2 + delta.z^2)))
yaw   = degrees(atan2(-delta.x, -delta.z))
roll  = 0
json
{
  "id": "camera",
  "name": "Camera",
  "archetype": "camera",
  "transform": {
    "position": [0, 3, 10],
    "rotation_euler": [0, 0, 0],
    "scale": [1, 1, 1]
  },
  "components": {
    "camera": {
      "fov": 60,
      "near_plane": 0.1,
      "far_plane": 1000,
      "camera_type": "perspective"
    }
  }
}

The example looks horizontally toward [0, 3, 0]. For a parented camera, rotation_euler is local; account for the parent's world rotation rather than applying the world-space formula directly. As a final authoring check, the dot product of camera forward and target - position must be positive.

Parenting and sockets

parent_id names the actor this one hangs from; its transform becomes local to that parent. parent_joint narrows the hang point to a joint of the parent's skinned rig — a sword in hand_r, a lamp on a helmet. The child follows the joint as the parent animates, its own transform being an offset from the joint. The name is resolved against whatever rig the parent binds, so a model swap that keeps its joint names keeps the socket; before the rig is bound the child hangs from the parent's origin. Reparenting drops the socket (it named a joint of the old rig). Inspector: select a parented actor → Attach to joint. Runtime: hi.world().attachToJoint / setParentJoint, Kawa Actor.attach_to_joint. See Skeletal animation.

json
{ "id": "sword", "archetype": "prop", "parent_id": "hero", "parent_joint": "hand_r",
  "transform": { "position": [0, 0.08, 0], "rotation_euler": [90, 0, 0], "scale": [1, 1, 1] } }

Scene packs and asset refs

Full URI rules: Assets and Shinra — Asset URIs and packs.

Scenes do not author a packs list. The loader derives the required pack closure from asset references and each pack’s dependencies, retains it for the layer, and releases it on unload. Shared packs remain open while another owner retains them. Session startup retains packs labeled boot; layout validation requires that label on system roles (scenes, shaders, and content). An obsolete scene packs field is ignored with a warning.

Two URI forms (extension-free stems; store maps kind → .shin*):

FormUse
asset://shared/models/cubeExplicit pack — pack must be retained
asset://./models/cubeUnscoped absolute path — catalog picks pack; that pack must be retained. Not relative to the scene; not rewritten to bundle
json
{
  "kind": "com.hikari.scene",
  "version": 1,
  "id": "damaged_helmet",
  "name": "Damaged Helmet",
  "actors": [{
    "id": "crate",
    "archetype": "cube",
    "components": {
      "render": {
        "mesh": "asset://./models/cube",
        "material": "asset://./materials/cube"
      }
    }
  }]
}

Art membership: configs/layout.json (role: "assets" + include globs). Include globs use first-match order; put specific packs before catch-alls. The sealed catalog gives each logical path one owner.

Missing or failed content refs are soft (error cube / pink material / _missing). Details: soft refs.

Scene environment

environment is first-class scene-root authoring data, not a special gameplay component. It selects the persistent default global source while retaining both source configurations:

json
{
  "environment": {
    "source": "atmosphere",
    "skybox": {
      "primary": "asset://./textures/studio",
      "color": [1, 1, 1, 1],
      "blend": 0
    },
    "atmosphere": {
      "time_of_day": 8.5,
      "day_of_year": 172,
      "latitude_degrees": 44.5,
      "longitude_degrees": 8.9,
      "utc_offset_hours": 2,
      "world_north_degrees": 82,
      "sun_light_id": "sun",
      "aerial_perspective_distance_scale": 1,
      "aerial_perspective_strength": 1
    }
  }
}

The editor's Environment → Scene Environment controls write this block through normal document history, including cubemap asset picking. The active branch is resolved after scene actor IDs and transforms are registered and before component Start; optional sun/moon light IDs therefore work without a script, while gameplay controllers can deliberately override the default during startup or later. It is applied only for a whole-world scene load. Additive layers cannot silently steal ownership of global sky and environment lighting.

See Time of day for editor preview, runtime control, and production day/night choreography.

Render geometry is one slot

An actor draws one geometry, authored under the key that names its kind:

KeyAssetDrawsMaterials
"model".model.json placeable (asset://…/Foo.model.json)one per partmaterials[i] per slot; omitted slots keep the cooked material
"mesh"one cooked engine mesh (extension-free stem)onematerial (plus optional albedo override)

Both keys on one render block is a scene error, not a precedence question — the validator reports it and the loader keeps model with a warning. Internally the two decode to a single SceneRenderDesc.geometry union whose tag is the asset kind, so no consumer carries a "which field wins" rule and an assignment cannot leave the other key behind. Runtime spawn spells the same slot as one kind-tagged ref (SpawnRender.geometry, .model_doc or .model).

Extensible components

components is an open map. Engine capabilities use reserved names such as render and physics; game/plugin components use their stable registry name and an object payload:

json
{
  "components": {
    "render": { "model": "asset://./models/crate.model.json" },
    "health": { "maximum": 100, "regeneration": 2.5 }
  }
}

The scene format and HSC1 cooker do not contain a closed component enum or duplicate plugin schemas. defineComponent maps the generic value tree into component data; plain value-semantic data receives a strict generated decoder, while owning data implements fromSceneValue. The editor snapshots live rows as authoring JSON before saving.

Reflected authoring schema

Engine, game, and plugin components expose one normalized static field shape: path, value kind, arity, optional/enum information, presentation options, and widget hint. defineComponent derives it from Data. Each engine built-in has one public module under scene/components/<name>.zig that owns its authored Desc, optional presentation rows, catalog presentation/placement, document normalization, runtime target, and structural/picker field policy. scene_asset.zig, the editor catalog/schema, and live-field routing consume those declarations instead of maintaining parallel tables. Inspector rendering and editor automation consume the same normalized shape.

Ordinary supported fields require no metadata. Add PropertyOptions only for semantics reflection cannot infer (label, tooltip, category, min/max/step, order, widget, hidden, read_only, or transient). Adjacent fields with the same category render in one nested, collapsible inspector subsection. runtime_only suppresses the authoring schema while preserving runtime field access and JSON-safe scene seeding. A transient field is runtime state: it stays reachable by name at runtime (hi.actors, target.component(Component).getPath(..), Kawa Actors.*) so a script can latch it, but it has no editable inspector row (read_only may expose live diagnostics), is never written to the scene payload, and is never replicated. A hidden field is the opposite trade — authored data that round-trips without an inspector row.

This does not turn runtime components into property bags. Values remain typed component storage; reflected rows are static declaration data, with no per-actor allocation or per-frame reflection. Zig only emits declarations reached by a product, so editor-only consumers do not pull inspector code into a game build. A shipped game may retain a small amount of schema/code data when its game module exports authorable components, but it pays no instance or tick cost.

Strings a scene sets — a []const u8 field, or the uri inside an AssetRef — are copied into the world's string pool as the row decodes, and the row borrows from there. That is the same owner a live hi.actors string write goes through, and it is why no row ever points into the parse buffer the load used. Data that owns heap of its own says so with deinit + fromSceneValue and keeps its own bytes.

removed_components removes inherited built-ins or .attachable archetype components for one actor. A component cannot be both authored and removed. Embedded component storage cannot be removed per instance.

Archetype capabilities vs authored params

Zig archetypes declare capabilities by listing hi.ComponentRender, hi.ComponentPhysics, etc. Scene JSON may omit parameter blocks under "components" for those capabilities. One merge rule fills the gap:

scene_actor_materialize.materialize(desc, components)

  • Missing camera / light blocks get component defaults.
  • Missing physics block on a physics-capable archetype → Static body (level-safe). Scene "physics" with omitted body_type also defaults to Static. Runtime spawn (SpawnPhysics) still defaults to Dynamic.
  • Authored blocks win.
  • Render: if the archetype has render and the actor has no usable material (components.render missing, or a non-placeable geometry without material), seed material to asset://./materials/default (see default_material_uri). GPU primitive create skips draws when material is null — this is the only seed path so bare spawn / UI-add / runtime spawn stay visible once a mesh exists (logic procedural mesh or authored geometry).

Call sites: scene load, live spawn (spawnActor), editor document attach, add-actor. Do not add a second seed path for default materials.

An actor whose graph extracts root motion picks its consumer with "animation": { "root_motion": "transform" | "script" | "off" } — the engine advances the transform, gameplay reads hi.animation().rootMotion and drives a controller, or nothing is extracted at all. Default transform. Details: Skeletal animation.

Authorable behavior fields are reflected automatically. Use sparse PropertyOptions for presentation and policy such as min/step, widget, ordering, transient, and read-only behavior. Add new authorable component fields through the unified document/world component-field channel rather than a new mutation variant.

Game subsystem and session services

GameSubsystem is optional. The engine supplies a no-op default. A custom subsystem is the session services root: it outlives scene unload/load and owns cross-scene work (HUD, progress, session Kawa scripts). Scene JSON holds only placeable content.

Hooks include onLaunch / onTick / onTerminate, onWorldAttach / onWorldDetach / onPlayEnded (two-World Play: one GameSubsystem, per-World state keyed by scope.id), plus onSceneWillUnload / onSceneLoaded (replace), onSceneLayerLoaded / onSceneLayerReady / onSceneLayerWillUnload (additive), and onSceneReadyStage (progressive none → entities → assets → gpu → world all). Runtime scene switches all go through one entry point — requestSceneLoad(ref, .{ .mode = .replace | .additive, .present = … }) — plus unload layer, cancel, load snapshot / layer readiness, and presentSceneLayer for a manual gate. Full rules: Session services (readiness stages).

Project settings

Composed session config merges Zig defaults with optional authored/staged JSON overlays and then writable per-user overlays (later layers win) for:

  • game and editor window / render / driver recipes;
  • startup scene and input-action bindings.

The sample enables TAA for the game and keeps editor chrome at native drawable resolution outside the scene graph. See Rendering and Frontends and drivers for the resulting runtime composition.

Games use hi.config for complete JSON documents. Reads resolve the per-user override first and then the authored/staged default. save writes only the per-user layer under the platform application-data root; reset removes that override. Custom configs/*.json documents may use any JSON schema. Known engine documents are kind/version checked, and save stamps their canonical header.

An Options screen uses one call, hi.config.applyPlayerSettings. It applies what can change live and merges the change into the player's sparse configs/render.json / configs/game.json in the user layer. Only fields the player has changed are ever written, so a later game patch that changes a project default still reaches players who never touched that setting. A preset in the change drops the player's earlier per-tier overrides, as it does live.

zig
const applied = try hi.config.applyPlayerSettings(allocator, .{
    .render = .{ .quality = .{ .preset = .high, .anisotropic_filtering = .x16 } },
    .window = .{ .mode = .fullscreen, .vsync = false, .frame_rate = .hz_120 },
    .texture_budget_mb = 2048,
});
if (applied.restart_required) showRestartHint();
SettingApplies
Everything in render (preset, tiers, features, HDR display, upscaler)Live
window.vsync, window.frame_rateLive (standalone game). macOS keeps presenting on the display link: VSync off removes the pacing hint but does not uncap; D3D12 switches the Present interval
texture_budget_mb (64 MiB–64 GiB)Live (standalone game); streamed textures refine again from their coarsest mip
window.mode, width, height, display_index, resizableNext launch (restart_required): the native window exists before the game's first callback

The live parts are also plain setters for immediate feedback: hi.render().setVSync, setFrameRateLimit, setTextureBudget, setGraphics. Read the current values back from hi.world().runtimeInfo().graphics: .quality, .features.requested, .display, .reconstruction and .host (launch window plus live VSync, frame-rate cap and texture budget; .host.live is false in the editor, where Play saves player settings but keeps the editor's pacing and the project's texture budget). hi.config.resetPlayerSettings removes both override files; the project's values apply from the next launch. hi.config.loadUser reads the player's override alone.

Saved settings need no code at boot: the host composes the user layer over the shipped configs before it opens the window or creates the renderer, so the first frame is already at the player's quality, window mode and pacing.

A master "Graphics quality" choice is one field: quality.preset resets every cost tier, and any tier the player then customises rides in the same patch on top of it (see Quality preset). hi.render().setQualityPreset(.low) is the live-only shorthand, and setBloomQuality / setLensFlares cover the two common player toggles. A user layer carrying a preset overrides the project's explicit tiers when the documents compose, which is what a player's choice should do.

Use hi.world().runtimeInfo().graphics and hi.render().displayInfo() for the current effective/capability-resolved state shown by the UI; graphics.quality.preset is the preset the tiers were last reset to. The config document is persistence policy, not a renderer-state mirror.

Focus streamer (open / large worlds)

Genre-agnostic cell residency around a focus (player, camera, interest). Not a voxel kit — city tiles, nav sectors, additive rooms, and craft chunks use the same loop.

TypeRole
hi.StreamGridPure schedule: plan loads/unloads from focus
hi.StreamerFull loop: plan scratch + confirm + optional pump(hooks)
hi.OverlayStore(T)Session values keyed by cell (edits that outlive unload)
zig
var streamer = try hi.Streamer.init(allocator, .{ .grid = .{ .load_radius = 3 } });
defer streamer.deinit();
var overlay = hi.OverlayStore(MyEditBlob).init(allocator);
defer overlay.deinit();

const plan = streamer.plan(player_pos);
for (plan.unloads) |cell| {
    if (takeEdit(cell)) |blob| _ = try overlay.put(cell, blob);
    despawnCell(cell);
    streamer.noteUnloaded(cell);
}
for (plan.loads) |cell| {
    const restored = overlay.take(cell);
    if (spawnCell(cell, restored)) streamer.noteLoaded(cell) else streamer.noteLoadFailed(cell);
}

Scene ids: markForTermination drops the scene-id index immediately, so a streamer may unload and respawn the same stable name ("chunk_3_-1") in the same game-subsystem tick without SceneActorIdConflict. ActorRefs still invalidate on mark; the entity frees later on purge.

Scene swaps detach outgoing actors atomically and budget their destruction across later frames. After termination callbacks, render watches and dirty entries are removed, primitive releases are queued, and borrowed material pointers are cleared. This happens before scene material retains are released: the cleanup pool still owns component rows that material scans can visit. GPU resources remain governed by the render queue and its material borrows; actor storage is freed by the purge.

Orthogonal to authored scene-layer streaming (requestSceneLoad with .mode = .additive). See sdk/src/streaming.zig.

Craft meshes border faces and corner ambient occlusion against loaded neighbor data, saved session edits, or a generated one-column terrain border, in that order. Chunk residency therefore does not change the mesh: streaming leaves existing visual and collision geometry intact instead of replacing it and briefly dropping its draw. Only block edits dirty affected chunks, including diagonal neighbors for edits at corners. Generated border heights are computed once per column on mesh workers; interior chunks with all neighbors available need no border generation.

World partition (authoring)

An open world stays one scene document. The scene root says how it is gridded, and each actor either follows the grid or is pinned to the always-loaded base layer. The cook and the runtime driver that turn cells into additive layers build on this block; the editor authors it.

json
{
  "partition": {
    "enabled": true, "cell_size": [64, 0, 64], "load_radius": 2, "unload_padding": 1,
    "grids": [ { "name": "landmark", "cell_size": [256, 0, 256], "load_radius": 1, "unload_padding": 1 } ]
  }
}
json
[
  {
    "id": "sun",
    "archetype": "light",
    "partition": {
      "pinned": true
    }
  },
  {
    "id": "castle",
    "archetype": "entity",
    "partition": {
      "grid": "landmark"
    }
  }
]
FieldMeaning
enabledOff (default, and the whole file when omitted) = one layer, as every scene has been. Nothing about a plain scene changes.
cell_sizeDefault grid: world size of one cell. 0 on an axis leaves it undivided; [64, 0, 64] is a grid of columns.
load_radius / unload_paddingDefault grid: the hi.StreamGrid ring and hysteresis the driver will use (ScenePartitionDesc.gridConfig).
gridsUp to four named grid levels above the default one, each with its own cell_size, load_radius, unload_padding. Names are [a-z0-9_]{1,24}, unique, never default. A coarse grid with a short radius keeps a landmark loaded from far away while the props around it stream on the default grid.
actor partition.pinnedAlways loaded: the actor and everything parented under it live in the base layer. Written only when true.
actor partition.gridThe named grid a root actor streams on; absent = the default grid. Ignored on a parented actor, which follows its root. Must name a declared grid: a partition edit that renames a grid in place takes its actors along, and one that removes a grid returns them to the default grid, in the same undo step.

The cell rule is the root ancestor's world position on the root's grid (scene/scene_partition.zig, one function shared with hi.StreamGrid through streaming.cellOf). Level 0 is the default grid and level n is grids[n - 1]; ScenePartitionDesc.level(n) is the grid, levelOf(name) the lookup. A parented actor travels with the top of its hierarchy, so a lamp on a streamed house never outlives the house. Position rather than bounds on purpose: the cook has no mesh bounds, and the two would disagree the day a large prop had an off-centre pivot. Bounds are a diagnostic: an actor whose live volume reaches spill rings past its home cell pops in late, and at or beyond the load radius it can be seen before its cell loads — the editor lists those and suggests pinning.

Editor surfaces (all document edits, undoable, agent-addressable):

  • World Partition panel (left dock): enable, a Default grid section (cell size as one X/Y/Z row, load radius, unload padding), a Named grids section with + on its title line and one collapsible group per grid (name, size, radius; × on the group's title line removes it; a typed name lands when the field loses focus); then the report — occupied cells and extent, the resident ring in cells and metres, the fullest cells, and the spanning actors (pop-in risk in the warning colour; a click selects).
  • Viewport show-flag (toolbar, cells icon): the ruling around the camera, occupied cells outlined, the selection's cells accented, at-risk cells in the warning colour, coordinates labelled once a cell is wide enough on screen. Only draws for a scene whose partition is enabled.
  • Inspector → World Partition (partitioned scenes only): Always loaded switch, a Grid choice on root actors of a scene with named grids, plus the cell the actor lands in (prefixed with its grid's name on a named level), or which pin holds it in the base layer.
  • Hierarchy search: is:pinned, grid:<name>, grid:default.
  • MCP: scene_get reports the root partition (with grids) and per-actor pinned / grid; scene_edit takes set_partition (path = enabled | cell_size | load_radius | unload_padding | grid_count | grids[n].name | grids[n].cell_size | grids[n].load_radius | grids[n].unload_padding, value, optional index; growing grid_count appends grids named grid_n), set_pinned (id, pinned) and set_grid (id, grid; empty = default).

Cook split

Shinra cooks a partitioned scene into a base layer plus one layer per occupied cell (src/shinra/src/cook/scene_partition.rs, the Rust mirror of the Zig rule above — same f32 floor division, same root-and-pin placement, prefab instances placed by their instance):

OutputContent
scenes/world.shinsceneevery root field (environment, name, the partition block), the pinned hierarchies, partition.cells: the default grid's cells that have a layer, and partition.grid_cells: the same per named grid, keyed by name
scenes/world/cells/cell_<x>_<y>_<z>.shinscenea minimal scene envelope around the hierarchies rooted in that cell of the default grid; primary_camera_id travels with whichever layer holds the camera. The layer has no partition block and its actors carry none either: the split consumed them, and the layer's path is its membership
scenes/world/cells/<grid>/cell_<x>_<y>_<z>.shinscenethe same for a cell of a named grid

An unpartitioned scene cooks exactly as before. Cell layers a cook stops producing (an actor moved, the partition switched off) are swept and announced as removed; only files named like a cell layer are touched, so an author's own files under cells/ survive. The cook stamp records every output, so a cache hit republishes the cells too. Cell layer stems come from scene_partition.cellLayerPath on the Zig side and cell_layer_rel on the Rust side; they must not drift.

A pin anywhere in a hierarchy keeps the whole hierarchy in the base layer, on both sides: a child can never sit in a different layer than its parent, because the layer that owns the parent destroys the child.

Runtime driver

scene/world/world_stream_driver.zig connects the pieces that already existed. When a whole-world load finalizes into a world that streams (World.streamsPartitions: every world except the editor's authored one), the world adopts the scene's partition: one hi.StreamGrid per level from each level's gridConfig(), the cell manifest per level, and the base stem. Every level plans around the same focus; a named level's cells dispatch with their ring distance scaled by its cell size relative to the default grid, so the prop under the player still goes first. Once per tick, ahead of scene-op dispatch (applyPendingSceneChange), the driver plans around the focus — the primary camera, or a game-set World.setStreamFocus — and turns the plan into ordinary layer ops: requestSceneLoadOpts(cell stem, .additive, priority = ring distance) for cells the manifest names, requestSceneUnloadLayer for cells that left the padded ring. A planned cell the manifest does not name is resident by definition. The grid's loop closes from the layer list: a cell is loaded once its layer exists, failed once nothing in the queue, the pool or the layer list knows its key (retry with backoff is the grid's). World.streamWarmingUp is the loading-screen gate: true from adoption until the first ring around the focus is resident and drawable — offered once, nothing still in flight, and every loaded layer at .gpu (materials, meshes, textures, particle kernels), the bar a gated scene load presents on. Cells themselves load .immediate, so the stage check is what keeps a loading screen up over a ring that exists but is still building. A failed cell or a failed build releases it and shows up as failed instead.

Games reach the driver through hi.world(): setStreamFocus(?[3]f32) centres the ring on something other than the primary camera (the player under a top-down view; null returns to the camera), and streamSnapshot() returns hi.StreamSnapshot — active, warming_up, the focus cell, and authored / resident / pending / failed cell counts — for a loading screen or a debug line. Kawa: Scene.stream_warming_up() and Scene.set_stream_focus(x, y, z) / Scene.set_stream_focus(). The example game's session HUD holds a "streaming cells · n / m" line on warming_up.

A whole-world load of a partitioned scene spawns its base layer only (scene_partition.splitAssetInPlace in startSceneLoadJobFromScene, before preload retains anything). A cooked base is already just that; an editor document carries every actor and is split in the job's arena, deriving the cell list from the actors it drops. So Play in the editor streams the same way a shipped build does, with cells read from the cooked layers in the cache — save before Play to see edits to streamed actors. The editor's authored world never streams and shows the whole document.

In the editor, Play mirrors the Play world's grid into the World Partition panel (a Streaming in Play block: focus cell, first-ring state, resident / in-flight / failed / authored counts) and into the cell overlay, which then rings the driver's focus, outlines resident cells, and marks cells in flight (amber) and failed (red). editor_app/authoring/partition.zig reads the grid a few times a second and pushes only when the readout changes.

Current limitations: unloaded cells have no far representation (HLOD), and the partition driver does not automatically persist gameplay changes across unload. Games can retain edits through OverlayStore. Named grids provide coarser placement for large actors; pin objects that must remain loaded.

Runtime geometry (generated visual + collision)

For shapes the game builds at runtime (voxel chunks, destruction, roads, sculpt), use hi.RuntimeGeometry rather than open-coding ProceduralMesh + create/set/release:

zig
var geom = hi.RuntimeGeometry.init(allocator, .{ .stride = hi.vertex_layout.stride });
defer geom.deinit();
geom.bind(actor);

geom.markDirty();
geom.beginBuild();
// fill geom.mesh and/or geom.col_positions + col_indices (workers OK)
geom.finishBuild();

var budget = hi.GeometryApplyBudget.init(8);
_ = geom.tryApply(&budget);
// Many actors / one physics safe-point:
// _ = hi.RuntimeGeometry.applyBatch(&.{ &a, &b }, &budget);

Build never calls the host; apply is game-thread only and always does create → setCollisionMesh (pin) → release creator. Empty collision deactivates physics so a placeholder box does not linger. See Physics — triangle mesh colliders and sdk/src/runtime_geometry.zig.

Runtime commands

Keep per-actor data on components; reserve global policy for the optional game subsystem (session services). Typed host mutations go through World.command / runtime_command.zig.

UI and shared value motion (springs, transitions, reduced motion) are specified in Motion Kit. Store drivers next to the state they animate; do not hang timelines on UiContext.

Frame timing and time scale

TickContext carries the whole clock, so nothing needs a hand-rolled timer:

FieldMeaning
dtScaled seconds since the last tick — 0 when paused or suspended. In fixedUpdate this is the fixed step
frame_dtScaled frame delta before tick-rate LOD multiplies dt (rate-N actors integrate frame_dt × N)
total_timeScaled seconds since session start
unscaled_dtReal seconds, ignoring time scale and pause
frame_indexMonotonic session frame counter, stable across scene loads
fpsSmoothed host tick cadence; use hi.render().fps() for rendered FPS
is_fixedTrue only inside fixedUpdate

For an in-game FPS counter, read hi.render().fps() from the normal HUD or onTick callback. It uses the same session-owned primary-viewport meter as the editor chrome: completed render-thread frames, including GPU back-pressure, smoothed over up to 16 completions. CPU ticks that the render mailbox coalesces do not count. Reads are allocation-free and do not wait for rendering.

The meter returns 0 until a rate is available or when the renderer is absent or stopped; display a placeholder for 0. Stale samples age down during a stall. It follows the session viewport across scene loads and Edit/Play World switches, independently of simulation pause or time scale. This is render delivery cadence, not a GPU execution-time measurement or an exact display scanout timestamp.

hi.world().setTimeScale(value) scales gameplay time: 0 freezes simulation time (entity updates and scripts receive a zero delta; physics skips integration), 1 is real time, 2 runs double speed; negatives clamp to 0. Slow-motion, pause menus, replay scrubbing and debug step-through all fall out of this one knob.

The UI and unscaled_dt stay on the real clock, so a pause menu still animates and its camera still smooths while the world is frozen. Integrate gameplay with dt and presentation with unscaled_dt and both behave correctly under pause without a single if (paused) branch.

Gameplay kits: motor and camera rig

Axes, handedness, euler order, and view-right vs object +X are documented in Coordinate space.

Two SDK value types cover the locomotion and framing every project otherwise rewrites. They are libraries, not mandated components: nothing in the engine requires them, and a game can use one, both, or neither.

Neither reads input, and neither knows about the other. The rig turns look deltas into a pose; the motor turns a world-space direction into a velocity. Mapping keys, sticks, path waypoints or AI steering onto those inputs is the game's job — which is exactly what keeps them genre-neutral.

zig
const Player = struct {
    motor: hi.CharacterMotor = .{ .max_speed = 6.5, .jump_speed = 7.5 },
    rig: hi.CameraRig = .{ .mode = .orbit, .distance = 4 },

    pub fn update(self: *@This(), actor: hi.ActorContext, ctx: *const hi.TickContext) void {
        const id = actor.id;
        const w = hi.world();
        const camera = w.primaryCamera();
        if (!w.isAlive(camera)) return;
        const pose = self.rig.apply(camera, .{
            .yaw = w.mouseDelta()[0],
            .pitch = w.mouseDelta()[1],
        }, w.position(id), ctx.unscaled_dt);

        // Camera decides what "forward" means; the motor never sees a camera.
        const b = self.rig.basis();
        var wish = @Vector(3, f32){ 0, 0, 0 };
        if (w.actionHeld("move_forward")) wish += b.forward;
        if (w.actionHeld("move_right")) wish += b.right;

        const out = self.motor.drive(id, .{
            .move = wish,
            .jump = w.actionPressed("jump"),
        }, ctx.dt);
        if (out.landed) hi.std.log.info("Landed", .{});

        const aim = self.rig.aimRay(pose);   // picking, shooting, block targeting
        _ = aim;
    }
};

CharacterMotor

Accelerate toward a wish velocity, hold a ground stick, fall under gravity, jump with slack on both sides of the ledge — coyote time and jump buffering are in the box, as are multi-jumps, terminal velocity and analog deflection.

step(grounded, intent, dt) is pure math and touches nothing: testable, and usable for actors physics has never heard of. drive wires it to a character controller; driveTransform integrates the transform directly for movers with no body at all. addImpulse layers knockback, launch pads and dashes on top.

GenreIntent.move fromTuning
FPS / TPScamera basis × inputdefaults
Platformerscreen axes × stickmax_jumps = 2, higher jump_speed
Top-down / twin-stickstick directiongravity = 0
RTS unitnext path waypoint − positiongravity = 0, driveTransform

hi.character.yawTowards is a free helper, not a motor field, because plenty of games face the aim direction rather than the travel direction.

CameraRig

Owns angles, clamps, boom length, follow smoothing and an optional collision probe. update is pure and returns a pose; apply also writes it onto a camera actor and pulls the boom in when geometry blocks it.

CameraSetup
First person.mode = .first_person, pivot at eye height
Third person.mode = .orbit, distance = 4, collision on
Top-down / RTS.mode = .orbit, pitch pinned (min_pitch == max_pitch), distance = zoom, target = a focus point the game drives
2.5D side-on.mode = .orbit, yaw = 0, pitch pinned, no look input
Fixed / cutscene.mode = .fixed, game sets the angles

Follow smoothing converges exponentially, so it behaves identically at 30 and 240 fps. Call snap() after a teleport or camera cut, or the camera sails across the level. basis() and aimRay() are the two connection points to gameplay: camera-relative movement and picking, with no coupling in either direction.

Both kits hold live state, so a behaviour embedding them should be runtime_only (or mark the field transient) — neither is authored data.

PreviousSession services and cross-scene stateNext Actor communication (hi.actors)

Documentation follows the current engine checkout.

Snapshot 86f12d68Source docs/systems/scenes-and-gameplay.md
On this pageGame packageEntity authoringChoosing component storageGameplay tags (catalog only)Active / enable trioScene dataCamera transformsParenting and socketsScene packs and asset refsScene environmentRender geometry is one slotExtensible componentsArchetype capabilities vs authored paramsGame subsystem and session servicesProject settingsFocus streamer (open / large worlds)World partition (authoring)Cook splitRuntime driverRuntime geometry (generated visual + collision)Runtime commandsFrame timing and time scaleGameplay kits: motor and camera rigCharacterMotorCameraRig Back to top