Scene Loading
A scene is a single plain .json file under a scenes/ dir (not .scene.json) that is the sole source of truth for what exists in the world. Scenes load asynchronously into an isolated staging world, then swap in atomically so no system ever observes a half-built scene.
See also: Architecture · Prefabs · Visual Editor
Two-world architecture
There is no singleton world. runtime/ecs/worldRegistry.ts owns the active koota World and exposes:
getCurrentWorld()— the active "main" world (created lazily on first call).setCurrentWorld(next)— atomically promotesnextto active and fires swap listeners.onWorldSwap(fn)— subscribe to(newWorld, oldWorld)swap events; returns an unsubscribe function.getEntityIndex(world)— per-worldMap<number, Entity>index, stored in aWeakMap<World, …>so a dropped world's index is GC'd with it.
Consumers must call getCurrentWorld() inside callbacks/functions, never capture it at module load — otherwise a swap wouldn't take effect for them. runtime/ecs/world.ts re-exports these and adds entity-index helpers (registerEntity, findEntityById, unregisterEntity, findEntityByGuid, guidOf) — the guid lookup is load-bearing for resolving a guid-form parentId / rootInstanceId on load (see "Entity-id stability on disk" below).
During a load, SceneManager builds a fresh staging world with koota's createWorld(), populates it in isolation (no system runs against it because it isn't active), then calls setCurrentWorld() to flip it in one statement. Renderers (Scene3D, Scene2D, and the useUIEntities selector) subscribe to onWorldSwap to flush their per-world caches the moment the swap happens.
koota caps total worlds at 16.
SceneManagercallsoldWorld.destroy()after each swap to free the slot; without it the engine breaks after ~16 swaps.
Resource cache with refcounting
runtime/loaders/meshTemplateCache.ts is a content cache keyed by the resolved path, with per-resource ownership tracked as Set<SceneId>. Each resource kind has typed acquire/release functions that take (sceneId, ref) where ref is a GUID (resolved to a path via the asset manifest). References are GUID-only: an internal asset path (e.g. /games/x/foo.mesh.json) is rejected by resolveRef with a loud console.error and resolves to undefined, so a stale/wrong ref fails visibly instead of silently loading. Genuinely external resources (http(s)://, data:, blob: URLs) are not manifest assets and pass through unchanged. See runtime/loaders/assetManifest.ts (resolveRef, isInternalAssetPath, isExternalUrl).
| Resource | Acquire | Owns |
|---|---|---|
.glb model | acquireModel / releaseModel | mesh templates (geometry + material) |
.mesh.json | acquireMesh / releaseMesh | metadata; transitively acquires its model + material |
.mat.json | acquireMaterial / releaseMaterial | one THREE.Material + its texture |
.prefab.json | acquirePrefab / releasePrefab | parsed prefab JSON |
| HDR environment | acquireEnvironment / releaseEnvironment | THREE.DataTexture (IBL) |
acquire* adds the sceneId to the resource's owner set (kicking off the load on first owner); release* removes it and disposes the GPU resource only when the set becomes empty. Because ownership is a set of scene ids, two scenes can SHARE the same resource — neither disposes it while the other still holds it.
Acquisition is transitive: acquiring a .mesh.json also acquires its underlying .glb and any .mat.json it references (and a model with baked LODs acquires each LOD GLB). All transitive dependencies are tracked under the same sceneId, so a single releaseAllForScene(sceneId) tears the whole graph down.
releaseAllForScene() runs after the swap so a shared resource's refcount only drops to zero once no remaining scene owns it.
Scene manifest format
The current scene file version is 12 (SceneFile.version), stamped from SCENE_FORMAT_VERSION in runtime/version.ts; the SceneFile interface is defined in editor/scene/serialize.ts:
interface SceneFile {
id: string; // stable UUID, written once, survives renames/moves
version: number; // stamped from SCENE_FORMAT_VERSION (currently 12)
createdAt: string; // preserved across saves, not regenerated — see "Entity-id
// stability on disk" below
baseScene?: string; // v10+: guid of a base scene this scene extends — see
// "Base scenes" below
resources: ResourceRef[];
entities: SerializedEntity[];
}SerializedEntity.id is now optional and, since v12, never written — see "Entity-id stability on disk" below.
A ResourceRef has { type, path, loader?, postprocessor? } where type is one of model | riggedModel | mesh | material | texture | prefab | font | environment | particle | animation. collectResourceRefsFromEntities() (runtime, in loadSceneFile.ts) walks every entity for the asset fields it references — including structural added subtrees and reference-node prefab GUIDs — and emits a sorted, deduped ref list. The editor's collectResourceRefs() (serialize.ts) delegates to it, so there is a single ref-walking implementation rather than two that can drift.
resources is a hint, not the authority: at load time SceneManager re-walks the entities (and every referenced prefab's nested entities, iteratively) so a stale manifest missing an entry — e.g. an HDR added after first serialization — still preloads everything and avoids first-view pop-in.
Migrations
Migrations chain in loadSceneFile.ts and run before any entity spawns:
migrateSceneData— v3→v4 (move text fieldsUIStyle→UIText, stripTransformfrom UI entities)migrateV4toV5— mergeUIStyle/UIText/UIContentintoUIElement, dropelementTypemigrateV5toV6— derive theresourcesarray by walking entities for older scenesmigrateV6toV7—Renderable2D.size→width+heightmigrateV7toV8— movePersistent.guid→EntityAttributes.guid;Persistentbecomes a bare marker tagmigrateV8toV9— rename renderable traits' per-rendererisActive→isVisible(splitting it from the entity on/offEntityAttributes.isActive), walking traits plus prefab override/added/nestedOverride subtreesmigrateV9toV10— no-op passthrough; adds the optional top-levelbaseSceneref (base scenes, below) — older files simply have nonemigrateV10toV11— no-op passthrough; changes HOWPrefabInstance.rootInstanceId(and any futureFieldHint.entityId-flagged field) is written — a GUID instead of a raw ecs id — not the shape of the data (see "Entity-id stability on disk")migrateV11toV12— no-op passthrough;serializeScenestops writing the per-entityidfield entirely. This is the terminal step — it stampsdata.version = SCENE_FORMAT_VERSION, so bumping the constant without chaining a new migration can't silently mislabel a freshly-migrated file as under-versioned
Entity-id stability on disk
The scene file carries no live ecs id at all, and a no-op Save All is a true no-op — getting there took the three v10–v12 migrations above.
The mental model. EntityAttributes.parentId was already a guid on disk (only a legacy pre-guid file carries a numeric one). PrefabInstance.rootInstanceId was the last numeric on-disk entity reference, and it went to disk as the entity's live ecs id — a koota allocation slot, i.e. whatever the loader happened to hand that entity this session. Once both are guids, nothing on disk references the per-entity id field, so serializeScene stops writing it.
Instead, both independent parsers of the scene-file format each backfill a synthesized id for their own single-call internal bookkeeping — the entity's array index, skipping any index already claimed by an explicit id elsewhere in the file (a genuinely mixed file — some entries id'd, some not — is unusual but not impossible, e.g. hand-edited or partially migrated):
| Parser | Backfill | Used for |
|---|---|---|
runtime/loaders/loadSceneFile.ts (assignSyntheticEntityIds) | called once right after the migration chain, before anything else reads entry.id | idMap, spawnedByEntryId, onEntitySpawned's oldId |
runtime/scene/sceneMutate.ts (assignSyntheticEntityIds + stripBackfilledEntityIds) | called before applyOps, stripped again right before writing the file back | its internal entity graph, EntityRef.id lookups, nextId()'s minting |
The shim is duplicated, not shared — sceneMutate.ts is deliberately standalone and dependency-free so it runs identically in Node and the browser. Neither synthesized id is ever persisted or compared across loads. sceneMutate.ts's copy strips its backfilled ids again before writing — otherwise a single setTrait through /api/scene-mutate would silently reintroduce an id on every entity in an id-less file, the exact diff noise this whole mechanism exists to remove, just via a different write path than Save All. (An entity addEntity genuinely adds keeps its real id — it mints one via nextId() after the backfill ran, so it was never in the backfilled set.)
The carry snapshot is untouched by any of this: it keeps setting real ecs ids, because onEntitySpawned hands SceneManager genuine old→new ecs-id pairs that the override-mark re-seed and the id remap below both depend on. The scene file and the carry snapshot are two different things that briefly wore the same SceneData type — the file's id churn existed entirely because it had borrowed the snapshot's representation.
FieldHint.entityId — the registry mechanism
A trait field that holds a live entity id declares it in the trait registry (runtime/ecs/traitRegistry.ts):
entityId?: { onMissing: 'root' | 'stripTrait' };Declared fields today (engine/app/ecs/registerTraits.ts):
| Field | onMissing | Why |
|---|---|---|
EntityAttributes.parentId | 'root' (write the schema default, silent) | An orphan is a legitimate partial-load outcome; sceneValidation already warns at author time |
PrefabInstance.rootInstanceId | 'stripTrait' + loud warn | Neither 0 nor a stale value is safe — both poison instance-membership lookups |
Driven generically off the registry, on both sides:
- Write —
serializeScenemaps everyentityId-hinted field throughguidForId(editor/scene/serialize.ts), so a future such field is guid-ified automatically — not a name-check onrootInstanceId. - Read — one registry-driven loop in
loadSceneFile.tsreplaced two hand-written remap blocks.resolveEntityIdFieldis dual-mode: a string resolves viafindEntityByGuid, a number via the legacy/carryidMap. EveryentityId-hinted field is also zeroed at spawn time — spawning a guid string into a numeric koota SoA field writesNaNbefore the remap pass can fix it.
Gotcha — a prefab-instance root's own guid lives at entry.guid (top-level), not inside its serialized EntityAttributes (serialize.ts: "Prefab roots write only their stable guid here — never as an override"). A root's rootInstanceId is a self-reference (it equals its own guid), so on load, pass 1 used to spawn the placeholder with EntityAttributes.guid still empty — nothing in the live world yet carried that guid — and pass 2's resolveEntityIdField self-lookup always missed, stripping PrefabInstance and logging "no live counterpart in this load" on every load, harmlessly (a fresh load's placeholder gets destroyed and re-instantiated correctly regardless) but noisily, and — on a base-scene carry, which spawns flat with no re-instantiation step — for real. Fixed by stamping entry.guid into the EntityAttributes trait args at spawn time (pass 1) whenever the entry carries one and doesn't already set it, so the placeholder is self-discoverable exactly like any other entity. sceneValidation.ts's field-type check needed the matching fix: it carved out EntityAttributes.parentId accepting a string (serialized) or number (live schema) but never extended that carve-out to PrefabInstance.rootInstanceId, so a valid guid-form file loudly failed the "unknown trait/field" schema check whenever the connected editor's live registry pushed the schema over the agent bridge.
This closed a real bug class: PrefabInstance.rootInstanceId going stale across a respawn because nobody remembered to add it to a hand-maintained remap list (it now lives structurally in the registry instead). The guard against a third such field regressing the same way is opt-out, not opt-in: engine/tests/editor/registerTraits.test.ts walks every registered trait's koota schema for /Id$/-shaped numeric fields and fails unless the field is declared entityId or carries an explicit allowlist entry with a reason (PrefabInstance.localId/parentLocalId — prefab-LOCAL ids, not ecs ids, are the allowlisted case).
The runtime representation did not change:
parentIdandrootInstanceIdstay numeric ecs ids in the live world. Making them guids at runtime was analysed and rejected —transformPropagationSystemrebuilds aMap<number,number>every frame, guids are lazily minted, and duplicate guids would make the hierarchy ambiguous rather than just lookups. This is a disk-form change only; translation stays at the load seam.
Why it mattered, and the regression gate
A scene saved after a base-scene carry (a level swap that keeps a shared base loaded — see "Base scenes" below) used to produce a completely different file than the same scene saved after a cold load — different ids throughout, different entity order, and a regenerated createdAt — with zero actual edits. Two further fixes closed that:
createdAtis preserved — captured from the raw parsed JSON intoSceneManager's per-sceneloadedScenesbookkeeping at load time and reused on save; a fresh stamp only when there is none.- Entity order for a carried scene now matches a cold load — the carry snapshot's respawn order used to be its subtree-descent (BFS) order;
snapshotPersistentEntitiesnow sorts its entries by ecs id, which on a cold load is file order. (Parent-before-child is not required:loadSceneFilespawns everything in pass 1 and resolvesparentIdin pass 2.)
The regression gate is engine/packages/modoki/tests/editor/scenePathIndependence.test.ts: with real sceneManager.loadScene + real serializeScene, a scene's serialized output — entities, rootInstanceIds and createdAt — must be identical whether it arrived via a cold chain load or a carried swap, for the base and the primary alike, and repeated cycles must be deterministic.
"A no-op Save All produces an empty
git diff" is a FALSE PASS and must never be used as the test on its own.saveAllonly writes dirty scenes, so a clean base is skipped entirely and the empty diff means not written. Assert the file was actually written (mtime/hash), or — better — callserializeSceneand compare the RESULT, which needs no write at all.
Base scenes (nestable, cross-scene persistence)
A scene may declare a base scene — baseScene: "<guid>" at the top level. The base loads additively into the same world, before the primary, and survives a swap to another scene that shares it. Shared rig and session state (Time, camera, lights, UI, physics config) is authored once in the base; a level file becomes only what is actually per-level.
The problem it solves is concrete: sling's Lvl-0001.json and Lvl-0002.json were byte-identical except FieldSource.level/.wave — ~38 duplicated entities per file, and no state (not even Time.elapsed) carried across the swap. Base scenes dissolve that identity problem rather than papering over it: there is exactly one Time entity, defined in one file.
A koota world can never survive a swap —
SceneManager.loadScenealways builds a freshcreateWorld()anddestroy()s the old one (koota caps at 16 worlds). So "the base survives" is implemented as snapshot-and-carry: the kept scenes' entities are serialized out of the dying world and respawned into the staging world, NOT re-read from file. Re-reading would resetTime.elapsedto its authored value, defeating the feature. This is the generalization ofsnapshotPersistentEntities(see Persistent entities below) — the two mechanisms coexist: base scene = "shared rig authored once",Persistent= "this entity survives a load".
Chain resolution
runtime/scene/sceneChain.ts (resolveSceneChain(startPath, fetchSceneMeta)) walks baseScene refs upward and returns { chain, warnings }:
- Nesting is allowed (engine-base → game-base → level).
- A
visitedSet of scene guids handles cycles and diamonds — two bases sharing a base is a normal case once nesting exists, and loading it twice is the bug; a plain depth counter doesn't cover that. - Order is root-most base FIRST, primary LAST — so a level's own entities win, and "everything not from the primary is base-origin" reads correctly in the editor.
- A depth cap (
MAX_CHAIN_DEPTH, mirroringNavigationManager.MAX_HISTORY) is a runaway backstop. - Cycles, dangling refs and the cap all WARN and DEGRADE, never throw — the walk stops and returns whatever resolved.
On each load SceneManager diffs the chains by scene guid: kept = old ∩ new (carried), toLoad = new \ kept (spawned from file, in chain order), toDrop = old \ new (torn down, resources released per scene id) — the same set-intersection the resource refcount already does. Unload is therefore declarative: loadScene(levelB) already expresses it, and there is deliberately no unloadScene() — a targeted unload would destroy entities with no notification seam for games holding module-level entity refs, and it would break the atomic two-world swap.
Provenance — EntityAttributes.sourceScene
Every entity a base scene spawns is stamped with that scene's guid in EntityAttributes.sourceScene (registered { type:'string', hidden:true, runtimeOnly:true }). It rides through the world swap for free, unlike an id-keyed side map.
LOAD-BEARING: empty
sourceScenemeans "belongs to the primary scene", not "belongs to nothing". Otherwise every entity a human creates in the editor would silently fail to save.
Two consumers:
- Save filtering —
serializeSceneexcludes any entity (and its subtree) whosesourceSceneis "foreign" to the scene being saved, mirroring the existingTransientexclusion. A level's file therefore never absorbs base rig, and a non-chained scene's save is byte-identical to pre-base-scene behaviour. - Editor grouping/ghosting —
EntityInfo.sourceScene(runtime/ecs/entityUtils.ts) drives the Hierarchy's scene groups and the ghost styling.
Editor authoring surface
- Set the ref —
editor/panels/assetViews/SceneAssetView.tsx: select a scene in Assets, set its base via anAssetRefFieldwith an inline cycle warning. It writes throughPOST /api/scene-mutate'ssetBaseSceneop (see "Scene-file mutation ops" below), not the generic whole-file asset-write path — a scene file is also what the live world serializes into, so a blind write from React state could race a Play/Stop snapshot or an agent's concurrent mutate. - Hierarchy scene groups —
editor/panels/Hierarchy.tsx(grouping helper inhierarchyFolders.ts): base scenes render as collapsed-by-default "🔗 Base" header rows above the primary content, with a dirty dot when that base has unsaved edits. A scene with no base collapses to exactly one group, so a non-chained Hierarchy renders exactly as before. - Base entities are editable IN PLACE, not read-only. The original design was "ghost — fields disabled, edit by opening the base scene"; the owner reversed it, so ghosting survives as a visual provenance marker plus an explicit Lock/Unlock affordance in the Inspector. Edits are staged in the live world and routed to the base's own file on save:
saveAllwalksgetLoadedScenes()and writes every dirty scene in the chain viaserializeScene({ scene }). Cmd+S silently writes the base too — owner-confirmed, with the dirty dot + per-file reporting as the non-optional visibility half. - Promote / demote — drag a Hierarchy row across a scene-group boundary to move which scene FILE authors an entity:
moveEntityToScene/promoteEntityToScene/demoteEntityToScene(editor/undo/entityActions.ts) re-stamp the whole subtree'ssourceSceneas one staged undo action (files change on the next save, not on the drop).editor/scene/sceneMoveScan.tsis the advisory pre-flight: it scans sibling scenes for guid collisions and names every scene a demote would strip the entity from. Guids are preserved, never rekeyed on a move —entityReftrait fields address entities by guid across the chain, so a rekey would silently break live refs. A demote that removes shared rig from sibling levels is allowed with a confirm, not refused — that blast radius is the understood semantic.
Two guards that keep this safe
- No cross-scene parenting. A level entity parented under a base entity breaks save provenance (filtering keys off an entity's OWN
sourceScene, not its parent's) and teardown.reparentEntityhard-rejects it — the one interactive path bothmodoki_reparent_entityand the Hierarchy drag go through — andSceneManagerwarns at load time after the staging world is fully populated. Promote/demote changessourceScene, so it satisfies the guard rather than relaxing it. - Duplicate guids across the chain.
filterDuplicateChainGuids(SceneManager.ts) drops a root (and its subtree) whose guid a chain scene already spawned, warning loudly. Chain order means the first scene to spawn a guid keeps it. This is a transitional safety net for bases extracted by copy-and-thin (sling's two levels shared all 38 guids), not a statement about precedence.
Gotchas
- A carried prefab instance loses its EDITOR bookkeeping (Apply-to-Prefab, structural overrides) across a swap that keeps its base loaded — the carry flattens the instance structure and never calls
instantiatePrefabIntoWorld. Documented, accepted; the runtime trait data is unaffected, andSceneManagerwarns so it is never silent — but only at the moment a carry actually happens (a base already known to contain a prefab instance shows up in that load'skeptBaseGuids), not on every fresh load. A base with a prefab instance loading for the first time, or reloading fresh (not carried), is silent — the loss only occurs on the carry itself. (Authored override values on carried instances DO survive — the mark set is captured off the old world and re-seeded per entity through the old→new id map.) - The Time/Input singleton fallback must run AFTER the carry respawn. A level whose Time lives in its base has no Time of its own, so a fallback running first spawns a phantom fresh Time and the carried one lands on top of it — two Time entities, which is a live bug (
getTimeisqueryFirstwhiletimeSystemisquery().updateEach, so every read gets an arbitrary winner andsetJournalTickfires twice a frame). - Override marks are WORLD-scoped, not per-
loadSceneFile-call. A chain loads N scene files into ONE staging world, so a per-callclearAllOverrideMarks()has the primary wipe the marks the base just seeded — on every chain load, carry or not.loadSceneFiletakesclearMarks(defaulttrue, so every other caller is unchanged);SceneManagerclears once per staging world and passesfalsefor its chain and carry calls. - Editing a base file on disk while a level is open does not hot-reload by guid alone (a base's guid doesn't change when its file does).
agentBridgematches the changed path against everygetLoadedScenes()entry and reloads vialoadScene(current, { forceReloadBases: [changedGuid] }), which forces that base out ofkeptinto toDrop+toLoad so it re-fetches instead of being carried. - Play → Stop restores authored base state by guid, skipping
runtimeOnlyfields — soTime.elapsedkeeps its carried value while a driftedTransformreverts. Without this, Play → Stop → Cmd+S would bake play-mode drift into shared rig. This is also why dirt is recorded from authored edits (editor/scene/sceneDirty.ts) and never inferred by diffing the world against the file.
Key files: runtime/scene/sceneChain.ts (resolveSceneChain) · runtime/scene/SceneManager.ts (chain load, carry, loadedScenes) · editor/scene/serialize.ts (serializeScene({ scene }), multi-scene saveAll) · editor/scene/sceneDirty.ts · editor/panels/assetViews/SceneAssetView.tsx · editor/panels/Hierarchy.tsx · editor/scene/sceneMoveScan.ts.
SceneManager API
runtime/scene/SceneManager.ts exposes the singleton sceneManager. The core call is:
await sceneManager.loadScene(path, {
onProgress?: (loaded, total) => void,
signal?: AbortSignal,
preloaded?: SceneData, // caller-supplied data instead of a fetch
gameId?: string, // explicit game switch (see managers-and-systems.md)
forceReloadBases?: string[], // guids to pull OUT of `kept` even though they'd
// otherwise carry — the base-file hot-reload primitive
});loadScene flow — a scene may declare baseScene, so this is a chain load, not a single-scene one (see Base scenes):
- Cancel in-flight load — aborts the previous preload and releases its acquired resources (cancel-and-replace; only one preload runs at a time).
- Resolve the chain (
resolveSceneChain) and diff it against the currently loaded chain:kept(carried),toLoad(spawned from file),toDrop(torn down). - Allocate a fresh
SceneIdpertoLoadentry + oneAbortController. - Fetch + migrate each
toLoadscene's JSON. - Acquire all resources in parallel (
Promise.all) for everytoLoadscene, iteratively expanding nested prefab resources first. - Carry — snapshot every kept-base and
Persistent-tagged entity out of the dying world (snapshotPersistentEntities), THEN spawn everytoLoadscene (root-most base first, primary last) plus the carried snapshot into the staging world vialoadSceneFile(dormant — no active system touches them). The Time/Input singleton fallback runs last, after the carry — see the base-scenes gotchas. beforeSwapHooksrun (registerBeforeSwap) — e.g. renderer shader pre-warm viacompileAsyncto kill the first-frame stutter. Failures are logged and swallowed.- Rebuild
loadedScenes, THEN atomic swap —setCurrentWorld(staging)firesonWorldSwap, which editor panels readloadedScenesfrom, so the rebuild must happen first or a base's Hierarchy label falls back to its raw guid. ThenreleaseAllForScene(id)for everytoDropscene id drops its refcounts; thenoldWorld.destroy()frees the koota slot. - Scene callbacks (
registerSceneCallback) fire for dynamic spawning.
On failure or abort, the staging world is destroyed and its resources released — the current scene (and its whole chain) is left completely untouched.
Reads: getCurrent() returns the primary (unchanged signature — NavigationManager's back-stack depends on it). getLoadedScenes() returns Map<SceneId, { path, guid, role: 'primary' | 'base', baseScene?, createdAt? }> — every scene currently in the chain, primary included. Mutation is exactly one entry point: loadScene(path) = "make this primary, resolve its chain, diff". There is deliberately no unloadScene() — see Base scenes.
The editor wrapper loadScene() in editor/scene/serialize.ts delegates to sceneManager.loadScene, then tracks the scene path and swaps to this scene's own per-scene undo history (swapHistory(scenePath) — empty on first visit, restored when you return to a previously-open scene), rather than dropping undo globally. unloadAll() and resetForTesting() exist for shutdown + deterministic tests.
Persistent entities
runtime/traits/Persistent.ts is a marker trait (no fields). It tells SceneManager to carry a root entity across a scene swap. Use markPersistent(entity, guid?):
- Assigns a UUID to
EntityAttributes.guidif the entity lacks one (explicitguidarg wins; returns the final guid). - Enforces root-only: throws if
parentId !== 0or the entity has noEntityAttributes. Children come along with their root automatically.
Because koota entity handles encode their owning world, a persistent entity cannot be moved between worlds — it is serialized and respawned into the staging world. SceneManager:
- Snapshots persistent root subtrees from the current world (
snapshotPersistentEntities) — this is also the mechanism base scenes generalize: the same function additionally snapshots any root whosesourceSceneis a KEPT base guid, so one snapshot call coversPersistententities and a carried base's entities together. Entries come back sorted by ecs id (not snapshot/insertion order) so a carried scene's later save matches what a cold load would have produced. - Acquires the resources those snapshots reference under the new
sceneId, so they survive the post-swap release even if the new scene doesn't list them. - Drops any scene-file root whose
EntityAttributes.guidmatches a persistent guid (filterPersistentDuplicates) — the live persistent entity shadows the file copy, preventing duplicates. - Respawns the snapshots into the staging world (tagged
version: SCENE_FORMAT_VERSION, currently 12, so migrations don't needlessly re-run).
Each snapshotted field is the union of the trait's koota .schema keys and its registered meta.fields keys (not meta.fields alone, which is a curated Inspector subset) — otherwise a field absent from the Inspector's curated set (e.g. Time.timeScale) would silently reset to its schema default across every swap.
Persistent entities must be ECS-pure — trait data only. Anything held in a closure, an in-flight tween, or a Web Audio node is lost on swap, since that state isn't in traits. Keep side-effecting singletons in services keyed by trait data.
Scene validation (warn-but-load)
runtime/scene/sceneValidation.ts (validateSceneData(data, schema?)) is a pure, dependency-light validator — it imports only the predicate helpers from runtime/loaders/assetRefRules.ts (which have zero imports), so it runs unchanged in the browser AND in Node (the dev server). It never throws and never blocks: it returns { warnings: string[], schemaApplied: boolean } and the loader always continues. The design is deliberately forgiving — a single typo surfaces a precise per-field message instead of blanking the whole view.
Three consumers push findings through different channels:
- The hot-reload handler (
app/debug/agentBridge.ts) validates the freshly fetched scene againstbuildSceneSchema()before handing it toloadScene, andconsole.warns each finding (prefixed[agentBridge]). GET /api/validate-scene?path=returns the findings plusschemaApplied/schemaAvailablein the HTTP response.POST /api/scene-mutateappends a post-apply validation pass to the op warnings (see the next section).
The two dev-server endpoints are surfaced as MCP tools — see Debug Tools (MCP) for the curl/tool surface rather than duplicating it here.
The trait schema is optional. Structural + asset-reference checks always run; trait/field type checks only run when a schema is supplied (schemaApplied reflects this). The schema is the live koota trait registry the renderer pushes over the HMR socket (R→M buildSceneSchema()), so a headless Node call with no browser connected still catches the common mistakes but skips type checks (schemaAvailable:false). A TraitSchema is { category: 'component'|'resource'|'tag', fields: Record<name, { type?, options? }> }; a field whose type is omitted is known (won't be flagged as unknown) but is not type-checked — used for object/array fields the registry can't confidently type.
Findings come from three passes:
- Schema-dependent trait/field checks — unknown trait, unknown field, type mismatch (
number/string/boolean/color/enum/entityRef/bindings/materialOverrides), and enum value not inoptions. Tag traits must serialize astrue; component/resource as a field object. Thebindingstype deep-checksUIActionshape (event∈ click/change/submit,kind∈ set/call, required sub-fields per kind). - Asset-reference rule (schema-independent) — every field in
REF_FIELDS_BY_TRAIT(e.g.Renderable3D.mesh/.material,ModelSource.glbPath,Environment.hdrPath,ParticleEmitter.effect) must be a GUID or an external URL. An internal asset path (/games/x/foo.mesh.json) gets the specific "references must be a GUID (use the asset's id / .meta.json sidecar)" message; anything else gets "is not a GUID or URL". The primitive sprite keywordscircle/square/triangleare exempt onRenderable2D.sprite. - Structural / referential-integrity pass (schema-independent) — duplicate entity ids, self- or dangling
parentId(matched as a GUID or a legacy numeric file id;''/0= root), danglingUIAction.bindings[].targetentity refs, and aPrefabInstancewhosesourceis its own guid (self-recursion).
REF_FIELDS_BY_TRAIT is the single source of truth for scalar ref fields — editor/scene/serialize.ts imports it for its save-time guard and the build tree-shaker's keep-walk (plugins/asset-tree-shaker.ts) walks it, so a new ref field added there is covered everywhere. Non-scalar refs (UIElement.fontFamily = a CSS family name; AnimationLibrary.animSets = a guid array) are intentionally excluded and handled explicitly. The predicates themselves live in runtime/loaders/assetRefRules.ts: isGuid (UUID-v4 shape), isExternalUrl (http(s):/data:/blob:), isInternalAssetPath (leading / + a managed asset extension).
Scene-file mutation ops
runtime/scene/sceneMutate.ts (applyOps(scene, ops, mint?)) is the validated, pure way to edit the on-disk scene JSON — an agent (or tooling) mutates through typed ops instead of hand-editing raw JSON, then the dev-server watcher + hot-reload reflect the change. GUID minting is injected (mint, defaults to newGuid) so it is side-effect-free and unit-tests without a live world; it runs identically in Node and the browser. It mutates scene in place and also returns it inside ApplyResult { scene, changed, errors, warnings }.
Five ops. The first four resolve an existing entity by EntityRef (id | name | guid, at least one; an ambiguous name match is an error — disambiguate with id/guid). EntityRef.id and addEntity's minted id are the module's OWN internal numeric addressing, synthesized on parse for a file that carries none (see "Entity-id stability on disk" above) — they never round-trip to disk as-is.
setTrait— mergesfieldsinto the trait (spread over any existing data); nofields= tag presence. Re-tagging or a no-op merge does not count aschanged.removeTrait— refuses the core traitsTransform/EntityAttributes; removing an absent trait is a silent no-op, not an error.addEntity— allocates the next free numeric id (real, not synthesized — it persists) and ensuresEntityAttributescarries a stableguid+name+parentIdso the entity round-trips through load/save + selection-restore.removeEntity— deletes the entity plus its whole subtree (children found byparentId, GUID or legacy numeric).setBaseScene— sets or clears a scene's top-levelbaseSceneref (see Base scenes); whatSceneAssetView's Inspector field writes through.
errors are hard (entity not found, malformed op) — those ops are skipped; the caller decides whether to still write (the /api/scene-mutate endpoint only persists when changed > 0, so a typo leaves the file untouched). warnings are soft — the op applied but the result is suspect: addEntity under a non-existent parent (orphan), or a surviving UIAction.target left dangling by a removeEntity. Neither blocks the write; the agent reads them to self-correct.
Prefab-instance roots are special. A setTrait/removeTrait on a prefab-instance root routes into overrides[rootLocalId], not the top-level traits map — the loader takes an instance's traits from the prefab and silently ignores top-level trait edits on the node. (This was the bug where setTrait Transform on an instance applied scale but not position.) The traitWriteContainer helper creates the override bucket on demand.
The /api/scene-mutate endpoint (dev server, MCP-wrapped) runs applyOps then a post-apply validateSceneData pass and returns both sets of warnings; it also refuses while the editor is Playing/Paused (a Stop reverts to the Play-press snapshot and would discard the edit). It does not echo the scene back by default (returnScene:true opts in) — to verify an edit, read the live world via /api/scene-state. Full endpoint/tool surface: Debug Tools (MCP).