hi.defineActor composes component types. It does not accept actor-level
lifecycle functions: implement optional pub fn hooks on a component's Data
struct and put that component in the actor's .components tuple. Actor events
fan out to those components in tuple order. Both .embedded and .attachable components participate, including runtime
attachments. Extra attachments receive events after the declared tuple.
const hi = @import("hikari_game");
pub const LampLogic = hi.defineComponent(.{
.name = "lamp_logic",
.requires = .{hi.ComponentLight},
.data = struct {
pub fn onEnable(_: *@This(), actor: hi.ActorContext) void {
actor.render().setLight(actor.id, .{ .enabled = true });
}
pub fn onDisable(_: *@This(), actor: hi.ActorContext) void {
// Release gameplay subscriptions or other activation-scoped state.
// The actor handle may already be invalid during teardown.
_ = actor;
}
},
});
pub const Lamp = hi.defineActor(.{
.archetype = "lamp",
.components = .{ hi.ComponentLight, LampLogic },
});The engine already gates lighting, rendering, physics, and ticking by actor activity. Hooks are for gameplay reactions; they are not required to hide an inactive light or stop an inactive actor's updates.
Hooks and dispatch locations
Every hook below takes self: *Data. Except deinit, ordinary hooks then take
actor: hi.ActorContext. The table lists any remaining arguments. All return
void, except awake, which may also return an error union of void.
The regular callback path is World/scene operation → unified event dispatcher →
host bridge → generated component handlers.
event_dispatcher.zig owns
actor lifecycle, typed events, native tick admission to batches, Kawa slot
delivery, and Kawa session notifications. Entity convenience methods alias
these dispatcher entry points; they contain no separate dispatch logic.
The host bridge is
game_archetype.zig; the
component fan-out and update runners are generated in
define_actor.zig. Hook signatures
are checked by declaration_validation.zig.
| Hook | Remaining arguments | When dispatched | Dispatch origin |
|---|---|---|---|
awake | ?*const hi.SceneActorView | Instance initialization, including inactive and withheld actors; scene view is optional | world_scene_spawn.zig spawnByArchetypeWith and scene_loader/instantiate.zig → Entity.instantiate → bridge onInstantiate |
start | None | Once per actor when the spawn/load path starts it; authored-inactive actors still start, but withheld layers defer startup until presentation | scene_loader/spawn.zig, scene_loader/instantiate.zig, world_scene_layer.zig presentSceneLayer → Entity.start → bridge onStart |
onEnable | None | After startup when effectively active, then each effective inactive → active transition | event_dispatcher.zig syncActivation, reached after start, world_entities.zig activity/presentation setters, and world_scene_edit.zig applySceneActorActive → bridge onEnable |
onDisable | None | Effective active → inactive transition, or final teardown if enabled was previously delivered | The same syncActivation path, plus Entity.terminate from world_entities.zig queueEntityForCleanup → bridge onDisable |
update | *const hi.TickContext | Active actors in the archetype's tick group, subject to component cadence | world_tick.zig runTickGroup → dispatcher runPhase(.update) → bridge updateBatch → generated component tile runners |
lateUpdate | *const hi.TickContext | Active actors in .post_update; component tick_rate applies only to update | world_tick.zig runTickGroup(.post_update, ...) → dispatcher runPhase(.late_update) → bridge lateUpdateBatch → generated runners |
fixedUpdate | *const hi.TickContext | Active actors during admitted fixed steps; requires a nonzero fixed timestep | world_tick.zig runFixedUpdateSteps → dispatcher runPhase(.fixed_update) → bridge fixedUpdateBatch → generated runners |
onCollision | *const hi.CollisionInfo | Contact events for active actors; phase distinguishes enter, stay, and exit | world_entities.zig dispatchCollisionEvents → Entity.onCollision → bridge onCollision |
onAnimationEvent | *const hi.AnimationEvent | Authored clip markers after animation evaluation, for active actors | world_tick.zig dispatchAnimationEvents → Entity.onAnimationEvent → bridge onAnimationEvent |
onMessage | *const hi.Message | Messages delivered to active, non-terminating actors; Zig handlers precede Kawa script slots | events/messages.zig flushMessages / immediate delivery → Entity.deliverMessage → bridge onMessage |
onChanged | []const u8 | Explicit property notifications, including successful typed/dynamic field writes and inspector data edits; inactive actors can receive them | world_actors.zig field setters and world_scene_edit.zig data edits → Entity.userDataChanged → bridge onUserDataChanged |
onEnvironmentPreview | hi.EnvironmentPreview | Transient editor environment preview for active supporting actors; restore authored state when active becomes false | world_environment_preview.zig applyEnvironmentPreview → Entity.applyEnvironmentPreview → bridge onEnvironmentPreview |
onDestroy | None | Teardown while component storage is still available, after any final onDisable | world_entities.zig queueEntityForCleanup → Entity.terminate → bridge onTerminate |
deinit | std.mem.Allocator instead of actor context | Release owned row data; also used when a custom scene decoder replaces an existing value | Generated define_component/scene.zig deinitRow / custom decoder for columns; define_actor.zig destroyBehaviour for embedded data |
See Tick groups for the complete frame ordering, animation phase, fixed-step admission, and cadence rules.
Dispatch cost and measurement
Tick callbacks use generated lists containing only components that implement the phase. The host processes 512-entry tiles, with component-major loops and native Zig calls into component methods. Detailed per-entity timing deliberately reduces batching; disable it when measuring normal dispatch throughput.
Activation is transition-driven, with no per-frame polling. Actors without activation hooks have null activation vtable slots. A single participating component needs no fan-out lifetime check; multiple components retain checks between callbacks so one retiring the actor prevents later delivery.
From src/hikari, run the CPU-only activation benchmark:
zig build entity-bench -Dgame-src=<absolute-game-src> -Doptimize=ReleaseFast -Dprofiler-timing=false -- --case=activation --jsonPass -Doptimize=ReleaseFast explicitly so imported SDK modules use the same
optimization as the benchmark executable. This case exercises the real host/SDK
bridge with 10,000 actors and zero, one, or five embedded callback components.
It reports notification-only cost separately from public setActive transitions,
which include handle resolution and active-list maintenance. Each callback
increments a checked counter. It excludes rendering, physics, attachable-column
access, dynamic-library boundaries, and substantial user callback work; results
are not whole-game frame costs or a comparison with another engine.
Unified dispatcher contract
All actor/component event producers enter scene/event_dispatcher.zig. Its
specialized paths share one policy boundary without boxing events or adding a
runtime event-name switch to each actor update:
// Engine-internal API. Gameplay keeps ActorContext / hi.actors / Actors.*.
dispatcher.emit(.message, entity, &message);
dispatcher.queueCollision(world, entity, contact);
dispatcher.flushCollisions(world);
dispatcher.syncActivation(entity);
dispatcher.runPhase(.update, world, descriptor, entries, tick, true);- Ownership:
World.eventsowns message queues, collision/animation queues, and recipient scratch storage. Edit and Play Worlds have separate state. Capacity is retained between deliveries and released at World teardown. First use or a larger burst can grow a queue; steady-state delivery does not allocate one object per event. - Typed events:
emitselects payload type and handler at compile time. Collision, animation, message, and preview require a live active actor. Property notifications also reach inactive actors. Startup and teardown use dedicated entry points because initialization/teardown storage can exist without a resolvable live actor handle. - Lifetime: deferred targets and collision counterparts are generation-checked references, never retained actor pointers. Animation names are copied. Queues copy the current event before invoking callbacks, so reentrant appends cannot invalidate its payload. Native component fan-out stops if a preceding callback retires the actor; teardown intentionally retains access to component storage.
- Languages: Zig runs before Kawa. Script recipients are captured before Zig delivery, using actor handles plus monotonically assigned attachment IDs. Removed/replaced slots are skipped, even if allocator addresses are reused; slots attached during a delivery join subsequent events. Script tick phases snapshot their slot recipients with the same identity checks.
- Ordering: component declaration order, then script attachment order. Collision and animation flushes consume the queue present at entry. Events appended during delivery wait for the next flush; recursively flushing that same queue is a no-op. Clearing queues cancels the current drain. Messages use the bounded multi-pass contract in Actor communication.
- Batches: World still selects tick groups, fixed steps, and LOD policy;
the dispatcher invokes the admitted batches. Generated native component tiles
stay intact. The worker pool in
src/dispatch/is a separate scheduling service. - Execution: event producers and queue mutation run on the game thread after worker barriers. User callbacks run outside World locks. Parallel components retain their restricted batch/command-buffer interface; they cannot emit ordinary events directly from worker threads.
Kawa session update, scene/layer readiness notifications, and script startup and
teardown also enter this dispatcher. Hikari disables Kawa's standalone Events.* bus and its events_* aliases with
the VM creation option enable_events = false. Cross-actor messages use Actors.send / Actors.emit and share the
same World dispatcher as Zig. Component deinit remains storage cleanup (including
scene decoding replacement), not a broadcastable gameplay event.
Activation and teardown contract
Startup happens once; activation can repeat. The actor's declared components share this activation cycle. Destruction exits the cycle, as described below.
Diagram source
flowchart LR
init["Initialize · awake"] -->|start once| inactive["Not enabled"]
inactive -->|onEnable| active["Active"]
active -->|onDisable| inactive
class active accentstart remains a one-time initialization hook, including for authored-inactive
actors. onEnable follows start and runs only if the final state is active.
Changing active state during awake or start does not emit premature activation
callbacks. Re-enabling an actor does not rerun start.
Effective activity is authored actor active AND layer presented. There is no inherited parent-activity gate. Setting the same value again produces no extra activation event. Changing authored activity while a layer is withheld produces no event until effective activity changes after startup. Presenting a layer updates all its actors' effective state before starting/notifying them.
Public activity setters and editor Active edits dispatch synchronously after
updating subsystem state and releasing the World lock. Notification state is
committed before callbacks, so writing the same state from a hook is harmless;
opposite-state writes can synchronously reenter activation hooks. The internal
setEntityActiveLocked / setEntityPresentedLocked functions only update state;
callers finish activation dispatch after unlocking.
Final onDisable runs at teardown, immediately before onDestroy, if the actor
was enabled after startup and has not since been disabled. Never-started or
never-enabled actors receive no synthetic enable/disable pair. Deferred destruction
marks the handle dead first and delivers teardown callbacks during cleanup.
During teardown, component storage remains available through ActorContext.get
/ require, but actor.id is historical: world lookups and target-bound field
operations may fail. Kawa slots have already been destroyed before final Zig
onDisable / onDestroy. deinit is storage cleanup and must not depend on a
live actor, sibling pointers, scripts, or world services. Custom scene decoding
can invoke it outside actor destruction.
Dynamic membership
ActorRef.component(C).add()/remove() and C.addTo(actor)/removeFrom(actor) enter
event_dispatcher.changeComponent. The dispatcher owns validation, deferred
structural commands, and component lifecycle delivery in
events/components.zig.
components/mutation.zig
handles storage and dependency enforcement; scene construction uses this storage
path before the normal awake/start sequence. Inspector live additions use the
same public dispatcher path as gameplay.
Adding to an initialized actor calls the new component's awake with a null scene
view, then start if the actor has started, then onEnable if enabled. A withheld
actor postpones startup/enable until presentation. Removal calls only that
component's final onDisable and onDestroy before reclaiming storage. The actor
and its remaining components keep running. Removing a component from its own
callback is deferred; callback borrows remain valid. If a removal hook retires its
actor, the retiring component is excluded from the actor's teardown fan-out so
hooks are not delivered twice. Changes to actor activity during a new
component's awake or start affect existing components; activation of the new
attachment waits until its own startup completes.
Extra components use generated independent callbacks and tick kernels in
define_component/callbacks.zig.
Tick membership is indexed by component and actor archetype. The dispatcher runs
512-entry tiles using the existing parallel runners and command buffers, without
scanning all actors to discover attachments each frame. update follows the
owning archetype's tick group and the component's tick_rate; lateUpdate uses
post-update and fixedUpdate uses the fixed phase. Declared attachable components
keep the original generated archetype batches when removed and restored. Message
and environment-preview listener rosters are updated on membership changes.
Extra-component batches run after the declared native batches in their phase; they are not merged into a global component-priority order. Do not rely on tuple ordering between an extra component and a declared sibling. Express ordering through tick phases/groups or explicit coordination. Structural changes can allocate and scan the target's pending commands; they are not allocation-free field writes. The repeated native tick path retains batching rather than doing per-frame name lookup or discovering components by scanning the whole World.
Structural commands are FIFO and generation-checked. World ticks (also paused World ticks), tick groups, and fixed phases flush them outside callback borrows and World locks. An actor retired within a callback keeps its storage until the borrow ends and normal cleanup can purge it. Each flush processes one queue snapshot, so hooks cannot create an unbounded recursive structural loop. Physics mutation acquires the scene barrier before World locks; callbacks run unlocked. System removal shares the entity teardown policies for GPU/audio/physics/assets. Component-owned Kawa slots and pending script attachments are removed with their component.
The runtime API and storage limits include committed-state reads, errors, and initialization timing. These are structural changes; ordinary data/field writes remain immediate.
Component scope and current limits
- Ordinary game components do not have independent enabled bits. The new hooks
follow their owning actor. Built-in light
enabled, renderis_visible, and physicsis_activeswitches affect those systems and do not emit actor hooks. onChangedis an actor-wide fan-out carrying a property path, without a component identity. It is a write notification, not an equality-based change detector. Direct mutation such asactor.require(Health).hp = 100does not emit it. There is no additionalchangedoronActiveChangedhook.- Parallel components allow
update,lateUpdate, andfixedUpdatewithhi.ParEntry, plus data cleanup. Ordinary activation/event hooks are rejected. - Swarms have bulk
integrateor per-rowupdate, and data cleanup; no actor lifecycle hooks. Kawa script callbacks are a separate surface documented in Scripting with Kawa; these activation hooks are Zig hooks.
awake accepts errors in its signature, but the current Entity.instantiate
wrapper escalates an initialization error to a panic. Recoverable per-actor
initialization failure is not yet part of the lifecycle contract.
Scene load/unload and editor Play replacement create/destroy actor instances;
those operations use the existing initialization and teardown hooks. Extra
per-actor onSceneLoaded, onSpawn, or onDespawn aliases are not needed for the
same transitions. Dynamic membership reuses these hooks rather than introducing
separate add/remove event names.