| Field | Value |
|---|---|
| Status | Nested instances + variants; Shinra .shinprefab (HSC1) cook + runtime binary load on the cooked path. |
| Audience | Engine, editor, game modules |
| Related | Scenes and gameplay, Assets and Shinra, Open component model |
Summary
A prefab is a reusable actor subtree asset. A scene references an instance and records what differs from the template; the engine expands it at load (prefab/hydrate.zig). Expanded actors exist in memory only — each carries a prefab link stamp for identity, and every consumer downstream (editor hierarchy, physics, selection, undo, MCP) sees the same flat actor list it always did.
Instances cannot go stale. A scene holds no copy of the template, so a prefab edited anywhere — the editor, a script, a git pull — is picked up the next time the scene is opened, with no refresh pass to run and nothing to forget.
Overrides are per field, addressed as render.material, light.intensity, transform.position, components.<name>. Overriding a material therefore leaves casts_shadow tracking the template — the failure mode of block-granular overrides, where touching one field silently freezes every field beside it.
Authoring format
{
"kind": "com.hikari.prefab",
"version": 1,
"id": "crate_stack",
"name": "Crate Stack",
"root": "root",
"actors": [ /* SceneActorDesc-shaped; local ids; root has no parent_id */ ]
}- On disk:
assets/prefabs/<stem>.prefab.json(authoring). - Cooked:
.shinprefab(HSC1, same wire as.shinscene) under cook/workprefabs/**— never next to authoring JSON. Shinra--prefabs <project>/assets/prefabs; Kaji passes this when the directory exists. Product stages intodata/prefabs/. - Runtime load (
World.retainPrefab/ AssetStore): prefer cooked.shinprefab(no JSON parse). Authoring.prefab.jsononly in Debug (mirror scene policy). - A scene references instances in a top-level
instancesarray (never expanded actors):
"instances": [{
"id": "lamp_blue",
"prefab": "prefabs/street_lamp",
"transform": { "position": [-7, 0, 0] },
"overrides": [
{ "local_id": "bulb", "path": "render.material", "value": "asset://./materials/blue" }
],
"removed": ["banner"],
"added": [ /* actors authored into this instance */ ]
}]Members mint {id}__{local_id} at expansion, so anything referencing a member by scene id (joints, scripts) keeps working. The per-actor prefab stamp still appears inside a .prefab.json, where it marks a nested instance member — scene_json_validator.Schema is what keeps the two documents apart while they share one decoder.
Variants (base)
Optional on version 1:
{
"kind": "com.hikari.prefab",
"version": 1,
"id": "enemy_red",
"name": "Enemy Red",
"base": "prefabs/enemy",
"root": "root",
"actors": [ /* full or sparse override set keyed by local id */ ]
}base: extension-free stem orasset://./…of the parent prefab.- Variant
actorsmay be a full copy or sparse (only overridden locals + structure adds). - Resolution (
scene/prefab/resolve.zig):- Load/resolve
baserecursively (cycle detect + depth limit, default 8). - Start from base’s fully resolved actor list.
- Matching local ids: merge authoring fields from variant onto base (Apply-style via
diff.zig). - Actors only in variant: append (structure add).
- Actors only in base: keep.
- Result is a fully resolved
PrefabAssetwith variant id/name/root andbase == null.
- Load/resolve
- Missing base → hard error on resolve.
- Base chain cycle →
PrefabCycle. - Editor Open Prefab edits the variant document as-is;
baseis preserved on save and editable via inspector Base prefab (set_prefab_base). - Make Prefab writes no
baseby default. Create Variant… writes a new prefab withbaseset (optional sparse overrides from a linked instance).
Shared expand
Module: src/hikari/src/scene/prefab/
expandToSceneActors(allocator, prefab, options) ![]SceneActorDesc- Options: instance
root_id, optional rootparent_id, root transform override,asset_urifor links, optionalreservedhost ids, optionalid_probe, optionalloaderfor nested re-hydrate,max_nest_depth(default 8). - Child scene ids:
{root_id}__{local_id}(collision sanitize with-Nsuffix). Ids overexpand.id_buf_len(512) fail withPrefabIdTooLong. - Root placement (parent and transform) is applied on the instance root whether it expands as a plain actor or as a re-hydrated nested root.
reservedis for small caller-owned sets (an open document). A host with a large live index passesid_probeinstead —World.spawnPrefabdoes, so a spawn costs one lookup per prefab actor rather than a copy of the whole scene-id index per instance.- Pure logic — used by editor place/make and
World.spawnPrefab.
Nested prefabs
- A prefab asset may contain actors that are themselves prefab instance members (
ScenePrefabLinkpointing at another prefab). - Validate allows nested links. Members sharing
prefab.root_idmust share the sameprefab.asset(NestedGroupInconsistentotherwise). Parent-id cycles still rejected. - Make Prefab allows selection that includes nested instances; nested links are stored (with
root_idremapped when the nested root is in the subtree). Links for the selection instance (sameprefab.root_idas the selection root) are cleared so co-members become outer-owned locals — only true nested groups (differentroot_idstill in the subtree) keep leaf stamps. - On expand of an outer prefab (when
loaderis set):- Remap all scene ids as usual.
- For each distinct nested instance group (
asset+ templateprefab.root_id):- Load nested asset via loader, resolve variants, expand under the remapped nested-root scene id.
- Apply overrides from the outer template’s stored members (
mergeRefreshvs nested source) onto re-expanded actors. - Nested members keep leaf prefab links (not the outer asset);
root_idis the new nested root scene id.
- Soft path if load fails: keep stored expanded members, remap ids/
root_id, warn once — content is not hard-broken offline.
- Cycle detection walks the prefab asset stem graph during nested load; depth limit stops runaway nests.
- Apply / Revert / Unpack still work per leaf link on the instance. Unpack clears members matching that instance
root_id(nested leaves unpack separately).
Loader
pub const PrefabLoader = *const fn (ctx: *anyopaque, allocator: Allocator, stem: []const u8) anyerror!PrefabAsset;
// free via prefab.free after use
pub const Loader = struct { ctx: *anyopaque, load: PrefabLoader };
pub const ExpandOptions = struct {
// …
loader: ?Loader = null,
max_nest_depth: u8 = 8,
};World retainPrefab / spawnPrefab and editor place/make inject a loader that reads cooked .shinprefab (AssetStore) first; authoring .prefab.json only in Debug. Pure tests supply fixtures without World.
retainPrefab resolves variants before caching; expand re-hydrates nested instances via the same loader.
Hydrate / dehydrate
| Direction | Module | When |
|---|---|---|
instances → actors | scene/prefab/hydrate.zig | Every scene load, both runtime and editor |
actors → instances | editor/scene_document/dehydrate.zig | Document save |
Hydrate runs inside the scene decoder, not in world.loadScene: scene_loader/preload.zig walks asset.actors to retain materials and meshes before the world loads, so a member appearing any later would render without its assets resident. Order per instance is fixed — resolve variants → expand (nested instances re-hydrate through the same loader) → drop removed → apply overrides → append added. Templates are decoded once per stem per load, so twelve lamps cost one decode. A broken instance warns and contributes nothing rather than failing the scene.
Dehydrate needs no bookkeeping during editing, which is why scene_document/{commands,edits_*,apply}.zig and undo were untouched by this model. Hydrate stamps each actor with its outer authored-instance ownership while retaining the leaf prefab link used by nested prefab operations. Save groups by that ownership stamp, treats an unstamped actor under a member as an added, treats an expected template local with no actor as a removed, and derives overrides from the diff.
The prefab loader is injected (prefab.Loader): runtime passes a store-backed one, the editor a path-backed one, both from scene/prefab_source.zig. A scene declaring instances with no loader fails with error.PrefabLoaderRequired — dropping them silently would open a scene that looks fine and is missing half its content.
Override model
Module: src/hikari/src/scene/prefab/patch.zig (pure; testable without editor UI).
| Concept | Rule |
|---|---|
| Baseline | Prefab asset actors (local ids) |
| Current | Expanded scene actor with matching prefab.local_id |
| Override | One differing leaf, addressed by path |
| Identity | id, parent_id, prefab link are never addressable |
| Root TRS | Instance-owned placement, recorded on the instance, never an override |
Paths are derived from the struct definitions, not from a table. classify treats an optional struct as a component block (block.field), special-cases transform and components, skips identity, and calls everything else a top-level leaf — so a field added to SceneRenderDesc is overridable the day it lands. Values are JSON text encoded/decoded by std.json over the typed field, so the value format follows the schema for free too. A path that no longer resolves (the template dropped the field) warns and is skipped; it never half-applies.
Game and plugin components get the same granularity, addressed as components.<name>.<key>. The engine has no schema for those payloads and must not grow one, so the split happens over the JSON object itself — each top-level key of the payload is a leaf, merged back in on apply. A component overriding components.cube_logic.health therefore keeps following the template's max_health, exactly like render.material and render.casts_shadow. Three cases stay whole-payload because no key path can express them: the component being added, removed, or holding a non-object payload, and a key the instance dropped (JSON null is a legal value, so it cannot double as "absent").
diff.zig remains for actor-granular work — variant resolution (mergeApplyOwned), Apply, and the inspector's override queries.
Apply
- Resolve instance root from selection (member or root →
prefab.root_id). - Load current prefab authoring file from disk.
- Merge non-root-transform instance fields into template locals.
- Write asset (non-undoable file write); preserves variant
basewhen present. - Plane
refresh_prefab(or internal refresh fromapply_prefab) updates other instances of the same stem using old→new delta (undoable document txn). Source instance already holds applied values.
Editor entry: applyPrefabInstance(app, root_or_member_id).
Revert
- Load prefab asset.
- For each linked member:
mergeRevertOwned(root keeps placement). SceneDocument.replaceActorper member in one undo transaction.- Mutation sink despawn/spawn updates the live edit world.
Editor entry: revertPrefabInstance(app, id).
Refresh (live document only)
Since every load expands from the template, refresh exists purely to update the already-open document, whose actors were expanded before the template changed. Closing and reopening the scene would achieve the same thing.
Plane op refresh_prefab (asset_stem + old_utf8 + new_utf8 + optional skip_root_id), used after Prefab Save on the stacked parent scene and by Apply. Internally:
- For each open-document instance of
asset_stem(optionally skip the Apply source root):- For each member: if a field equaled the old template, copy from new; true overrides stay.
- Instance root transform always preserved.
Unpack
Clear prefab on every actor in the instance (root_id match). Data kept. Undoable via replaceActor.
Editor entry: unpackPrefabInstance(app, id).
Editor UX
All prefab mutations enter the shared plane editor/authoring/scene_mutation.zig (bodies in editor/authoring/prefab_ops.zig). Chrome and MCP never re-implement document/file logic outside that path.
Plane ops (MCP scene_edit wire names)
| Op | Args | Notes |
|---|---|---|
make_prefab | id (root), name | Write assets/prefabs/<stem>.prefab.json (non-undoable) + replace subtree with linked expand (undoable). Scene mode only. Refuses an existing stem (DuplicateName) — the write is not undoable, and sanitizeStem collapses "Crate Stack" / "crate stack" onto one file. |
place_prefab | asset, optional parent_id / position | Resolve + expand + insert tree. Scene mode only. |
apply_prefab | id (root or member) | Merge instance → asset file (non-undoable); refresh sibling instances (undoable). |
revert_prefab | id | Template → instance members (root TRS kept). Undoable. |
unpack_prefab | id | Clear prefab links on instance. Undoable. |
create_variant | base_asset, name, optional from_instance_id | Write variant with base; optional sparse overrides from instance. File only (non-undoable); refuses an existing stem like make_prefab. |
set_prefab_base | base string or null | Prefab document mode only; updates prefab_edit_base + dirty. |
Chrome
- Make Prefab… (hierarchy context): when authored and not in prefab document mode → name prompt → plane.
- Create Variant…: when actor is prefab-linked (base = link stem) or while editing a prefab document (base = open stem) → name prompt → plane → open new file.
- Place Prefab: asset browser kind
prefab→ planeplace_prefab. Disabled while editing a prefab document. - Open Prefab: asset browser double-click; hierarchy Open Prefab on a linked instance; inspector Open Prefab on the instance card; File → Close Prefab to return. Open is chrome document-stack (not a scene mutation).
- Apply / Revert / Unpack: hierarchy context menu + inspector prefab card → plane.
- Hierarchy context menu filters rows by actor (not hard-coded indices): Always Focus / Add Child / Rename / Copy / Paste / Duplicate / Delete; Make Prefab only in scene mode; Create Variant when linked or prefab doc; Open/Apply/Revert/Unpack only when
actor.prefab != null. - Hierarchy badges:
prefab= top-level instance root;nested= instance root under a different prefab instance;member= non-root linked member; layers icon for all linked. - Inspector override accents (rebuild only, selected actor): section title accent when a whole builtin/open component block differs from the resolved template; Active checkbox accent when differs; Transform section accent only for non-root members (root TRS is instance-owned).
- Prefab document: inspector Base prefab text field + Clear → plane
set_prefab_base. Shown only on the prefab's root actor (PrefabDocOpts.root_id) or on an empty selection — it is a document-level control, and offering "Clear base" beside a banner reads as a property of that banner when it re-parents the entire template. - Reparent of a linked member out of its instance root is rejected.
Open Prefab document mode
Single SceneDocument chrome with a one-deep document stack (not a parallel document system):
push— parent sceneSceneDocumentis stacked; main document is materialised from the prefab asset (local actor ids, path = authoring.prefab.json). Nested member links stay for authoring fidelity.- Hierarchy/inspector edit the prefab actors as a mini-scene (live preview reloads session from scene-shaped UTF-8 of those actors).
- Save writes prefab format via
prefab.writeUtf8(not scene packs JSON), preservingbasewhen the opened asset was a variant (or afterset_prefab_base). On success, parent stack document instances of that stem are refreshed with old→new template delta (same as Apply sibling refresh). - Close Prefab restores the stacked scene document and reloads the live world from it.
Make / Place remain disabled in prefab mode.
Two save modes. scene_document/io.zig: SaveOptions.prefab_members picks whether members fold into instances (.fold, the default, for persistence) or stay ordinary actors (.expand). The prefab document's live-preview payload takes .expand, because folding would ask the save path to resolve the very templates the document is authoring, which a prefab document has no assets root for.
Editing a variant: the base chain is visible
A variant file stores only what it changes, so on its own street_lamp_festive opens as three actors floating where a lamp should be — useless for authoring, because inheritance is exactly the thing you need to see. Opening a prefab with a base therefore resolves the whole chain, and the two consumers get different views of it:
| holds | why | |
|---|---|---|
| Document | this file's actors unmerged, plus base-only actors appended and locked | so save writes back exactly what this file owns |
| Live preview | the fully merged chain | so the viewport shows the whole lamp |
The split is load-bearing, not tidiness. resolve.mergeVariantActors merges fields: a variant actor that overrides light and inherits render comes back from the resolver carrying the base's render block. Put that in the document and the next save writes an inheritance the author never touched into the variant file — silently flattening the chain one actor at a time. So an id the variant owns is taken from the variant, verbatim; only ids it does not own come from the resolved chain, and those are locked.
documentFromPrefabAssetWithContext builds it; a chain that will not load warns and degrades to the raw file rather than refusing to open.
The chain, in the hierarchy. A variant is additive, so a flat merged list cannot answer who injects what. SceneDocument.origin_levels records the chain — furthest ancestor first, this file last, own_origin indexing this file — and actor_origin credits each actor to the deepest file that mentions it. Deepest, not first: if the base declares bulb and the variant overrides it, the variant is where you edit it, so that is where it belongs. That rule is what makes "which section is it in" and "can I edit it" the same question.
model.appendChainSections splits the prefab's layer into one hierarchy section per file, top to bottom in composition order, badged base / base 2 / … / editing. A chain file contributing nothing visible still gets a header row. prefab_document.walkChain builds the provenance by walking base a second time, because resolvePrefab returns only the merged result.
Locked actors are context: visible, selectable, inspectable, never edited, never saved. Locked is derived (origin != own_origin), not stored, so editability and the chain sections cannot drift apart.
scene_mutation.applyOnerejects any operation whoseOperation.actorId()is locked withErrorCode.read_only. It gates the subject, not the parent:create/insert/insert_treeunder a locked actor stay legal, because adding a child adds to the variant.prefabAssetFromDocumentskips locked actors and sizes its output to the owned count (prefab.freereleasesasset.actorsas a slice).- The gizmo never offers a locked actor:
selection_flow.refreshOverlayclears the target for a locked primary andsnapshotDragGroupskips locked ids in a multi-select, so a drag cannot open a transaction the plane rejects every frame. - Chrome hides destructive context-menu rows, the inspector builds
read_only(the same mode Play uses), and the hierarchy row dims and takes prefab amber on its glyph andbasebadge.
Prefab mode chrome
editor_app/prefab_chrome.zig states the mode three ways: a 2pt amber frame inside the viewport rect, a band between the viewport sub-toolbar and the 3D view naming the template and the scene it returns to (with Close Prefab), and the toolbar Save button re-labelled Save Prefab. The hue is asset_browser.kindColor(.prefab). Save changes its word only, never its variant (the accent already means "unsaved authoring"). The chrome is derived from document_kind every frame, not latched by the open/close paths, so a discarded dirty prompt, a rolled-back open, or a project close all update it, and it re-bakes after a live theme switch.
Runtime SDK
const ref = hi.PrefabRef.must("prefabs/crate_stack");
const handle = try hi.world().retainPrefab(ref);
defer hi.world().releasePrefab(handle);
const root = try hi.world().spawnPrefab(handle, .{
.position = .{ 0, 1, 0 },
.rotation_euler = .{ 0, 45, 0 },
.scale = .{ 1, 1, 1 },
.parent = .invalid,
.active = true,
});
// Member lookup uses expand mint: "{root_scene_id}__{local_id}"
const lid = hi.world().findPrefabMember(root, "lid");
hi.world().despawnPrefabInstance(root);retainPrefabloads/decodes once (refcounted cache onWorld); resolves variants; nested links stay until expand. Cooked.shinprefabfirst; authoring JSON only in Debug.spawnPrefabexpands with unique root id + nested loader, batch-friendly spawn, parents attached after members exist.- Soft content refs on leaves match scene load (error mesh /
_missingarchetype).
Bulk spawn job
World-owned, multi-frame, one job at a time. Template is re-retained for the job duration so the game may release its handle early.
const job = try hi.world().beginPrefabSpawnJob(handle, .{ .budget_ns = 2_000_000 });
try hi.world().prefabSpawnEnqueue(job, .{ .position = p0 });
try hi.world().prefabSpawnEnqueue(job, .{ .position = p1 });
// each frame:
_ = try hi.world().pumpPrefabSpawnJob(job); // true when complete
const roots = hi.world().prefabSpawnResults(job); // []ActorRef so far
// when done:
hi.world().endPrefabSpawnJob(job);- Each pump spawns full instances via existing
spawnPrefab(one expand + batch entity allocate per instance) untilbudget_nsis exhausted (0= one instance per pump). - Results accumulate until
endPrefabSpawnJob. Result storage is sized atprefabSpawnEnqueue(it can never exceed the queue), so a slice fromprefabSpawnResultsstays valid across any number of pumps — a later enqueue orendPrefabSpawnJobinvalidates it.
findPrefabMember
Runtime entities do not store ScenePrefabLink. Lookup uses the expand mint convention:
- Root: ActorRef returned by
spawnPrefab/ job results. - Child:
findPrefabMember(root, local_id)→findBySceneId("{root_scene_id}__{local_id}"). - Collision suffixes (
-N) are not searched. - Nested member locals are relative to the nested expand root; deep nested mints follow the same
{parent_root}__{local}pattern.
Cook does not emit dependency edges for base or nested prefab.asset, so re-cooking a base does not invalidate variants. Product staging (StageCookedPrefabs) ships the whole prefabs/ tree.
Sample
src/games/example/scenes/prefab_showcase.json — a street of lamps and crates
where every prop is an instance: plain instances, component and transform
overrides, a variant (street_lamp_festive), and a nested instance
(lamp_corner contains a street_lamp). Templates live in
src/games/example/assets/prefabs/. The scene file stores unexpanded
instances + overrides; the engine hydrates at load. Generator:
src/games/example/tools/gen_prefab_showcase.py. The scene README walks through
Open Prefab / Apply / Revert / Unpack / Create Variant on it.
Invariants
- One expand + resolve + diff core under
scene/prefab/— never expand/diff-only logic solely undereditor/. - Flat actor lists +
parent_idonly. - Nested expand is O(n) with maps; recursive with depth limit + asset-stem cycle detection; no per-frame work.
validateAllocis O(n) (three-colour parent walk over an index table). No per-actor hashmap, and expand validates once per level — never twice at the same depth.- Prefab writer changes are guarded by comptime field-count tripwires in
scene/prefab/asset.zig, the same contracteditor/scene_document/serialization.zigcarries. A newSceneActorDesc/ render / physics / render-params field must be emitted there or the build fails. - Plane op bodies are
!voidwith ordinaryerrdefer; only the thin public wrapper maps toplane.ErrorCode. - No per-frame prefab work; spawn / apply / revert are explicit.
- Authoring structural/field edits go through document commands (
replaceActor, mutation plane) so live sink + undo stay consistent. - Game modules see only
PrefabRef/PrefabHandle/ActorRefvia HostApi (no new SDK surface required for nested/variants). - Apply file write is intentionally non-undoable; document-side refresh/revert/unpack are undoable.