Skip to content
hikari
RenderingEditorAIToolchainDocumentation
GitHub
hikari

© 2026 Flying Rat Studio.
All rights reserved.

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

Scenes and gameplay

On this page
On this pageGame packageEntity authoringChoosing component storageGameplay tags (catalog only)Active / enable trioScene dataCamera transformsScene packs and asset refsScene environmentRender geometry is one slotExtensible componentsArchetype capabilities vs authored paramsGame subsystem and session servicesProject settingsFocus streamer (open / large worlds)Runtime 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 src/games/example/src/root.zig. Zig supplies defaults; optional configs/*.json solo-file overlays win at session boot.

The sample package organizes gameplay under src/games/example/src/entities/, scenes under src/games/example/scenes/, solo configs (including input actions) under src/games/example/configs/, raw assets under src/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 and pack dependencies are top-level; capability and plugin data live under each actor's "components" object. Shinra compiles the validated document into an HSC1 schema-neutral binary value tree. Product/Release runtime loads that tree only (no JSON parse). Authoring .json fallback exists Debug only. 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.

Scene packs and asset refs

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

Scene fieldRole
packs[0]Primary pack name (usually scene stem). Retained on load.
packs[1..]Additional packs to retain (e.g. "shared").

On load: retain every entry in packs (refcounted). On unload: release them; shared stays if another scene still holds it. Session open keeps system packs only (engine, scenes, shaders, content).

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",
  "packs": ["damaged_helmet", "shared"],
  "actors": [{
    "id": "crate",
    "archetype": "cube",
    "components": {
      "render": {
        "mesh": "asset://./models/cube",
        "material": "asset://./materials/cube"
      }
    }
  }]
}

Art membership: configs/layout.json (role: "assets" + include globs). Seal fails if two packs claim the same path.

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 inspector row, 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.

Persistence and live application are deliberately separate. An Options screen keeps a typed document, saves it, then calls the owning runtime API:

zig
var render_doc = try hi.config.load(
    hi.config.RenderDocument,
    allocator,
    hi.config.files.render,
);
defer render_doc.deinit();

var quality = render_doc.value.pipeline.quality orelse .{};
quality.anisotropic_filtering = .x16;
render_doc.value.pipeline.quality = quality;
try hi.config.save(hi.config.files.render, render_doc.value);
hi.render().setGraphics(.{ .render = render_doc.value.pipeline });

Use hi.world().runtimeInfo().graphics and hi.render().displayInfo() for the current effective/capability-resolved state shown by the UI. 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.

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.

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 = 12 });
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 pauses the simulation (entity updates, scripts and the physics step all see dt == 0), 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 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) playLandSound();

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

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 cc148c75Source docs/systems/scenes-and-gameplay.md
On this pageGame packageEntity authoringChoosing component storageGameplay tags (catalog only)Active / enable trioScene dataCamera transformsScene packs and asset refsScene environmentRender geometry is one slotExtensible componentsArchetype capabilities vs authored paramsGame subsystem and session servicesProject settingsFocus streamer (open / large worlds)Runtime geometry (generated visual + collision)Runtime commandsFrame timing and time scaleGameplay kits: motor and camera rigCharacterMotorCameraRig Back to top