ADR-011: Unified Scene Model
Status: Accepted Revision: v2 Implementation: Partial First accepted: 2026-01-01 · Last amended: 2026-07-26 Relates to: ADR-001 (World managers) · #431 · #1079
Context
Issue #431 raised fundamental questions about scene management:
- Should scenes be separate
Worldinstances or entity subsets within a single World? - How do persistent entities survive scene transitions?
- How does streaming work?
- How do scenes integrate with the editor?
Through discussion, we identified that KeenEyes currently has two similar but separate concepts:
| Concept | File Format | Generator | Runtime |
|---|---|---|---|
| Prefab | .keprefab |
PrefabGenerator |
PrefabManager |
| Scene | .kescene |
SceneGenerator |
SceneSerializer |
Both represent hierarchies of entities with components. The distinction is artificial:
- A prefab is "a template to spawn multiple times" (player, enemy)
- A scene is "a template to load as a level" (forest, dungeon)
This mirrors Godot's elegant approach where everything is a scene (.tscn). A player is a scene, a level is a scene. The difference is how you use it, not what it is.
Decision
Unify prefabs and scenes into a single concept: Scenes.
Model
Scenes are entity hierarchies. A scene definition produces a root entity with descendants. How you use it determines whether it behaves like a "prefab" or a "level".
As built, the runtime splits this across two cooperating pieces:
- Generated static spawn methods — the unified generator turns each
.kescene/.keprefabfile into aScenes.SpawnX(world, ...)method that instantiates the file-defined entity hierarchy. world.Scenes(SceneManager) — manages lifecycle.Spawn(string name)creates a scene root entity (SceneRootTag+SceneMetadata),AddToSceneassociates spawned entities with that root, andUnload/TransitionEntity/MarkPersistenthandle reference counting and persistence.
// Instantiate file-defined hierarchies (prefab usage) via generated spawn methods
var player = Scenes.SpawnPlayer(world);
var enemy1 = Scenes.SpawnEnemy(world);
var enemy2 = Scenes.SpawnEnemy(world);
// Create a scene root for lifecycle tracking (level usage)
var level = world.Scenes.Spawn("ForestLevel");
world.Scenes.AddToScene(Scenes.SpawnForestLevel(world), level);
// Unload when transitioning
world.Scenes.Unload(level);
Components
All scene-related components live in KeenEyes.Abstractions (namespace KeenEyes.Scenes):
/// <summary>
/// Marks an entity as the root of a spawned scene.
/// </summary>
[TagComponent]
public partial struct SceneRootTag;
/// <summary>
/// Marks an entity as persistent across scene unloads.
/// </summary>
[TagComponent]
public partial struct PersistentTag;
/// <summary>
/// Metadata for a scene root entity.
/// </summary>
[Component]
public partial struct SceneMetadata
{
public required string Name;
public Guid SceneId;
public SceneState State;
}
/// <summary>
/// Tracks which scene an entity belongs to and reference count.
/// </summary>
[Component]
public partial struct SceneMembership
{
public Entity OriginScene;
public int ReferenceCount;
}
public enum SceneState
{
Loaded,
Unloading
}
Runtime API
SceneManager is an internal manager in World, accessed via world.Scenes:
public partial class World
{
private SceneManager? sceneManager;
/// <summary>
/// Gets the scene manager for spawning and managing scenes.
/// </summary>
public SceneManager Scenes => sceneManager ??= new SceneManager(this);
}
SceneManager API (as built):
| Method | Description |
|---|---|
Spawn(string name) |
Create an empty scene root entity (SceneRootTag + SceneMetadata) for lifecycle tracking |
AddToScene(Entity entity, Entity scene) |
Associate an entity with a scene root (increments reference count) |
RemoveFromScene(Entity entity, Entity scene) |
Remove an entity from a scene (decrements reference count) |
Unload(Entity sceneRoot) |
Unload scene, respecting persistence and reference counts |
MarkPersistent(Entity entity) |
Mark entity to survive scene unloads |
TransitionEntity(Entity entity, Entity toScene) |
Move entity to another scene (increments ref count) |
GetLoaded() |
Get all currently loaded scene roots |
GetScene(string name) |
Get loaded scene by name |
IsLoaded(string name) |
Check whether a scene with the given name is loaded |
LoadedCount |
Number of currently loaded scenes |
Note that SceneManager does not instantiate file-defined content — the generated static Scenes.SpawnX methods do that. There is also no Spawn(string name, Vector3 position) overload; per-instance overrides are typed optional parameters on the generated spawn methods, derived from each file's overridableFields list.
Scene Transitions and Persistence
Reference counting handles entities that span scenes:
// NPC spawns in village (RefCount = 1)
var village = world.Scenes.Spawn("Village");
var npc = world.Spawn().Build();
world.Scenes.AddToScene(npc, village);
// NPC follows player to forest (RefCount = 2)
var forest = world.Scenes.Spawn("Forest");
world.Scenes.TransitionEntity(npc, forest);
// Unload village - NPC survives (RefCount = 1)
world.Scenes.Unload(village);
// Unload forest - NPC despawns (RefCount = 0)
world.Scenes.Unload(forest);
Persistent entities are never despawned by scene unloads:
var player = world.Spawn().Build();
world.Scenes.MarkPersistent(player); // Player survives all scene transitions
File Format
The .kescene format remains unchanged. The existing JSON schema works for both "prefab" and "scene" usage:
{
"$schema": "../schemas/kescene.schema.json",
"name": "Player",
"version": 1,
"entities": [
{
"id": "root",
"name": "Player",
"components": {
"Transform3D": { "position": [0, 0, 0] },
"Health": { "current": 100, "max": 100 }
}
},
{
"id": "camera",
"name": "Camera",
"parent": "root",
"components": {
"Transform3D": { "position": [0, 2, -5] },
"Camera": { "fov": 60 }
}
}
]
}
Generator
One unified generator — SceneGenerator (editor/KeenEyes.Generators/SceneGenerator.cs) — processes both .kescene and .keprefab AdditionalFiles and produces spawn methods. It emits a Scenes class with an All list and one spawn method per asset; each method's optional parameters come from that file's overridableFields list (there is no fixed position parameter):
// Generated code — optional parameters derive from each file's overridableFields
public static partial class Scenes
{
public static IReadOnlyList<string> All { get; } = ["Player", "Enemy", "ForestLevel"];
public static Entity SpawnPlayer(World world, /* overridable-field parameters */) { ... }
public static Entity SpawnEnemy(World world, /* overridable-field parameters */) { ... }
public static Entity SpawnForestLevel(World world) { ... }
}
// Usage: overrides are typed named arguments
var enemy = Scenes.SpawnEnemy(world, myGamePositionX: 100, myGamePositionY: 50);
Systems Do Not Load/Unload with Scenes
Systems are registered on the World and query for matching entities. When scene entities spawn, systems automatically process them. When entities despawn, systems stop processing them. No explicit system loading/unloading is needed.
Consequences
Positive
- Simpler mental model - One concept instead of two
- Matches Godot's proven approach - Everything is a scene
- Less code duplication - One generator, one manager
- Flexible usage - Same file can be instanced many times or loaded as a level
- Clean API - Generated
Scenes.SpawnXmethods plusworld.Sceneslifecycle management for everything
Negative
- Migration - Superseded: the anticipated migration of existing
.keprefabfiles to.kescenenever happened and is not planned. Both extensions remain first-class inputs to the single unifiedSceneGenerator(the SDK auto-includes**/*.keprefab, samples still use it, anddocs/prefabs.mddocuments the workflow as current). - Naming - "Scene" for a player entity may feel odd initially
Neutral
- Deprecation path - Completed and exceeded:
PrefabGeneratorwas merged intoSceneGenerator, and the entire runtime prefab API (PrefabManager,EntityPrefab,IPrefabCapability,World.Prefabs) was deprecated and then removed outright in July 2026 (#1079). The unified model is the only prefab/scene mechanism.
Implementation
- [x] Add scene components to
KeenEyes.Abstractions(KeenEyes.Scenesnamespace) - [x] Add
SceneManagertoKeenEyes.Corewithworld.Scenesaccessor - [x] Update
SceneGeneratorto handle all use cases — unified over.kesceneand.keprefab - [x] Deprecate
PrefabManagerandPrefabGenerator— exceeded:PrefabGeneratormerged intoSceneGenerator, and the runtime prefab API was subsequently removed entirely (#1079) - [x] Update editor to use unified model (
EditorWorldManager) - [ ]
Migrate existing— superseded: both extensions remain supported by the unified generator; no migration planned.keprefabfiles to.kescene
References
- Issue #431: Scene Management Research
- Issue #1079: Remove deprecated runtime prefab API
- Godot Scene System
- ADR-001: World Manager Architecture
Changelog
- v2 — 2026-07-26 (living-ADR conversion): Implementation marked Partial:
SceneManager.Spawn(name)creates only an empty scene root — file-defined hierarchies spawn via generated staticScenes.SpawnXmethods withoverridableFieldsparameters, and the documentedSpawn(name, Vector3)overload was never built; the planned.keprefab→.kescenemigration was superseded (both extensions stay first-class). Decision/Consequences/Implementation amended to the as-built split (generated spawn methods vs.world.Sceneslifecycle), full SceneManager API table, and the complete removal of the runtime prefab API (#1079). - v1 — 2026-01-01 (#431): Accepted — Unify prefabs and scenes into a single scene concept (one file model, one generator, one runtime lifecycle manager), resolving issue #431's scene-management questions.