Scenes
Master scenes: collections of entities with hierarchies, serialization, and asset references. Learn scene loading, multi-scene editing, transitions, and management.
Scenes are collections of entities that represent a level, area, or state in your game. They contain entity hierarchies, component data, prefab instances, and asset references. Scenes can be saved, loaded, and composed together for multi-scene workflows.
What is a Scene?#
A scene is the fundamental organizational unit in Web Engine. It defines the entities, their components, hierarchies, and relationships that make up a playable environment or menu.
// Runtime scene structureinterface SceneData { id: string; // Unique scene identifier name: string; // Display name entities: SceneEntity[];} // Scene entity with metadatainterface SceneEntity { id: string; // UUID eid?: number; // Runtime ECS entity ID name: string; // Display name type: EntityType; // Entity category object3D: THREE.Object3D; parentId?: string; // Parent UUID for hierarchy components: Map<string, unknown>; prefab?: PrefabInstanceBinding; hidden?: boolean; // Editor visibility locked?: boolean; // Editor lock state}Hierarchical
Entities organized in parent-child hierarchies with transform inheritance.
Serializable
Save and load scenes with full component data, prefab instances, and asset references.
Composable
Load multiple scenes simultaneously for open-world or additive level streaming.
Versionable
Scene format versioning with automatic migration for backwards compatibility.
Scene Structure#
Scenes organize entities in a hierarchical tree structure. Root entities have no parent, while child entities inherit transforms from their parents.
// Example scene hierarchyScene: "Main Level"├── Environment (root)│ ├── Terrain│ ├── Sky│ └── Lighting│ ├── Sun (DirectionalLight)│ └── Ambient├── Player (root)│ ├── Camera│ ├── Weapon│ └── PlayerModel│ ├── Head│ ├── Body│ └── Legs└── Props (root) ├── Crate_001 (prefab instance) ├── Crate_002 (prefab instance) └── Barrel_001 (prefab instance) // Root entities: Environment, Player, Props// Child entities: Everything elseThis hierarchy enables logical grouping, transform inheritance, and efficient culling/batch processing.
Scene Serialization#
Web Engine serializes scenes to JSON or binary format. The serialized format includes entity data, component values, hierarchies, and prefab instance bindings.
Serialized Scene Format#
interface ECSSerializedScene { version: number; // Schema version for migration metadata: { name: string; createdAt: number; modifiedAt: number; }; entities: ECSSerializedEntity[];} interface ECSSerializedEntity { uuid: string; // Persistent entity ID name: string; parentUuid?: string; // Parent relationship siblingIndex?: number; components: Record<string, SerializedComponentData>; prefab?: PrefabInstanceBinding;} // Example serialized entity{ "uuid": "entity-123", "name": "Player", "components": { "Transform": { "position": [0, 1, 0], "rotation": [0, 0, 0], "scale": [1, 1, 1] }, "RigidBody": { "mass": 70.0, "friction": 0.5, "type": 0 }, "Health": { "current": 100, "max": 100 } }}Saving Scenes#
import { SceneSerializer, SerializationFormat } from '@web-engine/core'; // Serialize scene to JSONconst jsonData = SceneSerializer.serialize(world, { format: SerializationFormat.JSON, prettyPrint: true,}); // Save to fileawait fs.writeFile('scene.json', JSON.stringify(jsonData, null, 2)); // Serialize to binary (more compact)const binaryData = SceneSerializer.serialize(world, { format: SerializationFormat.BINARY, compression: true,}); // Save binaryawait fs.writeFile('scene.bin', binaryData);Prefab Efficiency
Entities that are prefab instances only serialize their overrides, not full component data. This dramatically reduces scene file size for scenes with many prefab instances.
Scene Loading and Unloading#
Loading a scene creates entities in the ECS world and hydrates them with component data. Unloading removes all entities and cleans up resources.
Loading Scenes#
import { SceneLoader } from '@web-engine/core'; // Load scene from JSONconst sceneData = await fetch('/assets/scenes/level1.json').then(r => r.json()); // Hydrate scene into worldconst entityMap = SceneLoader.hydrateScene(world, sceneData.entities); // entityMap: UUID -> entity ID mappingconsole.log(`Loaded ${entityMap.size} entities`); entityMap.forEach((eid, uuid) => { console.log(`Entity ${uuid} -> ECS ID ${eid}`);});Loading with Error Reporting#
// Load with detailed hydration reportconst result = SceneLoader.hydrateSceneWithReport(world, sceneData.entities); console.log(`Successfully loaded: ${result.successCount} entities`);console.log(`Failed to load: ${result.errorCount} entities`); // Check for issuesif (result.errors.length > 0) { console.error('Hydration errors:'); result.errors.forEach((error) => { console.error(` ${error.entityUuid}: ${error.message}`); });} // Check warnings (non-fatal)if (result.warnings.length > 0) { console.warn('Hydration warnings:'); result.warnings.forEach((warning) => { console.warn(` ${warning.entityUuid}: ${warning.message}`); });} // Unresolved references (e.g., missing prefabs)if (result.unresolvedReferences.length > 0) { console.warn('Unresolved asset references:'); result.unresolvedReferences.forEach((ref) => { console.warn(` Entity ${ref.entityUuid} -> Asset ${ref.assetId}`); });}Unloading Scenes#
// Unload all entities from a scenefunction unloadScene(entityMap: Map<string, number>) { entityMap.forEach((eid, uuid) => { // Remove entity from world removeEntity(world, eid); // Clean up metadata clearEntityMetadata(eid); }); console.log(`Unloaded ${entityMap.size} entities`);} // Or use SceneManagerSceneManager.unloadScene('level1-id');Resource Cleanup
Always clean up scene resources (textures, geometries, audio) when unloading. Use AssetRegistry.releaseAsset() to decrement reference counts and free unused assets.
Multi-Scene Editing#
Web Engine supports loading multiple scenes simultaneously. This enables additive scene composition, level streaming, and collaborative editing workflows.
Additive Scene Loading#
// Load main sceneconst mainScene = await loadScene('main-level'); // Additively load lighting sceneconst lightingScene = await loadScene('lighting-setup', { additive: true, // Don't clear existing entities}); // Additively load props sceneconst propsScene = await loadScene('level-props', { additive: true,}); // All three scenes are now active in the same world// Total entities = main + lighting + propsLevel Streaming#
// Level streaming systemclass LevelStreamingSystem { private loadedChunks = new Map<string, Map<string, number>>(); async streamChunk(chunkId: string, playerPosition: vec3) { const distance = calculateDistance(playerPosition, chunkId); if (distance < LOAD_DISTANCE && !this.loadedChunks.has(chunkId)) { // Load chunk const chunkData = await fetch(`/chunks/${chunkId}.json`).then(r => r.json()); const entityMap = SceneLoader.hydrateScene(world, chunkData.entities); this.loadedChunks.set(chunkId, entityMap); console.log(`Loaded chunk: ${chunkId}`); } else if (distance > UNLOAD_DISTANCE && this.loadedChunks.has(chunkId)) { // Unload chunk const entityMap = this.loadedChunks.get(chunkId)!; entityMap.forEach((eid) => { removeEntity(world, eid); }); this.loadedChunks.delete(chunkId); console.log(`Unloaded chunk: ${chunkId}`); } }} // Usage in systemfunction LevelStreamingSystem(world: IWorld) { const streaming = new LevelStreamingSystem(); const playerPos = getPlayerPosition(world); CHUNK_IDS.forEach((chunkId) => { streaming.streamChunk(chunkId, playerPos); }); return world;}Scene Transitions#
Implement smooth transitions between scenes with loading screens, fades, and progressive loading.
// Scene transition managerclass SceneTransitionManager { async transition(fromScene: string, toScene: string) { // 1. Fade out await this.fadeOut(); // 2. Show loading screen this.showLoadingScreen(); // 3. Unload current scene SceneManager.unloadScene(fromScene); // 4. Load new scene const sceneData = await fetch(`/scenes/${toScene}.json`).then(r => r.json()); // 5. Progressive hydration with progress updates await this.loadSceneProgressively(sceneData, (progress) => { this.updateLoadingProgress(progress); }); // 6. Hide loading screen this.hideLoadingScreen(); // 7. Fade in await this.fadeIn(); } async loadSceneProgressively( sceneData: any, onProgress: (progress: number) => void ) { const entities = sceneData.entities; const batchSize = 50; for (let i = 0; i < entities.length; i += batchSize) { const batch = entities.slice(i, i + batchSize); // Load batch SceneLoader.hydrateScene(world, batch); // Update progress const progress = (i + batch.length) / entities.length; onProgress(progress); // Yield to prevent blocking await new Promise(resolve => setTimeout(resolve, 0)); } }} // Usageconst transitionManager = new SceneTransitionManager();await transitionManager.transition('main-menu', 'level-1');Scene Asset References#
Scenes reference external assets like models, textures, audio, and prefabs. The asset system tracks dependencies and ensures assets are loaded before use.
Asset Dependencies#
// Scene asset manifestinterface SceneAssetManifest { prefabs: string[]; // Prefab UUIDs models: string[]; // Model asset IDs textures: string[]; // Texture asset IDs materials: string[]; // Material asset IDs audio: string[]; // Audio asset IDs} // Extract dependencies from scenefunction extractSceneAssets(sceneData: ECSSerializedScene): SceneAssetManifest { const manifest: SceneAssetManifest = { prefabs: [], models: [], textures: [], materials: [], audio: [], }; sceneData.entities.forEach((entity) => { // Collect prefab references if (entity.prefab) { manifest.prefabs.push(entity.prefab.prefabId); } // Collect component asset references Object.values(entity.components).forEach((componentData: any) => { if (componentData.modelAssetId) { manifest.models.push(componentData.modelAssetId); } if (componentData.textureAssetId) { manifest.textures.push(componentData.textureAssetId); } // ... etc }); }); return manifest;}Preloading Assets#
// Preload all scene assets before hydrationasync function loadSceneWithAssets(sceneId: string) { // 1. Load scene JSON const sceneData = await fetch(`/scenes/${sceneId}.json`).then(r => r.json()); // 2. Extract asset dependencies const manifest = extractSceneAssets(sceneData); // 3. Preload all assets in parallel await Promise.all([ ...manifest.prefabs.map(id => AssetRegistry.loadPrefab(id)), ...manifest.models.map(id => AssetRegistry.loadModel(id)), ...manifest.textures.map(id => AssetRegistry.loadTexture(id)), ...manifest.materials.map(id => AssetRegistry.loadMaterial(id)), ...manifest.audio.map(id => AssetRegistry.loadAudio(id)), ]); // 4. Hydrate scene (all assets are now loaded) const entityMap = SceneLoader.hydrateScene(world, sceneData.entities); return entityMap;}Asset Reference Counting
The AssetRegistry uses reference counting. When a scene is loaded, asset references increment. When unloaded, they decrement. Assets with zero references are automatically released from memory.
Scene Versioning and Migration#
Web Engine supports scene format versioning. When the engine schema changes, migration functions automatically upgrade old scenes to the current format.
// Scene version history// v0: Initial format// v1: Added quaternion to Transform// v2: Split Mesh into Geometry + Material components// v3: Added prefab instance bindings // Migration functionfunction migrateV2toV3(sceneData: ECSSerializedScene): ECSSerializedScene { if (sceneData.version !== 2) { throw new Error('Expected version 2'); } // Transform entities sceneData.entities.forEach((entity) => { // If entity has a prefab marker (old format) if (entity.components.PrefabMarker) { const marker = entity.components.PrefabMarker as any; // Convert to new prefab binding format entity.prefab = { prefabId: marker.prefabId, nodePath: marker.nodePath || '0', overrides: marker.overrides, }; // Remove old component delete entity.components.PrefabMarker; } }); // Update version sceneData.version = 3; return sceneData;} // Register migrationMigrationRegistry.register(2, migrateV2toV3); // Migrations run automatically on loadconst sceneData = await loadScene('old-scene.json');// Automatically migrated from v2 -> v3Scene Management API#
The SceneManager provides high-level APIs for scene lifecycle management.
import { SceneManager } from '@web-engine/core'; // Create new empty sceneconst sceneId = SceneManager.createScene('New Level'); // Set as active sceneSceneManager.setActiveScene(sceneId); // Get active sceneconst activeScene = SceneManager.getActiveScene();console.log(`Active scene: ${activeScene.name}`); // List all loaded scenesconst loadedScenes = SceneManager.getLoadedScenes();loadedScenes.forEach((scene) => { console.log(`- ${scene.name} (${scene.entities.length} entities)`);}); // Save sceneawait SceneManager.saveScene(sceneId, '/scenes/my-level.json'); // Load scene (replaces current)await SceneManager.loadScene('/scenes/main-menu.json'); // Load scene additivelyawait SceneManager.loadSceneAdditive('/scenes/lighting.json'); // Unload specific sceneSceneManager.unloadScene(sceneId); // Unload all scenesSceneManager.unloadAllScenes();Best Practices#
- Organize by purpose — Separate lighting, props, and gameplay into different scenes for modularity
- Use prefabs extensively — Leverage prefabs to reduce scene file size and improve consistency
- Preload assets — Load all scene assets before hydration to avoid runtime loading hitches
- Version scenes — Always include version numbers and register migration functions
- Test migrations — Validate that old scenes load correctly after schema changes
- Limit entity count — Keep scenes under 10,000 entities for optimal editor and runtime performance
- Stream large worlds — Use additive scene loading and chunk streaming for open-world games
- Clean up on unload — Always release assets and clear metadata when unloading scenes
Common Patterns#
Persistent Scene Pattern#
// Keep a persistent scene loaded across level transitionsasync function loadLevelWithPersistence(levelId: string) { // Unload current level (but not persistent scene) SceneManager.unloadScene(currentLevelId); // Load new level additively await SceneManager.loadSceneAdditive(`/scenes/${levelId}.json`); // Persistent scene (UI, player, managers) stays loaded console.log('Persistent entities preserved');} // Initial setupawait SceneManager.loadScene('/scenes/persistent.json');await loadLevelWithPersistence('level-1');Scene Checkpoints#
// Save scene state as checkpointfunction saveCheckpoint() { const sceneData = SceneSerializer.serialize(world, { format: SerializationFormat.JSON, }); // Store in local storage or save file localStorage.setItem('checkpoint', JSON.stringify(sceneData)); console.log('Checkpoint saved');} // Restore from checkpointasync function loadCheckpoint() { const checkpointData = localStorage.getItem('checkpoint'); if (checkpointData) { const sceneData = JSON.parse(checkpointData); // Clear current scene SceneManager.unloadAllScenes(); // Load checkpoint SceneLoader.hydrateScene(world, sceneData.entities); console.log('Checkpoint restored'); }}Scene Validation#
// Validate scene before loadingfunction validateScene(sceneData: ECSSerializedScene): boolean { const errors: string[] = []; // Check version if (!sceneData.version || sceneData.version > CURRENT_SCENE_VERSION) { errors.push('Invalid or unsupported scene version'); } // Check entity count if (sceneData.entities.length > MAX_ENTITIES) { errors.push(`Too many entities: ${sceneData.entities.length}`); } // Check for circular hierarchies const visited = new Set<string>(); sceneData.entities.forEach((entity) => { if (hasCircularHierarchy(entity, sceneData.entities, visited)) { errors.push(`Circular hierarchy detected: ${entity.uuid}`); } }); // Check asset references sceneData.entities.forEach((entity) => { if (entity.prefab && !AssetRegistry.hasPrefab(entity.prefab.prefabId)) { errors.push(`Missing prefab: ${entity.prefab.prefabId}`); } }); if (errors.length > 0) { console.error('Scene validation failed:'); errors.forEach(err => console.error(` - ${err}`)); return false; } return true;}