defineActor defines an actor type by composing components. Spawning creates an
instance. defineComponent declares ordinary Zig data, optional lifecycle hooks,
and a generated scene/editor schema. Content discovery registers exported actors
and their components automatically. There are no legacy defineEntity or Entry
aliases.
For storage tradeoffs and examples, start with Choosing component storage: attachable, embedded, and swarm.
const hi = @import("hikari_game");
pub const Spin = hi.defineComponent(.{
.name = "spin",
.data = struct {
degrees_per_second: f32 = 45,
state: struct { elapsed: f32 = 0 } = .{},
pub fn update(self: *@This(), actor: hi.ActorContext, tick: *const hi.TickContext) void {
self.state.elapsed += tick.dt;
var rotation = actor.rotationEuler();
rotation[1] += self.degrees_per_second * tick.dt;
actor.setRotationEuler(rotation);
}
},
});
pub const Beacon = hi.defineActor(.{
.archetype = "beacon",
.components = .{ hi.ComponentRender, Spin },
});Components are attachable by default. Their fields have defaults, appear in the
inspector when supported, and serialize under the component's stable name. Use
.storage = .embedded for fixed composition when needed: data lives in the actor
payload, with the same scene and inspector schema. Storage does not change the
callback API. Actor component tuple order defines execution order within a tile;
.requires = .{OtherComponent} validates presence, not scheduling order.
Engine components, such as lights
Built-in components join the same actor composition tuple. They declare engine capabilities; their runtime data is accessed through subsystem APIs.
pub const LampLogic = hi.defineComponent(.{
.name = "lamp_logic",
.requires = .{hi.ComponentLight},
.data = struct {
intensity: f32 = 10,
pub fn start(self: *@This(), actor: hi.ActorContext) void {
actor.render().setLight(actor.id, .{
.kind = .point,
.color = .{ 1, 0.8, 0.6 },
.intensity = self.intensity,
.radius = 8,
.cast_shadow = true,
});
}
pub fn onChanged(self: *@This(), actor: hi.ActorContext, _: []const u8) void {
actor.render().setLight(actor.id, .{ .intensity = self.intensity });
}
},
});
pub const Lamp = hi.defineActor(.{
.archetype = "lamp",
.components = .{ hi.ComponentLight, LampLogic },
});requires rejects an actor composition missing the light capability. The light
is configured in start; onChanged keeps it synchronized with inspector edits
and target.component(LampLogic).set(.intensity, 20). ComponentLight is a capability
marker, not a public light-data struct, so target.component(hi.ComponentLight).set(.intensity, 20) is
not available. Use actor.render().setLight(actor.id, patch) or
hi.render().setLight(target, patch) to update the engine light directly.
For a scene-authored light without custom behavior, the tuple can contain only
hi.ComponentLight. Other built-ins, such as ComponentRender, ComponentCamera,
and ComponentPhysics, compose the same way.
Scoped actor access
Lifecycle hooks belong to component Data, and actor events fan out in component
tuple order. See Actor and component lifecycle for every
event's dispatch source, activation order, teardown guarantees, and the current
limits of runtime-added components.
All ordinary lifecycle hooks receive hi.ActorContext as their second argument:
| Hook | Arguments after self | Return |
|---|---|---|
awake | actor, ?*const hi.SceneActorView | void or error union of void |
start, onDestroy | actor | void |
onEnable, onDisable | actor | void |
update, lateUpdate, fixedUpdate | actor, *const hi.TickContext | void |
onCollision | actor, *const hi.CollisionInfo | void |
onAnimationEvent | actor, *const hi.AnimationEvent | void |
onMessage | actor, *const hi.Message | void |
onChanged | actor, []const u8 | void |
onEnvironmentPreview | actor, hi.EnvironmentPreview | void |
deinit(self, allocator) releases owned data at storage teardown or replacement
by a custom scene decoder; it does not receive an actor context. Initialization
and scene codecs retain their distinct construction contracts.
Initial activation follows awake → start → onEnable. Later effective activity
changes dispatch onDisable / onEnable without rerunning start. These hooks
follow the actor's authored active state combined with layer presentation; they
do not add independent enabled flags to ordinary components.
actor.id is the generation-safe weak handle. actor.get(Health) returns an
optional *Health.Data; actor.require(Health) requires presence. These resolve
embedded, column, and instance-added data.
position, rotationEuler, scale, and their setters resolve transforms only
when called. No transform lookup is charged to callbacks that do not use one.
actor.world(), actor.render(), and the event helpers expose world services.
Builtins remain capability markers; use these subsystem services to operate them.
Parallel tick hooks use hi.ParEntry, with direct transform data and deferred
commands. They cannot use ordinary actor lifecycle hooks. Parallel access remains
explicit through .parallel = true and does not grant sibling/world access.
Authored values and runtime state
Ordinary fields are authored. Put temporary values in an inline state field:
speed: f32 = 5,
state: struct {
elapsed: f32 = 0,
grounded: bool = false,
} = .{},The whole state field is excluded from inspector schemas, scene output, and
replication. Applying authored settings preserves existing state, and scene
input that tries to author it is rejected. Defaults initialize it on spawn.
This convention adds no allocation, indirection, or generated instance wrapper.
Owned state still needs deinit; the generated scene decoder can skip that state
and decode ordinary authored values. Custom scene codecs remain responsible for
their own ownership policy.
metadata.properties remains available for labels, ranges, widgets, and individual
transient fields when a flat script-facing path matters. metadata.runtime_only
hides the inspector surface; it does not mean all values are transient. Use
state for that distinction.
Runtime component membership
Entry points
| Caller | Add | Remove | Result |
|---|---|---|---|
| Typed actor view | target.component(C).add() | target.component(C).remove() | Zig !void |
| Typed component | C.addTo(target) | C.removeFrom(target) | Zig !void; same implementation |
| Name-based Zig / tools | hi.world().componentAdd(target, "health") | hi.world().componentRemove(target, "health") | Zig !void |
| Parallel component callback | entry.cmd.addComponent(C, target) | entry.cmd.removeComponent(C, target) | void; records a worker command |
| Kawa | Actors.add(actor, "health") | Actors.remove(actor, "health") | Boolean request acceptance |
Check committed presence with target.component(C).has(), C.hasOn(target),
hi.world().componentHas(target, "health"), or Kawa Actors.has(actor, "health").
There is no implicit add-on-write, replacement operation, multiple instances of
the same type, or automatic actor migration. Add/remove changes membership, not
the actor's archetype or identity. Export runtime-only components so content
discovery registers them even when no actor tuple includes them.
try target.component(Health).add(); // Health.Data defaults
try target.component(Health).remove();
try Health.addTo(target); // Equivalent component-first form
try Health.removeFrom(target);
try target.component(hi.ComponentLight).add();
try target.component(hi.ComponentLight).remove();Register the component with the game/module before attaching it. One actor has
at most one instance of each component type. add reports AlreadyPresent;
remove reports NotPresent. Missing requirements return DependencyMissing;
removing a requirement still used by another component returns RequiredByOther.
Requirements are checked in queued command order; the engine does not implicitly
add dependencies or cascade removals.
Errors: compile time versus runtime
Typed operations on .embedded produce a compile error, including worker
commands. catch cannot intercept a compile error. Passing a defineSwarm to
ActorRef.component also fails to compile: a swarm has no actor component view.
For names only known at runtime, use the name-based World API and catch its error:
hi.world().componentRemove(target, "door_controller") catch |err| switch (err) {
error.NotAttachableStorage => return, // Embedded membership is fixed.
error.NotPresent => return, // Already absent.
else => return err,
};The enclosing function in this example returns !void.
| Runtime error | Meaning |
|---|---|
EntityNotInWorld | Target is dead, retiring, stale, or not in this World. |
UnknownComponent | The name is not registered in this World. |
NotAttachableStorage | The registered type is embedded or otherwise not attachable. |
MandatoryComponent | Attempt to structurally change Transform, which every actor requires. |
AlreadyPresent | Add requested for a component already present in projected membership. |
NotPresent | Remove requested for a component absent from projected membership. |
DependencyMissing | A required component would be missing at this point in the command sequence. |
RequiredByOther | A remaining component requires the component being removed. |
OutOfMemory | Storage or command allocation failed. |
| Initialization/script errors | A synchronous attachment failed during initialization; the error propagates. |
These APIs return an open Zig error union; keep an else branch when handling a
subset. The table describes public validation and common failure paths, not an
exhaustive list of every user-defined awake error.
Execution and initialization
Outside gameplay callbacks these calls commit synchronously. Within a dispatcher
callback they enqueue a generation-checked command. Commands commit at the next
World tick or tick-group/fixed-phase structural boundary, after borrowed rows and
worker tiles are no longer in use. try confirms validation and queue acceptance;
a later allocation or initialization failure is logged. Commands raised by
transition hooks wait for a subsequent boundary. has, field reads, and field
writes see committed membership: adding and immediately setting a field inside
the same callback does not work. Put initial user values in Data defaults or
awake; configure newly attached system components in a subsequent callback.
Parallel callbacks record the same changes with
entry.cmd.addComponent(Health, target) or
entry.cmd.removeComponent(Health, target). These commands drain in deterministic
slice/append order, then commit at a structural boundary. Parallel code must use
this command buffer; calling ActorRef/HostApi mutation methods from worker threads
is unsupported.
Worker command methods have no catchable return value: recording/drain failures
are diagnosed by the command system. Kawa returns false for a rejected request;
it does not expose Zig error values. None of the deferred forms returns a future,
completion receipt, or transaction. A failed queued command is logged and later
commands still run with fresh validation. Queued commands targeting a retired
actor are discarded, never redirected to a recycled actor slot. A sequence of
several operations is not atomic; earlier successful operations stay committed.
Queue required components before dependents, and queue removal of dependents before requirements. Removing then adding the same type queues a fresh instance. A retained typed view resolves that replacement; it does not identify a particular attachment generation. Do not retain raw row pointers across callbacks.
An added component receives awake(null), then start and onEnable when the
actor's startup/activity permits. Removal sends final onDisable when enabled,
then onDestroy, then releases scripts, owned data, and system resources.
Re-adding constructs fresh defaults and a fresh lifecycle. This also works for
attachable components declared in defineActor: their fixed payload slot becomes
absent and can later hold a new column row. Weak component views remain valid as
views, but resolve whichever instance is currently attached.
| Storage | Runtime membership | Mutable contents |
|---|---|---|
User .attachable | Add/remove, whether declared by the actor or added later | Yes |
| System components | Add/remove with subsystem construction and cleanup | Through their system APIs |
| Transform | Mandatory; cannot remove | Yes |
.embedded | Fixed within the actor payload; cannot add/remove | Yes, including runtime state |
defineSwarm | Fixed population schema; rows are not actors and accept no component attachments | Yes; use hi.swarm(C).spawn, retire, get, set, and column operations |
An actor with embedded components can still gain and lose other attachable components. Immutability describes membership/layout, not the actor's values or the swarm population size. Runtime changes affect the current World; they do not rewrite the authored scene document. Inspector authoring remains a document edit.
Optional system types are Render, Physics, Light, Camera, Audio, VisualZone, Decal, FogVolume, Animation, Particle, and Kawa. Use each system's configuration API after attachment; attaching a row alone does not provide a render asset, audio clip, particle program, animation graph, or script asset. Resource creation and publication still follow that subsystem's normal asynchronous work. Physics removal/re-addition uses attachment generations to reject old simulation replies.
See Choosing component storage for layout costs and why embedded and swarm membership stays fixed. The Runtime Components example demonstrates declared and extra attachments, system configuration after commit, lifecycle counters, fresh defaults, typed call directions, field paths, and catching an unsupported name-based request.
See Component lifecycle for dispatch
and batching details. Kawa uses Actors.add(actor, "health") and
Actors.remove(actor, "health"), returning whether the request was accepted.
Typed cross-actor fields
const hp: ?u64 = target.component(Health).get(.hp);
_ = target.component(Health).set(.hp, 100);
_ = target.component(Motion).set(.velocity, .{ 1, 0, 0 });
_ = target.component(Motion).setAt(.velocity, 1, 2);
const elapsed: ?f32 = target.component(Health).getPath("state.elapsed");
_ = target.component(Health).setPath("state.elapsed", 2.5);target.component(Health) creates a typed view holding only the weak ActorRef;
it does not resolve or retain a data pointer. Each operation resolves the current
component afresh, so the view can be retained across ticks. has() checks presence;
creation itself does not imply the actor or component exists. Swarms cannot be
accessed through actors.
get and set accept a compile-time Component.Field enum generated from the top-level
fields of Component.Data, so .hp has a concrete enum type for contextual
completion and misspellings fail to compile. getPath and setPath accept compile-time
strings for nested exposed fields or computed paths such as "state." ++ "elapsed".
The view methods inline into the same field accessors, with no allocation or
runtime selector or path lookup. The example's Health.Data.state contains
elapsed: f32 = 0.
Selectors and paths must identify an exposed, safely copyable field; selecting a
whole nested struct still requires direct ActorContext.get access. get returns
the declared Zig type wrapped in an optional for missing/dead actors or components;
optional fields therefore have an outer presence optional. set accepts exactly
the declared type and reports success. Arrays/vectors additionally support
getAt(.field, index) / setAt(.field, index, value), and nested arrays/vectors use
getPathAt("nested.field", index) / setPathAt("nested.field", index, value).
Indexing checks bounds and copies the field value. Strings and AssetRef use the
same get/set names; setters intern borrowed strings before publication.
Owning pointers are not exposed through typed copies.
Both call directions are supported and share the same implementation:
_ = Health.field(.hp).set(target, 100);
_ = target.component(Health).set(.hp, 100);
const elapsed = Health.fieldPath("state.elapsed").get(target);
const same_elapsed = target.component(Health).getPath("state.elapsed");The component-level factories also expose accessor types for generic code,
including their Value and Element types. Prefer the actor-bound view for
ordinary gameplay; component-first access remains a supported public API.
Direct assignment through actor.require(Health).hp remains callback-scoped
pointer mutation and does not call onChanged automatically.
Field paths resolve at compile time; typed calls use a component ID and field index, without runtime string searches. Values cross the host boundary as exact typed byte copies, validated against the
registered field type and size. Wide integers never pass through f64. Writes
notify onChanged and retain numeric script synchronization. Direct sibling
pointer mutation is ordinary gameplay data access and does not imply a change
notification. Dynamic tooling and Kawa keep hi.actors/Actors.* name-keyed APIs.
Generated declarations and editor Rebuild
Component.Field, typed accessors, lifecycle adapters, and scene/editor schemas
are Zig compile-time declarations. They do not produce a separate source file.
Their implementation starts in src/hikari/sdk/src/define_component.zig and
src/hikari/sdk/src/define_component/; actor composition lives in
src/hikari/sdk/src/define_actor.zig. Actor-bound component views live in
src/hikari/sdk/src/component_ref.zig. The compiled declarations become part of
the game module.
Content discovery does write <game-source>/content_manifest.gen.zig, beside
root.zig. sdk/build/content_manifest.zig rescans the game's Zig sources and
rewrites this import list on every build-script invocation, including editor
Recompile (the toolbar action whose tooltip is "Rebuild the game module").
Added, renamed, and removed source files are therefore reflected on the next
build. Discovery reads the filesystem and does not use Git tracking, so ignored
projects such as Stylized and Bistro work the same way. Do not edit generated
manifests by hand.
The editor invokes zig build game-module from the project's resolved SDK root.
Zig tracks imported source changes and reevaluates the affected compile-time
declarations. Under the default project layout, editor build outputs live at:
| Output | Location relative to the project |
|---|---|
| Local/global Zig caches | .engine/cache/game/zig/local/ and global/ |
| Plugin composition sources | .engine/cache/game/zig/local/hikari-plugins/<build-key>/ |
| Built game library on macOS | .engine/lib/libgame.dylib |
| Staged reload generation on macOS | .engine/hot-reload/gen-<n>/game.dylib |
| Full compiler log | .engine/logs/game-module-build.log |
On success, the editor loads a fresh generation, rebuilds scene registrations, and refreshes the inspector. Replacement waits until the editor can safely swap modules; compilation failures leave the existing module running.
Recompile uses the selected SDK: an explicit engine_sdk path, otherwise a
bundled sdk/ beside the editor executable, with a monorepo fallback to
src/hikari/sdk/. It does not refresh a bundled SDK from engine source or rebuild
the running editor host. Kaji's editor packaging restages bundled SDK inputs when
their content hash changes. Engine/host ABI changes require rebuilding and
restarting the editor; ordinary game component changes use Recompile.
Swarm populations
pub const Crowd = hi.defineSwarm(.{
.name = "crowd",
.capacity = 100_000,
.data = struct {
x: f32 = 0,
y: f32 = 0,
z: f32 = 0,
pub fn integrate(store: *hi.SwarmStore(@This()), dt: f32) void {
for (store.liveIndices()) |index| store.column("x")[index] += dt;
}
},
});Use hi.swarm(Crowd) for store operations. A swarm has no actor identity,
hierarchy, actor scripts, or actor lifecycle hooks. Supply bulk integrate or
per-row update(self, SwarmEntry, dt), not both. Capacity is required and nonzero.
The existing World-owned SoA storage and deferred retirement path are retained.
Diagnostics and validation
Unknown declaration options, malformed dependency tuples, unsupported hook signatures, and invalid typed field paths fail at the declaration. New options must be explicitly admitted by the SDK; misspellings never silently disable behavior.
From src/hikari, run zig build test-sdk compile-fail -Dgame-src=<absolute game>
for authoring contracts. zig build test covers host lifecycle and field access;
zig build entity-bench measures dispatch. Build actual products with Kaji.
Live inspection
Play's read-only inspector reflects live transform, system and public user fields,
including components attached after spawn. Added sections carry a Runtime pill.
Normal callback assignments and field/system setters participate automatically;
hidden, transient, and Data.state remain excluded. Only the selected actor is
observed, with coalesced notifications rather than per-frame field polling. See
live actor inspection
for the callback invalidation policy and the prepared Play-only edit boundary.