Hands-on walkthrough: Tutorials — First messages.
Game modules never hold engine Entity pointers. The public surface for find, liveness, messages, and name-keyed component fields is:
| Language | Namespace |
|---|---|
| Zig | hi.actors (alias hi.Actors) — sdk/src/actors.zig |
| Kawa | Actors.* — distinct from self-only Actor.* (transform, scene id, …) |
hi.world().send / World.find / Entity.is_alive still work as aliases for older samples; new code should use hi.actors / Actors.*.
Short path (preferred)
From an ActorContext lifecycle callback — sender is implicit (e.id):
pub fn update(self: *@This(), e: hi.ActorContext, ctx: *const hi.TickContext) void {
_ = .{ self, ctx };
e.sendEventTo("msg_door_zig", "open", .{});
e.emitEvent("ping", .{});
e.sendEvent(target, "damage", .{ .args = &.{ .{ .float = 25 } } });
}With only a self ActorRef (e.g. update(self, id, ctx)):
const a = hi.actors;
a.sendEventToFrom(id, "msg_door_zig", "open", .{});
a.emitEventFrom(id, "ping", .{});
a.sendEventFrom(id, door, "damage", .{ .args = &.{ .{ .float = 25 } } });Optional find + component fields:
if (a.tryFind("enemy_00")) |enemy| {
if (enemy.component(Health).has()) {
const hp = enemy.component(Health).get(.hp) orelse 0;
_ = enemy.component(Health).set(.hp, hp - 10);
}
}Actors.send("msg_door_zig", "open");
Actors.emit("ping");
if (Actors.has(enemy, "health")) {
let hp = Actors.get_number(enemy, "health", "hp");
Actors.set_number(enemy, "health", "hp", hp - 10);
}Fire-and-forget does not need a prior isAlive check (dead targets no-op). Use tryFind / isAlive when the branch depends on presence. Prefer e.sendEvent* so you never type .from = id.
Choose the right channel
| Goal | Use | Avoid |
|---|---|---|
| A tells B “open / damage / ping” (any language mix) | Message send / emit | String method invoke, globals |
| A reads/writes B’s numeric component fields | target.component(Comp).get(.field) / set(.field, value) in Zig; getNumber / setNumber for dynamic names | Stuffing large state into message payloads |
| A reads/writes string or asset fields | The same typed get / set in Zig; getString / setString / getAsset / setAsset for dynamic names | Free-form invent outside component schema |
| Query / tag actors by catalog gameplay tags | hasTag / addTag / removeTag / withTag | Free-form tag invent — catalog only (configs/gameplay_tags.json) |
| Same actor, Zig component A → typed sibling B | ActorContext.get / require / has in lifecycle | Name-keyed path on hot same-actor loops when you have types |
| Cache a neighbour across frames | Store ActorRef, check isAlive before use | Raw pointers; assuming the ref keeps them alive |
| Prevent unload | Existing loose layer / session scope only | Holding a ref (refs never pin) |
Cross-actor method call by name is intentionally not provided. Messages are the function-call substitute at actor boundaries.
Concepts
| Piece | Role |
|---|---|
ActorRef | Generation-safe handle (EntityId alias). Weak: never retains. Dead → soft no-op. See Refs. |
| Scene id | Stable authored string ("msg_door_zig"). Preferred address for find / sendToSceneId. |
| Message name | Interned verb ("open", "ping"). |
| Payload | Up to four MessageValues (bool / int / float / vec3 / entity ref / interned name). |
send | Point-to-point to one actor. |
emit | Broadcast to every registered message listener. |
| Listeners | On delivery: Zig components with onMessage (declaration order), then all Kawa script slots with on_message. Inactive / terminating actors skip. |
Find and liveness
Zig
const hi = @import("hikari_game");
const a = hi.actors;
const door = a.find("msg_door_zig"); // scene id → ActorRef (or .invalid)
_ = a.findByName("DoorZig"); // display name, O(n) — prefer scene id
if (a.tryFind("msg_door_zig")) |d| { // live only
_ = d;
}
if (!a.isAlive(door)) return; // generation still live
if (!a.isActive(door)) return; // live and entity-activeKawa
let door = Actors.find("msg_door_zig"); // handle or nil
let by_name = Actors.find_by_name("DoorZig");
if (Actors.is_alive(door) == false) return;
if (Actors.is_active(door) == false) return;
// Self: Actors.find(Actor.get_scene_id()) when you need a handle for Actors.*Rules
- Always treat stored handles as maybe dead after despawn, scene replace, or layer unload.
isAlive/ deadsend/ field access: no crash — false / null / no-op.- Refs do not stop unload. Lifetime extension is only loose actors (
layer_keynull) or session services, not game handles.
Messages
Language-agnostic bus: Zig ↔ Zig, Zig ↔ Kawa, Kawa ↔ Zig, Kawa ↔ Kawa. One payload ABI and one World-owned event dispatcher; deferred delivery by default. Both language APIs resolve addresses and enqueue into this same dispatcher.
Frame order
collision events → may enqueue
Zig entity updates → may enqueue
Kawa entity updates → may enqueue
flushMessages() → deliver (multi-pass, budgeted)
purge terminatedEnqueue is O(1). Delivery is deferred so handlers do not re-enter mid-update. Nested enqueues run breadth-first in subsequent passes, with at most 8 passes
and 4,096 actor-recipient deliveries per flush (including broadcast recipients).
Overflow logs and drops the remaining work. A recursive flushMessages call is
a no-op; the outer flush owns delivery. Queues retain capacity.
Each broadcast snapshots registered listeners when delivery begins. Adding or removing listeners during a handler cannot skip a different recipient or add a new recipient to the current broadcast. Dead/inactive targets are checked again before invocation. Nested immediate broadcasts have independent snapshots. Clearing queues cancels the current drain; newly enqueued messages survive for the next flush.
Engine has immediate sendNow / emitNow for rare re-entrancy; game modules use deferred send / emit only.
Zig send / receive
Preferred — event helpers with self as sender:
// ActorContext form (best DX)
e.sendEventTo("msg_door_zig", "open", .{});
e.emitEvent("ping", .{});
e.sendEvent(door, "damage", .{ .args = &.{ .{ .float = 25 }, .{ .entity = other_id } } });
// ActorRef form
const a = hi.actors;
a.sendEventToFrom(id, "msg_door_zig", "open", .{});
a.emitEventFrom(id, "ping", .{});
a.sendEventFrom(id, door, "damage", .{ .args = &.{ .{ .float = 25 } } });When you must set from explicitly (or leave it empty), use sendEvent / sendEventTo / emitEvent with Event{ .from, .args }.
Explicit Message still works when you need full control:
var open = hi.Message.init(a.internMessageName("open")).withFrom(id);
a.send(a.find("msg_door_zig"), open); // dead → no-op
a.sendToSceneId("msg_door_zig", open);
a.emit(hi.Message.init(a.internMessageName("ping")).withFrom(id));Receive on a logic component (defineActor discovers onMessage → emit listener):
pub fn onMessage(self: *@This(), _: hi.ActorContext, msg: *const hi.Message) void {
if (a.messageNameEql(msg.name, "open")) {
self.open = true;
}
}Kawa send / receive
Actors.send("msg_door_zig", "open");
Actors.send(door, "damage", 25);
Actors.emit("ping");
fn on_message(name, from, a0, a1, a2, a3) {
if (name == "open") { /* ... */ }
if (name == "ping") { Debug.log("ping"); }
}from is an actor handle (or empty). Hikari disables Kawa’s standalone Events.* bus and its events_* aliases with the VM option enable_events = false. Use Actors.* for gameplay messages; both languages share World delivery rules.
Delivery fan-out
When a message is delivered to a live active actor:
- Zig — each archetype component that declares
onMessage(declaration order). - Kawa — every script slot on that actor that declares
on_message(multi-slot component scripts + entity-level script).
Both languages can handle the same event on one actor. Script-only actors register as listeners when on_message is present at attach; Zig defineActor registers when any component has onMessage.
Component field access
Cross-actor field access uses exact typed helpers in Zig and dynamic name-keyed helpers in scripts and tooling. Same rules apply to self and other actors. See Gameplay API for types, borrowing, and runtime state.
Zig — prefer target-first component access
Every defineComponent type exposes weak-handle field access without repeating the component name string:
// After: const Health = hi.defineComponent(.{ .name = "health", .data = struct { hp: f32 = 100 } });
if (target.component(Health).has()) {
const hp = target.component(Health).get(.hp) orelse return;
_ = target.component(Health).set(.hp, hp - 10);
// arrays / vectors:
// target.component(Health).getAt(.tint, 1);
// target.component(Health).setAt(.tint, 1, 0.5);
}
// Runtime field path (rare):
_ = hi.actors.getNumber(target, Health.component_name, "hp", 0);
_ = hi.actors.setNumber(target, Health.component_name, "hp", 0, 50);| Helper | Role |
|---|---|
id.component(Comp).has() | Component present on live actor |
target.component(Comp).get(.name) / set(.name, value) | Exact declared field value (including arrays, strings, assets) |
target.component(Comp).getAt(.name, index) / setAt(.name, index, value) | Array / vector element |
target.component(Comp).getPath("nested.path") / setPath("nested.path", value) | Same typed accessor for a compile-time nested or computed path |
hi.actors.getNumber / setNumber | Runtime path string |
Generic free functions remain for dynamic names:
| Zig | Kawa | Notes |
|---|---|---|
hasComponent(id, "health") | Actors.has(actor, "health") → bool | Component on mask |
getNumber(id, "health", "hp", 0) | Actors.get_number(…) | ?f64 / number | nil |
setNumber(id, "health", "hp", 0, v) | Actors.set_number(…) | bool / 0|1; fires onChanged when applicable |
- Types: float, int, bool (0/1), enum ordinals; vectors/arrays use index (0 for scalars).
- Also: strings (
getString/setString) and assets (getAsset/setAsset/ Kawaget_asset/set_asset). - Not supported: arbitrary method call on another actor.
- Missing / dead / bad field: soft fail (
null/false/ nil). - Fields come from Zig
Data(property_fields) — scripts never declare inspector schema.
if (Actors.has(target, "health")) {
let hp = Actors.get_number(target, "health", "hp");
Actors.set_number(target, "health", "hp", hp - 10);
// vector element: Actors.get_number(actor, "fx", "tint", 1)
}Same-actor typed Zig (ActorContext)
When a lifecycle callback takes ActorContext (or you already hold typed component types on the same actor), prefer:
pub fn update(self: *@This(), e: hi.ActorContext, ctx: *const hi.TickContext) void {
_ = .{ self, ctx };
if (e.get(Health)) |h| {
h.hp -= 1;
}
const nav = e.require(Navigation); // archetype-required
_ = nav;
}ActorContext.get / require | hi.actors.getNumber | |
|---|---|---|
| Typing | Compile-time component type | Name string |
| Same actor hot path | Preferred | Fine |
| Other actor | N/A (batch view is self) | Use actors.* |
| Kawa | N/A | Use Actors.* |
API quick reference
Zig hi.actors
| Function | Role |
|---|---|
find / findByName | Scene id / display name → ActorRef |
tryFind / tryFindByName | Same + live → ?ActorRef |
isAlive / isActive | Liveness / entity-active |
internMessageName / messageNameEql | Message names |
send / sendToSceneId / emit | Message bus (explicit Message) |
sendEvent / sendEventTo / emitEvent | Name + full Event{ .from, .args } |
sendEventFrom / sendEventToFrom / emitEventFrom | Preferred with self id — from first, then Event{ .args } only |
hasComponent / getNumber / setNumber | Dynamic name-keyed fields |
Zig ActorContext message helpers
| Method | Role |
|---|---|
e.sendEvent(to, name, event) | Point-to-point; from = e.id |
e.sendEventTo(scene_id, name, event) | By scene id; from = e.id |
e.emitEvent(name, event) | Broadcast; from = e.id |
event.from is ignored on these paths — only event.args matter. Empty payload: .{}.
Zig defineComponent field helpers
| Function | Role |
|---|---|
id.component(Comp).has() | Present on actor |
target.component(Comp).get(.hp) / set(.hp, value) | Exact typed value access |
target.component(Comp).getAt(.tint, index) / setAt(.tint, index, value) | Indexed access |
hi.actors.getNumber / setNumber | Runtime field path |
Kawa Actors.*
| Function | Role |
|---|---|
find / find_by_name | Handle or nil |
is_alive / is_active | Booleans |
send(target, name, …args) | Target = handle or scene-id string |
emit(name, …args) | Broadcast |
has / get_number / set_number | Component fields |
get_string / set_string / get_asset / set_asset | String / AssetRef fields |
has_tag / add_tag / remove_tag | Catalog gameplay tags |
Related (not the unified namespace)
| API | Role |
|---|---|
Actor.* (Kawa) | This scripted actor: transform, scene id, … |
World.find / World.send | Older aliases; prefer Actors.* |
Entity.is_alive / Entity.send | Older aliases |
Comparison
| Messages | Actors field access | Host-only typed call | |
|---|---|---|---|
| Cross-language | Yes | Yes | No |
| Loose coupling | High | Component + field names | Needs Zig type |
| Hot path | Enqueue + flush | Dense property lookup | Function call |
| Use for | Events, doors, damage, pings | hp, flags, knobs | Internal host tools only |
Demo scene
src/games/example/scenes/messaging.json:
| Actor | Path |
|---|---|
msg_door_zig | Zig onMessage (blue door) |
msg_door_kawa | Kawa on_message (pink door) |
msg_pad | Zig collision → send open to both doors + emit ping |
msg_weight | Dynamic cube falls onto pad |
msg_listen_zig_* / msg_listen_kawa | Multi-listener ping |
Load the messaging layer from the sample session HUD, or set the scene as startup. Both doors start closed; the weight hits the pad and both lift.
Implementation map
| Concern | Location |
|---|---|
| Game API | src/hikari/sdk/src/actors.zig |
| Engine field access | src/hikari/src/scene/world/world_actors.zig |
| Host ABI | WorldApi.component_* + host_bind/world_api_actors.zig |
| Message queue / flush | scene/events/messages.zig, exposed through scene/event_dispatcher.zig |
| Delivery fan-out | scene/event_dispatcher.zig typed emit(.message) (Zig then Kawa slots) |
| Kawa natives | scene/scripting/kawa_host/actors.zig |
Performance notes
- Send/emit: append to a retained-capacity queue; no per-message heap.
- Names: intern once; compare with
MessageName/messageNameEql. - Refs: two-word generation check; dead targets dropped at flush.
- Broadcast: listeners only (handlers registered), not the full world.
- Field access: linear scan of a component’s small
property_fieldslist; no heap on the hot path. - Budget: each flush caps passes and actor-recipient deliveries, including broadcast recipients; overflow logs and drops.
Keep messages small (name + ≤4 args). Large state lives on components, not in the payload.
See also
- Tutorials — First messages
- Game-facing refs —
ActorRefweak semantics - Scenes and gameplay — components, active trio
- Scripting with Kawa — attach scripts, tick order
- Lifecycle — when messages flush