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).
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:
| Level | Scene / component field | Host API (game modules) | Effective behaviour |
|---|---|---|---|
| Entity | actor "active" (default true) | hi.world().setActive / isActive | Off → 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 / isPhysicsActive | Authored 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 / isVisible | Authored 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.
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{
"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:
pitch = degrees(atan2(delta.y, sqrt(delta.x^2 + delta.z^2)))
yaw = degrees(atan2(-delta.x, -delta.z))
roll = 0{
"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.
{ "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*):
| Form | Use |
|---|---|
asset://shared/models/cube | Explicit pack — pack must be retained |
asset://./models/cube | Unscoped absolute path — catalog picks pack; that pack must be retained. Not relative to the scene; not rewritten to bundle |
{
"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:
{
"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:
| Key | Asset | Draws | Materials |
|---|---|---|---|
"model" | .model.json placeable (asset://…/Foo.model.json) | one per part | materials[i] per slot; omitted slots keep the cooked material |
"mesh" | one cooked engine mesh (extension-free stem) | one | material (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:
{
"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 omittedbody_typealso 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.rendermissing, or a non-placeable geometry withoutmaterial), seedmaterialtoasset://./materials/default(seedefault_material_uri). GPU primitive create skips draws when material is null — this is the only seed path so bare spawn / UI-add / runtimespawnstay 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.
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();| Setting | Applies |
|---|---|
Everything in render (preset, tiers, features, HDR display, upscaler) | Live |
window.vsync, window.frame_rate | Live (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, resizable | Next 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.
| Type | Role |
|---|---|
hi.StreamGrid | Pure schedule: plan loads/unloads from focus |
hi.Streamer | Full loop: plan scratch + confirm + optional pump(hooks) |
hi.OverlayStore(T) | Session values keyed by cell (edits that outlive unload) |
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.
{
"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 } ]
}
}[
{
"id": "sun",
"archetype": "light",
"partition": {
"pinned": true
}
},
{
"id": "castle",
"archetype": "entity",
"partition": {
"grid": "landmark"
}
}
]| Field | Meaning |
|---|---|
enabled | Off (default, and the whole file when omitted) = one layer, as every scene has been. Nothing about a plain scene changes. |
cell_size | Default grid: world size of one cell. 0 on an axis leaves it undivided; [64, 0, 64] is a grid of columns. |
load_radius / unload_padding | Default grid: the hi.StreamGrid ring and hysteresis the driver will use (ScenePartitionDesc.gridConfig). |
grids | Up 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.pinned | Always loaded: the actor and everything parented under it live in the base layer. Written only when true. |
actor partition.grid | The 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_getreports the rootpartition(withgrids) and per-actorpinned/grid;scene_edittakesset_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, optionalindex; growinggrid_countappends grids namedgrid_n),set_pinned(id,pinned) andset_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):
| Output | Content |
|---|---|
scenes/world.shinscene | every 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>.shinscene | a 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>.shinscene | the 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:
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:
| Field | Meaning |
|---|---|
dt | Scaled seconds since the last tick — 0 when paused or suspended. In fixedUpdate this is the fixed step |
frame_dt | Scaled frame delta before tick-rate LOD multiplies dt (rate-N actors integrate frame_dt × N) |
total_time | Scaled seconds since session start |
unscaled_dt | Real seconds, ignoring time scale and pause |
frame_index | Monotonic session frame counter, stable across scene loads |
fps | Smoothed host tick cadence; use hi.render().fps() for rendered FPS |
is_fixed | True 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.
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.
| Genre | Intent.move from | Tuning |
|---|---|---|
| FPS / TPS | camera basis × input | defaults |
| Platformer | screen axes × stick | max_jumps = 2, higher jump_speed |
| Top-down / twin-stick | stick direction | gravity = 0 |
| RTS unit | next path waypoint − position | gravity = 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.
| Camera | Setup |
|---|---|
| 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.