# Actor communication (`hi.actors`)

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

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

```zig
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(hi.actors.find("enemy_00"), "damage", .{ .args = &.{ .{ .float = 25 } } });
}
```

**With only a self `ActorRef`** (for example, in a helper passed `actor.id`):

```zig
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:**

```zig
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);
    }
}
```

```kawa
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](/docs/systems/refs.md). |
| **Scene id** | Stable authored string (`"msg_door_zig"`). Preferred address for `find` / `sendToSceneId`. |
| **Message name** | Interned verb (`"open"`, `"ping"`). |
| **Payload** | Up to four `MessageValue`s (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

```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-active
```

### Kawa

```kawa
let door = Actors.find("msg_door_zig");    // handle or nil
let by_name = Actors.find_by_name("DoorZig");
if (Actors.is_alive(door) and Actors.is_active(door)) {
    Actors.send(door, "open");
}
// 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` / dead `send` / field access: **no crash** — false / null / no-op.
- Refs **do not** stop unload. Lifetime extension is only loose actors (`layer_key` null) or [session services](/docs/systems/session-services.md), not game handles.

---

## Messages

Language-agnostic bus: **Zig ↔ Zig**, **Zig ↔ Kawa**, **Kawa ↔ Zig**, **Kawa ↔ Kawa**. One payload ABI and one World-owned [event dispatcher](/docs/guides/component-lifecycle.md#unified-dispatcher-contract); deferred delivery by default. Both language APIs resolve addresses and enqueue into this same dispatcher.

### Frame order

```text
collision events    → may enqueue
Zig entity updates  → may enqueue
Kawa entity updates → may enqueue
flushMessages()     → deliver (multi-pass, budgeted)
purge terminated
```

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

```zig
// 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:

```zig
const 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):

```zig
pub fn onMessage(self: *@This(), _: hi.ActorContext, msg: *const hi.Message) void {
    if (a.messageNameEql(msg.name, "open")) {
        self.open = true;
    }
}
```

### Kawa send / receive

```kawa
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") { Debug.log("open requested"); }
    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:

1. **Zig** — each archetype component that declares `onMessage` (declaration order).
2. **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](/docs/guides/gameplay-api.md#typed-cross-actor-fields) 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**:

```zig
// 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` / Kawa `get_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.

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

```zig
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

`hikari-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_fields` list; 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](/docs/tutorials/first-messaging.md)
- [Game-facing refs](/docs/systems/refs.md) — `ActorRef` weak semantics
- [Scenes and gameplay](/docs/systems/scenes-and-gameplay.md) — components, active trio
- [Scripting with Kawa](/docs/guides/scripting-kawa.md) — attach scripts, tick order
- [Lifecycle](/docs/systems/lifecycle.md) — when messages flush
