Prefabs
A prefab packages a fully configured entity — its children, components, materials, physics, animations, and scripts — into a reusable .prefab asset. Configure something once (a third-person character, a pickup, an enemy), save it as a prefab, then reuse it everywhere: drag copies into the scene, or spawn them from scripts at runtime.
File anatomy
A .prefab file is JSON:
{
"version": 1,
"name": "Enemy",
"snapshot": {
"entities": [1, 2, 3], // ids renumbered 1..N, root first
"components": [ // same columnar shape as a world snapshot
{ "component": "PrefabNode", "entries": [ /* stable per-node uuids */ ] },
{ "component": "TransformComponent", "entries": [ /* … */ ] }
// …every other serialized component
]
}
}- Every entity carries a
PrefabNode { nodeId }— a stable uuid used to match instance entities back to the file across sessions and machines. - The root's
ParentIdis alwaysnull; entity references inside the subtree (scriptEntityInput/ComponentInputfields, instanced-mesh slots) are remapped on instantiation. References that pointed outside the subtree are nulled when the prefab is created. - Scene singletons (
Environment,Postprocessing) never serialize into a prefab. Cameras and image targets cannot be turned into prefabs.
Creating and editing (editor)
- Create: drag an entity from the Hierarchy into the Asset Browser. The dragged entity automatically becomes the first linked instance.
- Edit: double-click the
.prefabtile — the editor enters Prefab Mode, showing only the prefab's contents with the full inspector. Saving writes the file; closing returns to the scene and applies the changes to every instance. - Instantiate: drag the
.prefabfrom the Asset Browser into the viewport or onto a Hierarchy row.
Instances and per-property overrides
Scene instances stay fully editable. Any property you change on an instance (a material color, a script field, a child's transform) becomes an override: when the prefab asset is edited later, overridden properties keep their per-instance values while everything else syncs from the file.
Always per-instance (never synced): the instance root's position / rotation / scale, name, and place in the scene hierarchy.
Rules of thumb:
- Components or children you add to an instance are yours — prefab updates never remove them.
- Components or children you delete from an instance come back on the next prefab apply (deletion tracking is not supported).
- "Unpack" in the inspector detaches ONE level — the objects lose their link but any nested instances inside survive as standalone instances.
Nested prefabs
Prefabs can contain other prefab instances: drag a prefab into an open prefab (Prefab Mode), or create a prefab from a subtree that contains whole instances. Node identity is scoped per placement, so a prefab may nest two copies of the same child prefab and each keeps independent overrides.
- Editing a child prefab cascades: every prefab file that nests it is patched (overrides preserved), and every scene instance updates when you close the editor — transitively through any depth of nesting.
- A prefab can never contain itself, directly or through another prefab (the editor rejects the drop).
- Only whole instances nest — a partial slice of an instance can't become a prefab.
- In the scene, content can't be re-parented across a nested instance boundary (its identity would change); open the host prefab to restructure.
- Runtime spawning is unaffected: files are fully materialized, so
spawnPrefabneeds no resolution regardless of nesting depth.
Spawning at runtime
Scripts spawn prefabs with this.spawnPrefab(source, options?). Declare an AssetInput field and drag the .prefab onto it in the editor:
import { Behaviour } from "@relu-interactives/spatial-ecs";
export default class EnemySpawner extends Behaviour {
data = {
// AssetInput — drag Enemy.prefab onto this field in the inspector
enemyPrefab: { path: "", type: "prefab" },
spawned: [] as number[],
};
private async spawnWave() {
for (let i = 0; i < 3; i++) {
const rootId = await this.spawnPrefab(this.data.enemyPrefab, {
position: { x: i * 2 - 2, y: 0, z: -6 },
});
this.data.spawned.push(rootId);
}
}
private despawnWave() {
for (const id of this.data.spawned) {
this.despawn(id); // removes the root and all of its children
}
this.data.spawned = [];
}
}Details:
spawnPrefabresolves once the entities exist. Heavy assets inside (models, textures, audio) stream in reactively — repeated spawns of the same prefab share geometry through the model cache, so waves are cheap.sourcecan be anAssetInput({ path, type }), a URL string, or a pre-loadedPrefabAsset.- Options:
{ parentId, position, rotation, scale }— the transform applies to the spawned root in its parent's local space. world.spawnPrefab(...)/world.despawnPrefab(id)expose the same calls outside of behaviours.
Preloading and caching
Each prefab URL is fetched and parsed once per session (loadPrefab caches the parsed file; failed loads retry). Published experiences preload prefab files referenced by script AssetInput fields — plus the models and textures inside them — before the experience starts, so the first spawn does not hitch.
Custom hosts
Prefab files store editor-space asset URLs. Published and preview builds register a resolver that maps them onto the deployed assets/ folder:
import { setPrefabAssetUrlResolver } from "@relu-interactives/spatial-ecs";
declare function rewriteToMyCdn(url: string): string;
setPrefabAssetUrlResolver((url) => rewriteToMyCdn(url));The standard project templates already do this — you only need it when embedding the runtime in a custom host.

