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
Guides11 min read

Actor and component lifecycle

On this page
On this pageHooks and dispatch locationsDispatch cost and measurementUnified dispatcher contractActivation and teardown contractDynamic membershipComponent scope and current limits Back to top

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.

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

HookRemaining argumentsWhen dispatchedDispatch origin
awake?*const hi.SceneActorViewInstance initialization, including inactive and withheld actors; scene view is optionalworld_scene_spawn.zig spawnByArchetypeWith and scene_loader/instantiate.zig → Entity.instantiate → bridge onInstantiate
startNoneOnce per actor when the spawn/load path starts it; authored-inactive actors still start, but withheld layers defer startup until presentationscene_loader/spawn.zig, scene_loader/instantiate.zig, world_scene_layer.zig presentSceneLayer → Entity.start → bridge onStart
onEnableNoneAfter startup when effectively active, then each effective inactive → active transitionevent_dispatcher.zig syncActivation, reached after start, world_entities.zig activity/presentation setters, and world_scene_edit.zig applySceneActorActive → bridge onEnable
onDisableNoneEffective active → inactive transition, or final teardown if enabled was previously deliveredThe same syncActivation path, plus Entity.terminate from world_entities.zig queueEntityForCleanup → bridge onDisable
update*const hi.TickContextActive actors in the archetype's tick group, subject to component cadenceworld_tick.zig runTickGroup → dispatcher runPhase(.update) → bridge updateBatch → generated component tile runners
lateUpdate*const hi.TickContextActive actors in .post_update; component tick_rate applies only to updateworld_tick.zig runTickGroup(.post_update, ...) → dispatcher runPhase(.late_update) → bridge lateUpdateBatch → generated runners
fixedUpdate*const hi.TickContextActive actors during admitted fixed steps; requires a nonzero fixed timestepworld_tick.zig runFixedUpdateSteps → dispatcher runPhase(.fixed_update) → bridge fixedUpdateBatch → generated runners
onCollision*const hi.CollisionInfoContact events for active actors; phase distinguishes enter, stay, and exitworld_entities.zig dispatchCollisionEvents → Entity.onCollision → bridge onCollision
onAnimationEvent*const hi.AnimationEventAuthored clip markers after animation evaluation, for active actorsworld_tick.zig dispatchAnimationEvents → Entity.onAnimationEvent → bridge onAnimationEvent
onMessage*const hi.MessageMessages delivered to active, non-terminating actors; Zig handlers precede Kawa script slotsevents/messages.zig flushMessages / immediate delivery → Entity.deliverMessage → bridge onMessage
onChanged[]const u8Explicit property notifications, including successful typed/dynamic field writes and inspector data edits; inactive actors can receive themworld_actors.zig field setters and world_scene_edit.zig data edits → Entity.userDataChanged → bridge onUserDataChanged
onEnvironmentPreviewhi.EnvironmentPreviewTransient editor environment preview for active supporting actors; restore authored state when active becomes falseworld_environment_preview.zig applyEnvironmentPreview → Entity.applyEnvironmentPreview → bridge onEnvironmentPreview
onDestroyNoneTeardown while component storage is still available, after any final onDisableworld_entities.zig queueEntityForCleanup → Entity.terminate → bridge onTerminate
deinitstd.mem.Allocator instead of actor contextRelease owned row data; also used when a custom scene decoder replaces an existing valueGenerated 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:

sh
zig build entity-bench -Dgame-src=<absolute-game-src> -Doptimize=ReleaseFast -Dprofiler-timing=false -- --case=activation --json

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

zig
// 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.events owns 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: emit selects 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
Diagram source
flowchart LR
    init["Initialize · awake"] -->|start once| inactive["Not enabled"]
    inactive -->|onEnable| active["Active"]
    active -->|onDisable| inactive
    class active accent

start 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, render is_visible, and physics is_active switches affect those systems and do not emit actor hooks.
  • onChanged is 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 as actor.require(Health).hp = 100 does not emit it. There is no additional changed or onActiveChanged hook.
  • Parallel components allow update, lateUpdate, and fixedUpdate with hi.ParEntry, plus data cleanup. Ordinary activation/event hooks are rejected.
  • Swarms have bulk integrate or per-row update, 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.

PreviousMigration from Unity / UnrealNext Systems

Documentation follows the current engine checkout.

Snapshot cc148c75Source docs/guides/component-lifecycle.md
On this pageHooks and dispatch locationsDispatch cost and measurementUnified dispatcher contractActivation and teardown contractDynamic membershipComponent scope and current limits Back to top