Skip to content
hikari
RenderingEditorAIToolchainDocumentation
GitHub
hikari

© 2026 Flying Rat Studio.
All rights reserved.

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

Scripting with Kawa

On this page
On this pageLayersAttaching scriptsTick phasesSession scriptsActors communication (preferred)Refs from KawaInput contextsNative bindingsKawa runtimeBytecode cook and VM lifetimeEntity messaging from KawaWhere to work Back to top

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.

text
World scene scripting hooks
  → SceneScriptBackend (VM-agnostic VTable + registerNative)
  → Kawa adapter (kawa_host/* marshaling; Audio.* → audio/script_ops)
  → Kawa C runtime and VM

Engine 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 .script on a game logic component. One slot per component instance, in archetype declaration order. The script's data scope is seeded from that component's fields, not the whole-actor user_data blob.

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

HookKawa fnZig fnTick group
Gameplayupdate(dt, total_time)update.update (default; override with defineActor .tick_group)
Latelate_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.

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

zig
hi.world().attachSessionScript(
    hi.AssetRef.must(.script, "asset://./scripts/session"),
) catch |err| { … };

Engine internals use script_backend.attachSession(world, path). Session hooks:

Kawa fnWhen
startAfter attach
update(dt, total_time)Each Play frame (session tick)
on_scene_will_unloadBefore 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)
destroyWhen the session script is torn down

on_scene_ready_stage / Scene.ready_stage() / Scene.layer_ready_stage(key) stage ints:

IntNameNotes
0noneNo layers / cleared
1entitiesActors spawned
2assetsSoft refs resolved
3gpuPer-layer terminal (GPU resident)
4allWorld 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.

APIRole
Actors.find / find_by_nameScene id or display name → handle or nil
Actors.is_alive / is_activeWeak-handle checks (never pin unload)
Actors.send / emitEntity message bus (Zig + all Kawa slots with on_message)
Actors.add / removeRuntime component membership by registry name; boolean request acceptance, deferred inside callbacks (semantics)
Actors.has / get_number / set_numberComponent fields by registry name (numbers / bools / enums)
kawa
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:

kawa
fn on_message(name, from, a0, a1, a2, a3) {
    if (name == "open") { /* ... */ }
}

Refs from Kawa

APIRole
Actors.find / World.find / find_by_nameActor ref (or nil) — prefer Actors.*
World.spawn({...})Transactional runtime spawn with the same initial state as Zig SpawnDesc
Actor.is_alive / send / name_ofSafe 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_playingFire-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_mixMix + 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:

text
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 input

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

zig
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 to bin/game/tools/kawac for Shinra (--kawac / $KAWAC), which cooks .kawa → .kawabc with 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 registerScriptNatives bindings 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:

kawa
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

TaskLocation
VM-agnostic engine APIsrc/hikari/src/scripting/api.zig
Kawa integration APIsrc/hikari/src/scripting/kawa/public.zig
Scene + session script contractsrc/hikari/src/scene/scripting/backend.zig
Kawa scene host / Scene.loadsrc/hikari/src/scene/scripting/kawa_host.zig (+ kawa_host/ domains)
Entity messagingentity-messaging.md
Kawa source/runtimesrc/kawa/src/
Sample actor + session scriptssrc/games/example/assets/scripts/
PreviousData-driven content, JSON, and pathsNext Shader authoring

Documentation follows the current engine checkout.

Snapshot cc148c75Source docs/guides/scripting-kawa.md
On this pageLayersAttaching scriptsTick phasesSession scriptsActors communication (preferred)Refs from KawaInput contextsNative bindingsKawa runtimeBytecode cook and VM lifetimeEntity messaging from KawaWhere to work Back to top