Scenes
The scene system (@web-engine-dev/scene) provides a function-based API for saving and loading ECS world state to and from JSON. Paired with the prefab system (@web-engine-dev/prefab), it enables reusable entity templates, hierarchical scenes, and VCS-friendly serialization.
Scene Data Format
Scenes use a single, canonical JSON format designed for version control diffing:
{
"version": 1,
"name": "Main Level",
"entities": [
{
"persistentId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Player",
"components": {
"transform": { "px": 0, "py": 1, "pz": 0, "rx": 0, "ry": 0, "rz": 0, "rw": 1, "sx": 1, "sy": 1, "sz": 1 },
"Health": { "current": 100, "max": 100 }
}
},
{
"persistentId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Sword",
"parent": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"components": {
"transform": { "px": 0.5, "py": 0, "pz": 0, "rx": 0, "ry": 0, "rz": 0, "rw": 1, "sx": 1, "sy": 1, "sz": 1 }
}
}
],
"editorMetadata": {
"editorCamera": { "position": [0, 5, 10] },
"selectedEntities": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]
}
}Key design decisions:
- Persistent UUIDs -- Every entity has a
persistentIdthat survives save/load cycles and enables VCS-friendly diffing - Flat entity list with parent references -- Children reference parents by
persistentId, avoiding deeply nested JSON - Component data by type name -- Keyed by the name used in
defineComponent(), enabling automatic mapping through the component registry - Editor metadata -- Optional section stripped in production builds
Loading Scenes
Load a scene JSON into an ECS world:
import { loadScene } from '@web-engine-dev/scene';
import { createWorld } from '@web-engine-dev/ecs';
const world = createWorld();
const sceneData = JSON.parse(sceneJson);
const result = loadScene(world, sceneData);
console.log(`Loaded ${result.entityCount} entities`);
// Access the entity map (persistentId -> Entity)
const playerEntity = result.entityMap.get('a1b2c3d4-e5f6-7890-abcd-ef1234567890');The loader:
- Validates the format version
- Checks for duplicate persistent IDs and circular parent references
- Spawns entities and assigns persistent IDs
- Sets up parent-child hierarchy via
world.setParent() - Maps component data through the
ComponentRegistryfor automatic deserialization - Emits an
OnSceneLoadedevent
Component Registry
The scene system uses a global ComponentRegistry to map serialized component names to ECS component types. Register your components before loading:
import { ComponentRegistry } from '@web-engine-dev/scene';
ComponentRegistry.global.register('Health', Health, {
// Optional: custom mapper for complex deserialization
mapper: (entity, world, data) => {
world.insert(entity, Health, data as { current: number; max: number });
},
// Optional: custom serializer for saving
serializer: (entity, world) => {
const h = world.get(entity, Health);
return h ? { current: h.current, max: h.max } : undefined;
},
});If no custom mapper is provided, loadScene uses direct insertion: world.insert(entity, componentType, data).
Saving Scenes
Save the current world state back to the scene format:
import { saveScene } from '@web-engine-dev/scene';
const sceneData = saveScene(world, {
name: 'Main Level',
editorMetadata: {
editorCamera: { position: [0, 5, 10] },
},
});
// Serialize to JSON
const json = JSON.stringify(sceneData, null, 2);The saver:
- Queries all entities with the
SceneEntitytag - Skips entities with the
Volatiletag (runtime-only entities that should not be saved) - Assigns persistent IDs to any entity that doesn't have one
- Serializes in hierarchy-traversal order (parents before children) to preserve sibling order
- Iterates all registered components on each entity and serializes them
- Detects circular references in the hierarchy and throws
Serialization Formats
import { serializeScene } from '@web-engine-dev/scene';
// Development: pretty-printed, preserves editor metadata
const devJson = serializeScene(sceneData, { format: 'development' });
// Production: compact, strips editor metadata
const prodJson = serializeScene(sceneData, { format: 'production' });Filtering Entities
Save a subset of entities:
const sceneData = saveScene(world, {
name: 'Player Only',
filter: (entity) => world.has(entity, IsPlayer),
});Unloading Scenes
Remove all scene entities from the world:
import { unloadScene } from '@web-engine-dev/scene';
unloadScene(world);
// All entities with the SceneEntity tag are despawned
// Persistent ID index and entity names are cleared
// OnSceneUnloaded event is emittedEntity Identity
Every scene entity has a persistent UUID that survives save/load cycles. Use these for stable references:
import {
assignPersistentId,
getPersistentId,
findEntityByPersistentId,
getEntityName,
setEntityName,
} from '@web-engine-dev/scene';
// Assign an ID to a new entity
const id = assignPersistentId(world, entity);
// Look up an entity by its persistent ID
const entity = findEntityByPersistentId(world, 'a1b2c3d4-...');
// Entity display names (editor-only, stored in SceneState resource)
setEntityName(world, entity, 'Main Camera');
const name = getEntityName(world, entity); // 'Main Camera'Scene State Resource
All scene-level state is consolidated in a single ECS resource:
import { SceneState, getSceneState } from '@web-engine-dev/scene';
const state = world.getResource(SceneState);
// state.activeScene -- { name, path?, dirty }
// state.persistentIds -- bidirectional entity <-> UUID index
// state.entityNames -- display names map
// state.loadedScenes -- multi-scene tracking
// state.activeSceneId -- currently active scene IDMulti-Scene Workflows
Load multiple scenes additively into the same world:
import {
loadSceneAdditive,
unloadSceneById,
saveSceneById,
setActiveSceneId,
getLoadedScenes,
} from '@web-engine-dev/scene';
// Load scenes additively with unique IDs
loadSceneAdditive(world, levelData, {
sceneId: 'level-1',
path: '/scenes/level-1.json',
});
loadSceneAdditive(world, uiData, {
sceneId: 'ui-overlay',
path: '/scenes/ui.json',
});
// Set which scene receives new entities
setActiveSceneId(world, 'level-1');
// Save only one scene
const levelSave = saveSceneById(world, 'level-1');
// Unload just one scene (preserves others)
unloadSceneById(world, 'ui-overlay');
// List loaded scenes
const scenes = getLoadedScenes(world);Entities in multi-scene workflows receive a SceneMembership component that tracks which scene they belong to.
Scene Tags and Components
The scene package provides several built-in ECS components:
| Component | Type | Description |
|---|---|---|
SceneEntity | Tag | Marks an entity as belonging to a loaded scene |
PersistentId | Data (u32 high/low) | Hash of the persistent UUID |
Active | Tag | Entity is enabled (default for all loaded entities) |
Volatile | Tag | Entity should not be saved (runtime-only) |
SceneMembership | Data (u32 high/low) | Tracks which scene an entity belongs to (multi-scene) |
Prefab System
The prefab system (@web-engine-dev/prefab) provides reusable entity templates.
Defining Prefabs
import { definePrefab, createComponentTypeId } from '@web-engine-dev/prefab';
const Position = createComponentTypeId('Position');
const Health = createComponentTypeId('Health');
const playerPrefab = definePrefab('Player')
.withName('Player Entity')
.withDescription('The main player character')
.withTags('character', 'controllable')
.withComponent(Position, { x: 0, y: 0, z: 0 })
.withComponent(Health, { current: 100, max: 100 })
.build();Prefab Variants
Variants inherit from a base prefab and modify specific properties:
const variant = {
id: createPrefabId('DamagedPlayer'),
name: 'Damaged Player',
basePrefabId: playerPrefab.id,
componentOverrides: [
{ typeId: Health, overrides: { current: 25 } },
],
addedComponents: [
{ typeId: StatusEffects, values: { bleeding: true } },
],
removedComponents: [],
};When a variant is resolved, the system:
- Recursively resolves the base prefab
- Removes components listed in
removedComponents - Applies
componentOverrides(merges values) - Adds
addedComponents
Hierarchical Prefabs
Prefabs can contain child prefabs for complex entity hierarchies:
const vehiclePrefab = definePrefab('Vehicle')
.withComponent(Position, { x: 0, y: 0, z: 0 })
.withChild('driver_seat', seatPrefab.id, {
localTransform: { position: { x: -0.5, y: 0, z: 0 } },
overrides: [{ typeId: SeatType, overrides: { isDriver: true } }],
})
.withChild('passenger_seat', seatPrefab.id, {
localTransform: { position: { x: 0.5, y: 0, z: 0 } },
})
.build();Instantiation
Create entities from prefabs at runtime through the PrefabInstantiator:
import { PrefabInstantiator, PrefabRegistry } from '@web-engine-dev/prefab';
const registry = new PrefabRegistry();
registry.register(playerPrefab);
const instantiator = new PrefabInstantiator({
registry,
entityFactory: {
create: () => world.spawn(),
destroy: (entity) => world.despawn(entity),
},
componentFactory: {
addComponent: (entity, typeId, values) => {
world.insert(entity, typeId, values);
},
setParent: (entity, parent) => {
world.setParent(entity, parent);
},
},
});
// Instantiate with optional runtime overrides
const result = instantiator.instantiate(playerPrefab.id, {
overrides: [
{ typeId: Health, overrides: { current: 50 } },
],
});Override Tracking
The PrefabOverrideTracker records which properties on prefab instances have been modified from their template values. These overrides are persisted in scene data:
import {
PrefabOverrideTracker,
PrefabOverrideTrackerResource,
} from '@web-engine-dev/prefab';
const tracker = new PrefabOverrideTracker();
world.insertResource(PrefabOverrideTrackerResource, tracker);
// Overrides are automatically serialized into EntityData.prefabOverrides
// when saving scenesECS Integration
The prefab package provides ECS components and resources:
PrefabInstanceComponent-- Links an entity to its source prefabPrefabChildComponent-- Identifies child entities within a prefab hierarchyPrefabEntity-- Tag marking prefab-spawned entitiesPrefabRegistryResource-- ECS resource for the prefab registryPrefabInstantiatorResource-- ECS resource for the instantiator
Events: OnPrefabInstantiated, OnPrefabDestroyed, OnPrefabPooled, OnPrefabReactivated