Scene Management
Problem
You need to load, unload, and transition between different game scenes (menus, levels, etc.) while managing entity lifecycles.
Solution
Scene Definition
public interface IScene
{
string Name { get; }
void Load(World world);
void Unload(World world);
}
public abstract class SceneBase : IScene
{
public abstract string Name { get; }
protected List<Entity> sceneEntities = new();
public virtual void Load(World world)
{
// Override in derived classes
}
public virtual void Unload(World world)
{
// Despawn all entities created by this scene
var buffer = world.GetCommandBuffer();
foreach (var entity in sceneEntities)
{
if (world.IsAlive(entity))
{
buffer.Despawn(entity);
}
}
buffer.Execute();
sceneEntities.Clear();
}
protected Entity SpawnSceneEntity(World world)
{
var entity = world.Spawn().Build();
sceneEntities.Add(entity);
return entity;
}
protected EntityBuilder CreateSceneEntity(World world)
{
return new TrackedEntityBuilder(world.Spawn(), sceneEntities);
}
}
Scene Manager
public sealed class SceneManager
{
private readonly World world;
private readonly Dictionary<string, IScene> scenes = new();
private IScene? currentScene;
private IScene? nextScene;
private SceneTransition? activeTransition;
public string? CurrentSceneName => currentScene?.Name;
public SceneManager(World world)
{
this.world = world;
}
public void RegisterScene(IScene scene)
{
scenes[scene.Name] = scene;
}
public void LoadScene(string name)
{
if (!scenes.TryGetValue(name, out var scene))
throw new ArgumentException($"Scene '{name}' not found");
nextScene = scene;
}
public void LoadSceneWithTransition(string name, SceneTransition transition)
{
if (!scenes.TryGetValue(name, out var scene))
throw new ArgumentException($"Scene '{name}' not found");
nextScene = scene;
activeTransition = transition;
activeTransition.Start(TransitionPhase.Out);
}
public void Update(float deltaTime)
{
if (activeTransition != null)
{
activeTransition.Update(deltaTime);
if (activeTransition.Phase == TransitionPhase.Out && activeTransition.IsComplete)
{
// Transition out complete - swap scenes
PerformSceneSwap();
activeTransition.Start(TransitionPhase.In);
}
else if (activeTransition.Phase == TransitionPhase.In && activeTransition.IsComplete)
{
// Transition complete
activeTransition = null;
}
}
else if (nextScene != null)
{
// Immediate swap (no transition)
PerformSceneSwap();
}
}
private void PerformSceneSwap()
{
currentScene?.Unload(world);
currentScene = nextScene;
nextScene = null;
currentScene?.Load(world);
}
}
Scene Transitions
public enum TransitionPhase { None, Out, In }
public abstract class SceneTransition
{
public TransitionPhase Phase { get; private set; }
public float Progress { get; private set; }
public bool IsComplete => Progress >= 1f;
protected float duration;
protected SceneTransition(float duration)
{
this.duration = duration;
}
public void Start(TransitionPhase phase)
{
Phase = phase;
Progress = 0f;
}
public void Update(float deltaTime)
{
Progress = MathF.Min(1f, Progress + deltaTime / duration);
OnUpdate(Progress, Phase);
}
protected abstract void OnUpdate(float progress, TransitionPhase phase);
}
public class FadeTransition : SceneTransition
{
private readonly World world;
public FadeTransition(World world, float duration = 0.5f) : base(duration)
{
this.world = world;
}
protected override void OnUpdate(float progress, TransitionPhase phase)
{
float alpha = phase == TransitionPhase.Out ? progress : 1f - progress;
// Set screen overlay alpha
ref var overlay = ref world.GetSingleton<ScreenOverlay>();
overlay.Color = new Color(0, 0, 0, alpha);
}
}
public class SlideTransition : SceneTransition
{
private readonly World world;
private readonly Direction direction;
public SlideTransition(World world, Direction direction, float duration = 0.3f) : base(duration)
{
this.world = world;
this.direction = direction;
}
protected override void OnUpdate(float progress, TransitionPhase phase)
{
// Calculate slide offset based on direction and progress
ref var camera = ref world.GetSingleton<Camera>();
// Slide camera or UI elements
}
}
Example Scenes
public class MainMenuScene : SceneBase
{
public override string Name => "MainMenu";
public override void Load(World world)
{
// Create UI entities
CreateSceneEntity(world)
.With(new UIElement { Type = UIType.Button, Text = "Play" })
.With(new Position { X = 400, Y = 300 })
.With(new MenuAction { Action = MenuActionType.StartGame })
.Build();
CreateSceneEntity(world)
.With(new UIElement { Type = UIType.Button, Text = "Options" })
.With(new Position { X = 400, Y = 350 })
.With(new MenuAction { Action = MenuActionType.OpenOptions })
.Build();
CreateSceneEntity(world)
.With(new UIElement { Type = UIType.Button, Text = "Quit" })
.With(new Position { X = 400, Y = 400 })
.With(new MenuAction { Action = MenuActionType.Quit })
.Build();
// Background
CreateSceneEntity(world)
.With(new Sprite { Texture = "menu_background" })
.With(new Position { X = 0, Y = 0 })
.WithTag<Background>()
.Build();
}
}
public class GameplayScene : SceneBase
{
private readonly string levelName;
public override string Name => $"Level_{levelName}";
public GameplayScene(string levelName)
{
this.levelName = levelName;
}
public override void Load(World world)
{
// Load level data
var levelData = LevelLoader.Load(levelName);
// Create player
var player = CreateSceneEntity(world)
.With(new Position { X = levelData.PlayerStart.X, Y = levelData.PlayerStart.Y })
.With(new Health { Current = 100, Max = 100 })
.With(new PlayerInput())
.WithTag<Player>()
.Build();
// Create level geometry
foreach (var tile in levelData.Tiles)
{
CreateSceneEntity(world)
.With(new Position { X = tile.X, Y = tile.Y })
.With(new Sprite { Texture = tile.TextureName })
.With(new TileData { Type = tile.Type })
.Build();
}
// Create enemies
foreach (var spawn in levelData.EnemySpawns)
{
var prefab = world.GetPrefab(spawn.PrefabName);
var entity = world.SpawnPrefab(prefab)
.With(new Position { X = spawn.X, Y = spawn.Y })
.Build();
sceneEntities.Add(entity);
}
// Set up camera to follow player
ref var camera = ref world.GetSingleton<Camera>();
camera.Target = player;
}
public override void Unload(World world)
{
// Save progress before unloading
SaveManager.SaveCheckpoint(world);
base.Unload(world);
}
}
Scene Persistence (Don't Destroy on Load)
[TagComponent]
public partial struct Persistent : ITagComponent { }
public class PersistenceAwareSceneBase : SceneBase
{
public override void Unload(World world)
{
var buffer = world.GetCommandBuffer();
foreach (var entity in sceneEntities)
{
// Don't despawn persistent entities
if (world.IsAlive(entity) && !world.Has<Persistent>(entity))
{
buffer.Despawn(entity);
}
}
buffer.Execute();
sceneEntities.Clear();
}
}
Why This Works
Explicit Entity Ownership
Each scene tracks which entities it created:
- Clean unload: Despawn only your entities
- Persistent entities survive scene changes
- No orphaned entities
Transition Abstraction
Separating transition from scene loading:
- Reusable transitions across scenes
- Visual continuity during load
- Async loading possible during transition
Scene Registration
Explicit scene registration instead of reflection:
- AOT compatible
- No magic strings
- Clear dependencies
Variations
Additive Scenes
public class AdditiveSceneManager
{
private readonly List<IScene> activeScenes = new();
public void LoadAdditive(string name)
{
var scene = scenes[name];
scene.Load(world);
activeScenes.Add(scene);
}
public void UnloadScene(string name)
{
var scene = activeScenes.Find(s => s.Name == name);
if (scene != null)
{
scene.Unload(world);
activeScenes.Remove(scene);
}
}
}
// Usage: Load UI scene on top of gameplay
sceneManager.LoadScene("Level1");
sceneManager.LoadAdditive("HUD");
sceneManager.LoadAdditive("PauseMenu"); // Paused state
Async Scene Loading
public class AsyncSceneLoader
{
public async Task<IScene> LoadSceneAsync(string name, IProgress<float>? progress = null)
{
var scene = scenes[name];
// Load assets in background
var assets = GetRequiredAssets(scene);
float totalAssets = assets.Count;
float loaded = 0;
foreach (var asset in assets)
{
await AssetManager.LoadAsync(asset);
loaded++;
progress?.Report(loaded / totalAssets);
}
return scene;
}
}
// Usage with loading screen
public async void TransitionToScene(string name)
{
sceneManager.LoadScene("LoadingScreen");
var progress = new Progress<float>(p =>
{
ref var loading = ref world.GetSingleton<LoadingProgress>();
loading.Percent = p;
});
var scene = await loader.LoadSceneAsync(name, progress);
sceneManager.LoadSceneWithTransition(name, new FadeTransition(world));
}
Scene State Serialization
public interface ISerializableScene : IScene
{
SceneState SaveState(World world);
void LoadState(World world, SceneState state);
}
public class SceneState
{
public string SceneName { get; set; } = "";
public Dictionary<string, object> Data { get; set; } = new();
}
// Save game state
public void SaveGame()
{
if (currentScene is ISerializableScene serializable)
{
var state = serializable.SaveState(world);
SaveSystem.Save("savegame.json", state);
}
}
// Load game state
public void LoadGame()
{
var state = SaveSystem.Load<SceneState>("savegame.json");
sceneManager.LoadScene(state.SceneName);
if (currentScene is ISerializableScene serializable)
{
serializable.LoadState(world, state);
}
}
Scene Events
public class SceneManager
{
public event Action<string>? SceneLoading;
public event Action<string>? SceneLoaded;
public event Action<string>? SceneUnloading;
public event Action<string>? SceneUnloaded;
private void PerformSceneSwap()
{
if (currentScene != null)
{
SceneUnloading?.Invoke(currentScene.Name);
currentScene.Unload(world);
SceneUnloaded?.Invoke(currentScene.Name);
}
currentScene = nextScene;
nextScene = null;
if (currentScene != null)
{
SceneLoading?.Invoke(currentScene.Name);
currentScene.Load(world);
SceneLoaded?.Invoke(currentScene.Name);
}
}
}
// Subscribe to events
sceneManager.SceneLoaded += sceneName =>
{
Analytics.TrackEvent("scene_loaded", sceneName);
};
Level Streaming
public class StreamingSceneManager
{
private readonly Dictionary<Vector2Int, IScene> chunks = new();
private readonly HashSet<Vector2Int> loadedChunks = new();
private const int ChunkSize = 100;
private const int LoadRadius = 2;
public void Update(Vector2 playerPosition)
{
var playerChunk = new Vector2Int(
(int)(playerPosition.X / ChunkSize),
(int)(playerPosition.Y / ChunkSize)
);
// Determine which chunks should be loaded
var shouldBeLoaded = new HashSet<Vector2Int>();
for (int x = -LoadRadius; x <= LoadRadius; x++)
{
for (int y = -LoadRadius; y <= LoadRadius; y++)
{
shouldBeLoaded.Add(playerChunk + new Vector2Int(x, y));
}
}
// Unload chunks that are too far
foreach (var chunk in loadedChunks.ToList())
{
if (!shouldBeLoaded.Contains(chunk))
{
chunks[chunk].Unload(world);
loadedChunks.Remove(chunk);
}
}
// Load new chunks
foreach (var chunk in shouldBeLoaded)
{
if (!loadedChunks.Contains(chunk) && chunks.ContainsKey(chunk))
{
chunks[chunk].Load(world);
loadedChunks.Add(chunk);
}
}
}
}
See Also
- Serialization Guide - Saving scene state
- Prefabs Guide - Entity templates for scenes
- Entity Spawning - Creating scene entities