Use attachable components for ordinary gameplay, embedded components for permanent actor state, and swarms for large populations that can operate without full actor services.
Storage is chosen per component, not per actor. One defineActor can combine
attachable and embedded components. defineSwarm is a separate population API:
its rows cannot go in an actor's .components tuple.
The choice belongs to the component declaration: the same component type does
not switch between attachable and embedded storage on different actors.
Compare the three choices
| Attachable | Embedded | Swarm | |
|---|---|---|---|
| Declaration | defineComponent (default) | defineComponent with .storage = .embedded | defineSwarm |
| Data layout | Dense array of whole component values; actor stores a row index | Component value inside the actor payload | Separate array for each data field (structure of arrays / SoA) |
| Identity | Belongs to an actor with ActorRef | Belongs to an actor with ActorRef | Population row with SwarmRef |
| Membership | Add/remove individual components at runtime | Fixed by the actor definition | Fixed row schema; spawn/retire rows |
| Capacity | Grows as needed; growth can allocate | Follows actor population and payload size | Maximum capacity reserved when the store is created |
| Ordinary actor hooks | Yes | Yes | No; bulk integrate or per-row update |
| Scene and inspector fields | Yes, for supported authored fields | Yes, for supported authored fields | No individual scene actor or actor inspector |
| Component replication option | Supported for eligible fields | Unsupported | Unsupported |
| Strongest fit | Flexible, reusable gameplay components | Small, permanent actor-specific logic/state | Uniform simulation over many simple rows |
Both actor storage kinds allow mutable data, including transient Data.state.
Fixed membership does not make embedded values immutable. A swarm's fixed
capacity does not make its population size fixed.
Attachable: the general-purpose default
Choose attachable components when different actors share the same behavior, or when an individual actor may gain or lose that behavior. Health, inventory, status effects, interaction, and optional abilities are typical examples.
const hi = @import("hikari_game");
pub const Health = hi.defineComponent(.{
.name = "health",
.data = struct {
hp: u32 = 100,
},
});
pub const Player = hi.defineActor(.{
.archetype = "player",
.components = .{Health},
});
// The target must currently lack Health.
pub fn giveHealth(target: hi.ActorRef) !void {
try target.component(Health).add();
}.storage = .attachable is optional because it is the default. Exported content
discovery registers the actor and component. Components used only for runtime
attachment must also be registered with the game/module.
Strengths: component values are packed together by type, and membership can
change without resizing the actor's fixed payload. Empty Data is also valid:
a component that only supplies hooks still participates in dispatch and
add/remove operations.
Limitations: resolving an actor's data needs a row lookup. Structural changes can move rows, so pointers are borrowed only for the current callback. Declared components have indexed payload slots; components attached to an instance outside its declared tuple use a side-list lookup. Declare commonly expected components in the actor tuple even when they must remain removable.
Add/remove requests inside callbacks commit at a later structural boundary.
Adding and then immediately reading or setting the new component in the same
callback does not see the queued instance. Use defaults or awake for its initial
data. See runtime membership for
dependencies, errors, and activation order.
Embedded: permanent actor state
Choose embedded components for compact logic or state that every instance of an actor type always needs: a door controller, puzzle state, or a permanent motion controller.
const hi = @import("hikari_game");
pub const Spin = hi.defineComponent(.{
.name = "spin",
.storage = .embedded,
.data = struct {
degrees_per_second: f32 = 45,
state: struct { yaw: f32 = 0 } = .{},
pub fn update(self: *@This(), actor: hi.ActorContext, tick: *const hi.TickContext) void {
self.state.yaw = @mod(self.state.yaw + self.degrees_per_second * tick.dt, 360);
actor.setRotationEuler(.{ 0, self.state.yaw, 0 });
}
},
});
pub const Spinner = hi.defineActor(.{
.archetype = "spinner",
.components = .{Spin},
});This rotates the actor's transform. Add and configure a render component if the
actor should be visible. degrees_per_second is authored; state.yaw is runtime
state, excluded from scene output and the inspector.
Strengths: data lives directly in the actor payload, so access avoids the separate component-row lookup. Embedded components use the same ordinary hook signatures and supported authored field schemas as attachable components.
Limitations: the component cannot be added or removed from an individual actor, and the component replication option is unavailable. Large embedded data enlarges every instance's payload. Reading one embedded field across many actors does not provide the contiguous field array a swarm does. Use measurement before concluding that embedding makes an entire workload faster.
Membership is fixed because the actor's generated payload has compile-time sizes
and offsets, with no nullable row slot for embedded data. Removing or inserting
embedded values would change that layout and invalidate compiled sibling access.
Use an attachable type when presence must vary; use fields on an embedded type to
represent states such as idle/running. Typed add/remove is a compile error;
name-based add/remove returns catchable error.NotAttachableStorage.
An actor can mix the two kinds: .components = .{Spin, Health} permanently embeds
Spin while keeping Health removable. Both can access sibling data through
callback-scoped actor.get(C) / actor.require(C).
Swarm: bulk population simulation
Choose a swarm when many instances share one data shape and do not need actor hierarchy, component attachments, individual actor scripts, or actor events. Examples include background crowds, boids, and simple projectiles whose movement and collision policy you implement in population logic.
const hi = @import("hikari_game");
pub const Motes = hi.defineSwarm(.{
.name = "motes",
.capacity = 10_000,
.data = struct {
x: f32 = 0,
speed: f32 = 2,
remaining: f32 = 3,
pub fn integrate(store: *hi.SwarmStore(@This()), dt: f32) void {
const x = store.column("x");
const speed = store.columnConst("speed");
const remaining = store.column("remaining");
for (store.liveIndices()) |index| {
x[index] += speed[index] * dt;
remaining[index] -= dt;
if (remaining[index] <= 0) store.retireAt(index);
}
}
},
});
// Call after the population has been registered in the active World.
pub fn emitMote() !void {
_ = hi.swarm(Motes).spawn(.{ .x = 1, .speed = 4 }) orelse
return error.PopulationFull;
}The engine runs integrate and then drains retirements. Retiring a row during
integration keeps the current iteration valid; that slot becomes reusable after
the drain. Duplicate retirement requests are coalesced, and retirement uses
capacity reserved at construction. Keep generation-checked SwarmRef handles
when referring to rows across ticks.
Strengths: field arrays let bulk logic touch only the fields it needs.
liveIndices() visits the live population even when most capacity is unused.
Rows avoid the full actor object and component dispatch machinery. This example
allocates no memory during integration or retirement.
Limitations: capacity is required, nonzero, and bounded by the u32 index
range. Unused capacity still consumes memory for field arrays and bookkeeping.
spawn returns null when full. Rows must be non-owning values; owning
resources and deinit belong in actor components. Borrowed strings and references
must outlive their use. A swarm is not a drop-in replacement for actors with
physics, animation, hierarchy, or messages. Rendering needs an explicit visual
binding; the simulation above does not draw anything.
The schema is fixed because each field has a population-wide column, and bulk
kernels compile against those columns. A per-row optional component would require
another layout and dispatch model, losing the uniform field traversal this API
is designed for. Mutate existing fields, use a flag for an optional state, or
spawn into a separately defined population with the other schema. This is an
explicit application operation, not an automatic migration preserving a
SwarmRef. Use actors when individual objects need dynamic capabilities.
SwarmRef cannot be passed where ActorRef is required. Typed actor component
access rejects swarm definitions at compile time, not as a catchable error.
Passing a registered swarm name to name-based actor membership is rejected as
NotAttachableStorage; an unregistered name returns UnknownComponent.
Use bulk integrate(store, dt) for field-oriented work. The alternative is
update(self, hi.swarm_store.SwarmEntry(hi.SwarmStore(@This())), dt), which gathers a row into
a temporary value and writes it back. Choose one hook, not both. Column slices
cover capacity, including dead and uninitialized slots: iterate liveIndices()
unless a full-capacity traversal explicitly checks liveness. Let the engine drain
retirements after callbacks; do not drain while iterating borrowed live indices.
Decide by the required behavior
| Requirement | Choice |
|---|---|
| A reusable gameplay component, with no special constraints | Attachable |
| Add/remove a capability on individual actors | Attachable |
| Use the SDK's component replication option | Attachable |
| Permanent, small actor state with frequent direct access | Embedded |
| A large uniform population that can avoid actor services | Swarm |
| Thousands of objects that still need full actor services | Actor components; population size alone does not justify a swarm |
Storage and scheduling are separate choices. .parallel = true selects parallel
tick hooks for ordinary components; it is not a fourth storage kind and changes
the available callback API. Neither attachable nor embedded pointers may escape
a callback. See Gameplay API and
Component lifecycle for those contracts.
The Runtime Components tutorial keeps an embedded controller while adding/removing user and system attachments. It also demonstrates a catchable name-based rejection without stopping the actor.
Inspecting runtime changes
Play's inspector stays read-only but reads live values for both embedded and attachable components. Attaching a user component exposes its generated public field schema immediately; runtime assignments in its callbacks update the shown values. Built-in additions behave the same way. Added components carry a Runtime pill, and removal drops the section. Hidden/transient fields stay hidden. Embedded membership remains fixed even though its public values are live; swarms have no actor inspector. See live actor inspection for notification costs and the future Play-only edit boundary.