Editor highlighting for .kawa: tools/vscode-extensions/kawa (VS Code / Cursor), tools/nvim-plugins/kawa (Neovim / Vim).
Layers
The engine's scene-scripting seam is VM-agnostic. src/hikari/src/scripting/api.zig defines the engine-level native-binding payload, while src/hikari/src/scripting/kawa/public.zig is the supported Kawa integration barrel. Only the Kawa implementation folder may touch translated C declarations directly.
World scene scripting hooks
→ SceneScriptBackend (VM-agnostic VTable + registerNative)
→ Kawa adapter (kawa_host/* marshaling; Audio.* → audio/script_ops)
→ Kawa C runtime and VMEngine domain natives live under scene/scripting/kawa_host/ and bind via
kawa_backend engine_host_bindings, except Audio.*: ops are
VM-agnostic in audio/script_ops.zig; the Kawa adapter is
kawa_host/audio.zig, registered from the application session so the
scripting dylib never links audio. A future non-Kawa VM reimplements only
the thin adapter and keeps the same registerNative path.
Kaji builds Kawa as a native library first. The engine imports its headers for declarations and links the prebuilt artifact, matching the integration model used for native middleware.
Attaching scripts
Scripts attach at two levels, and an actor may carry any mix of them:
- Entity-level —
defineActor.script. One slot per actor, attached last. - Component-level —
defineComponent.scripton a game logic component. One slot per component instance, in archetype declaration order. The script'sdatascope is seeded from that component's fields, not the whole-actoruser_datablob.
An entity can therefore have Zig logic components, a Kawa script, scripted components, or all three (open form — no .behaviour field). Engine capability builtins (transform, physics, …) and .swarm components reject .script at comptime; the builtin kawa component is the one empty script host, used by scene JSON. Runtime model: Script logic authoring.
// Zig logic only
hi.defineActor(.{
.archetype = "player",
.components = .{ hi.ComponentPhysics, PlayerLogic },
});
// Hybrid: Zig component hooks + Kawa (script path is an asset URI)
hi.defineActor(.{
.archetype = "player",
.components = .{ hi.ComponentPhysics, PlayerLogic },
.script = "asset://./scripts/player.kawa",
});
// Script-primary actor with engine capabilities
hi.defineActor(.{
.archetype = "fly_camera",
.components = .{hi.ComponentCamera},
.script = "asset://./scripts/camera_controller",
});
// Component-level: data in Zig, behaviour in Kawa, one slot per instance
const Patrol = hi.defineComponent(.{
.name = "patrol",
.storage = .embedded,
.script = "asset://./scripts/patrol",
.data = struct {
speed: f32 = 2.0,
radius: f32 = 12.0,
},
});
hi.defineActor(.{
.archetype = "guard",
.components = .{ hi.ComponentRender, Patrol },
// Entity-level .script may still be added alongside.
});A scene instance overrides either level: entity-level "script": { "path" } at the actor root, component-level "script": { "path", "params" } inside that component's object under "components". Zig component batches, then all script slots, run within each tick group; on_message fires on every slot that declares it.
In the editor, the inspector shows the script that will actually run on each slot, not only what the scene file authored. The actor's Script section shows the archetype's .script as a default (with "Change script" to override it on this actor) and, for an authored override, names the default it replaces so "Remove script" reads as "return to the archetype default". Each game component is its own section; a component that declares .script or allows_script opens with a Script row showing its declared default or the instance's override. The row is a drop target for .kawa assets, the pencil opens the picker, and × clears an override. Both go through the mutation plane: set_asset (kind: "script") for the actor slot and set_component_script (id, component, path string or null) for a component slot, so agents and the panel write the same thing.
Default script / session script paths are asset URIs (AssetRef when attaching from Zig). See Game-facing refs.
World scene code starts and updates entity scripts through scene/scripting/backend.zig. In editor Edit mode, simulation scripts do not run; they begin in Play.
Tick phases
Actor scripts expose two hooks, mapped to game-thread tick groups (full schedule: Lifecycle — Tick groups):
| Hook | Kawa fn | Zig fn | Tick group |
|---|---|---|---|
| Gameplay | update(dt, total_time) | update | .update (default; override with defineActor .tick_group) |
| Late | late_update(dt, total_time) | lateUpdate | .post_update (fixed) |
Every actor's update runs before any actor's late_update, so a transform
written during update is readable in late_update no matter which actor wrote
it or what order the scene declares them in. Zig component batches run before Kawa
in the same group.
That ordering is the whole point. Anything that reads a transform another
entity writes belongs in late_update: camera-relative aiming and picking,
world-space HUD markers, attachments and sockets, IK. Put it in update and it
silently reads whatever the previous tick left behind — which looks correct
while standing still and drifts badly the moment things move.
Both hooks are optional; declaring neither costs nothing.
fn update(dt, total_time) {
// move, simulate — write your own transform here
}
fn late_update(dt, total_time) {
// the camera has already moved this tick, so this ray matches what is drawn
}Session scripts have update only — they are not part of the entity phase pass.
Session scripts
A session script is not bound to an actor. Attach once from game code with an
AssetRef (typically from GameSubsystem.onLaunch after the asset store is ready):
hi.world().attachSessionScript(
hi.AssetRef.must(.script, "asset://./scripts/session"),
) catch |err| { … };Engine internals use script_backend.attachSession(world, path). Session hooks:
| Kawa fn | When |
|---|---|
start | After attach |
update(dt, total_time) | Each Play frame (session tick) |
on_scene_will_unload | Before replace scene unload |
on_scene_loaded(path) | After a successful replace scene load |
on_layer_loaded(key) | An additive layer presented — its content is live (key = request path) |
on_layer_ready(key) | A manual layer reached its gate; open it with Scene.present_layer(key) |
on_layer_will_unload(key) | Before additive layer unload |
on_scene_ready_stage(key, stage) | Readiness change (see stage table below; empty key = world) |
destroy | When the session script is torn down |
on_scene_ready_stage / Scene.ready_stage() / Scene.layer_ready_stage(key) stage ints:
| Int | Name | Notes |
|---|---|---|
| 0 | none | No layers / cleared |
| 1 | entities | Actors spawned |
| 2 | assets | Soft refs resolved |
| 3 | gpu | Per-layer terminal (GPU resident) |
| 4 | all | World only — every layer at gpu |
Scene unload clears actor script caches but keeps the session script entry. Actor.* is unavailable (nil) in session scope. Use Scene.reload, Scene.load / load_additive / unload_layer / cancel_load, Scene.ready_stage / layer_ready_stage, Input.*, and Debug.log. Sample: src/games/example/assets/scripts/session.kawa. Full load/layer API: Session services.
Actors communication (preferred)
Unified find / message / name-keyed component fields. Full guide: Actor communication.
| API | Role |
|---|---|
Actors.find / find_by_name | Scene id or display name → handle or nil |
Actors.is_alive / is_active | Weak-handle checks (never pin unload) |
Actors.send / emit | Entity message bus (Zig + all Kawa slots with on_message) |
Actors.add / remove | Runtime component membership by registry name; boolean request acceptance, deferred inside callbacks (semantics) |
Actors.has / get_number / set_number | Component fields by registry name (numbers / bools / enums) |
let door = Actors.find("msg_door_zig");
if (Actors.is_alive(door)) {
Actors.send(door, "open");
}
// Self handle when needed for Actors.*:
// Actors.find(Actor.get_scene_id())Actor.* remains this scripted actor (transform, scene id, …). World.find / World.send / Entity.* are aliases of the same host ops.
Receive:
fn on_message(name, from, a0, a1, a2, a3) {
if (name == "open") { /* ... */ }
}Refs from Kawa
| API | Role |
|---|---|
Actors.find / World.find / find_by_name | Actor ref (or nil) — prefer Actors.* |
World.spawn({...}) | Transactional runtime spawn with the same initial state as Zig SpawnDesc |
Actor.is_alive / send / name_of | Safe on dead refs |
Content.read("content://…") | ContentRef-style file read → string |
Render.set_material(actor, mat_uri [, albedo_uri]) | AssetRef material (+ optional albedo) |
Render.has_mesh(actor) | True when store mesh is bound/pending or CPU geometry is present (Zig: Render.hasMesh) |
Scene.set_skybox_texture("asset://…") | AssetRef primary cubemap (empty clears) |
Scene.set_skybox_texture_secondary("asset://…") | AssetRef secondary cubemap (empty clears) |
Scene.set_skybox_blend(t) | Dual-skybox blend in [0, 1] |
VisualZone.state(actor) / VisualZone.update(actor, {…}) | Look zone read/write (Zig visualZoneState / setVisualZone); update({…}) alone uses the owning actor |
Light.state / Light.update, … | Same pattern for other components |
Animation.root_motion(actor) | {x, y, z, yaw} the graph took out of the pose last step — yaw in radians, because a script wants a heading change rather than a quaternion |
Animation.set_root_motion(actor, "transform" | "script" | "off") | Who applies it; see skeletal animation |
Audio.play(cue [, opts]) / stop / fade_stop / crossfade / is_playing | Fire-and-forget SFX (posts to audio worker); opts include loop / hold, spatial v2 (inner_cone_deg / outer_cone_deg / forward / occlusion / reverb_send); see Audio |
Audio.set_master_mute / master_mute / set_master_gain / set_bus_gain / set_bus_mute / set_duck_amounts / set_reverb_mix | Mix + mute + duck + global reverb wet (gains ≥ 0, duck 0..1, worker clamp); posts only |
Sample scene: scenes/refs_demo.json + assets/scripts/ref_demo.kawa.
Zig ↔ Kawa interop (component scripts, entity scripts, messages with arguments, name-keyed fields): scenes/script_interop.json + script_interop.README.md.
Look zones: scenes/look_showcase.json + assets/scripts/look_zone_pulse.kawa.
Audio one-shot sample: scenes/audio_demo.json + assets/scripts/sfx_oneshot.kawa.
World.spawn accepts identity, parent/layer membership, active state, transform,
component blocks (including visual_zone), user_data_json, and script override/parameters (entity-level, or per component inside its block). Component and enum
field names are snake_case; asset fields are AssetRef URI/stem strings. See
First runtime spawn for the full example.
Input contexts
Input.* action reads (held, pressed, released, value, vector) pass through the input context stack, so a script obeys the same gating as Zig game code — a script cannot keep firing while a pause menu is up. Input.vector gates each axis independently, since a layer may allow strafing while locking forward motion.
Scripts can drive the stack too, and share the one stack on World:
Input.push_context({ name = "menu", actions = ["ui_cancel"], exclusive = true, priority = 100, pointer = "free" })
Input.pop_context("menu")
Input.has_context("menu")
Input.clear_contexts()
Input.allows("look") -- for gating raw mouse/gamepad inputOnly name is required. pointer is "free" or "captured". push_context returns false rather than truncating when the stack is full or a layer is oversized. Semantics and the raw-input idiom: Input.
Native bindings
Games register numeric natives through the thin SDK before scripts that call them attach:
fn myNative(argc: u32, argv: [*]const f64, out: [*]f64, out_count: *u32) callconv(.c) void {
out[0] = argv[0] * 2;
out_count.* = 1;
}
try hi.world().registerScriptNatives(&.{
.{ .namespace = "Game", .name = "double", .impl = myNative },
});Numbers in → numbers out (out_count == 0 → nil, 1 → number, >1 → array). Domain namespaces (Inventory.*, terrain helpers, …) live in the game module, not the engine.
Register from GameSubsystem.onWorldAttach(scope) (before entity scripts attach). Each editor Play World has its own scripting backend, so world-native bindings must be installed for every attachment. Re-registering the same namespace+name replaces the stored implementation (safe after game-module recompile). Namespace/name string slices must outlive the world (static literals are the usual pattern).
Engine code that needs full Kawa value types still builds VM-agnostic NativeBinding values with bindNamespace from kawa/public.zig and registers via the host World.registerScriptNatives. Do not expose raw C-layer declarations to ordinary engine or game files.
Kawa runtime
Kawa is a C VM/compiler with a typed language, bytecode archive/import-export support, native C bindings, script ownership, events, debug symbols, and tracing. Its implementation has compiled modules with private internal headers for VM execution and archive functionality; its public API and serialized archive format are preserved across those internal splits.
Scripts use lifecycle/event behavior supplied by the host. Kawa's event system supports normal and one-shot listeners, deferred posting/flush, owner keys, and automatic cleanup when a script is destroyed.
Bytecode cook and VM lifetime
- Offline compiler:
src/kawa/tools/kawac→bin/kawa/<platform-arch>/kawac(kawac <in.kawa> <out.kawabc>). Kaji stages it tobin/game/tools/kawacfor Shinra (--kawac/$KAWAC), which cooks.kawa→.kawabcwith the same options as runtime compile (emit_symbols, SIMD tier-1). - Compiled script cache: the engine retains bytecode across scene unload (actor instances clear; session script entry stays). Do not destroy/reload compiled scripts on every scene swap —
KAWA_MAX_SCRIPTS(256) is a per-VM slot budget and careless destroy can poison the VM when user-function meta strings still borrow chunk tables. - Editor Stop: may rebuild the Kawa VM to recover poison; stored
registerScriptNativesbindings are re-applied automatically. Purge only on backend teardown or script-asset invalidation.
Entity messaging from Kawa
Prefer Actors.send / Actors.emit for actor-to-actor gameplay (works with Zig handlers too). World.send / World.emit are the same bus:
let door = Actors.find("msg_door_zig");
if (Actors.is_alive(door)) {
Actors.send(door, "open");
}
Actors.emit("ping");
fn on_message(name, from, a0, a1, a2, a3) {
if (name == "open") { /* ... */ }
}Hikari creates Kawa VMs with enable_events = false, including after resets. The standalone VM’s Events.* namespace and events_* aliases are therefore unavailable in Hikari; gameplay messages use Actors.send / Actors.emit through the World dispatcher. Details: Entity messaging.
Where to work
| Task | Location |
|---|---|
| VM-agnostic engine API | src/hikari/src/scripting/api.zig |
| Kawa integration API | src/hikari/src/scripting/kawa/public.zig |
| Scene + session script contract | src/hikari/src/scene/scripting/backend.zig |
Kawa scene host / Scene.load | src/hikari/src/scene/scripting/kawa_host.zig (+ kawa_host/ domains) |
| Entity messaging | entity-messaging.md |
| Kawa source/runtime | src/kawa/src/ |
| Sample actor + session scripts | src/games/example/assets/scripts/ |