# Scenes and gameplay

Hands-on walkthrough: [Tutorials — First entity](/docs/tutorials/first-entity.md).

## 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](/docs/guides/gameplay-api.md).

### 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](/docs/guides/component-storage.md) 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](/docs/guides/scripting-kawa.md#attaching-scripts). 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](/docs/systems/lifecycle.md#entity-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](/docs/systems/lifecycle.md#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](/docs/systems/entity-messaging.md). 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](/docs/guides/component-lifecycle.md) 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.

```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](/docs/systems/coordinate-space.md): 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](/docs/systems/animation.md#joint-sockets).

```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](/docs/systems/assets-and-shinra.md#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` |

```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](/docs/systems/assets-and-shinra.md#soft-refs-and-loadhot-reload-recovery).

### 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](/docs/guides/time-of-day.md) 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:

```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](/docs/systems/animation.md#root-motion).

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](/docs/systems/session-services.md) ([readiness stages](/docs/systems/session-services.md#readiness-stages-show-the-scene-gate)).

## 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](/docs/systems/rendering.md) and [Frontends and drivers](/docs/systems/frontends-and-drivers.md) 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();
```

| 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](/docs/guides/project-file.md#quality-preset-renderjson)).
`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) |

```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"
    }
  }
]
```

| 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_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):

| 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:

```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](/docs/systems/physics.md#triangle-mesh-colliders-static) 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](/docs/systems/motion-kit.md). 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](/docs/systems/coordinate-space.md).

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.

| 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.
