Architecture¶
Brine2D's architecture is inspired by ASP.NET Core, bringing familiar patterns like dependency injection, configuration, and scoped services to game development.
Overview¶
Core principles:
| Principle | Description |
|---|---|
| Dependency Injection | Services injected via constructor (testable, maintainable) |
| Scoped Services | EntityWorld scoped per scene (automatic cleanup!) |
| Configuration | Options pattern (appsettings.json, environment variables) |
| Hosting Model | GameApplication builder (like WebApplication) |
| Lifecycle Hooks | Scene lifecycle (Initialize → Load → Update → Render → Unload) |
High-Level Architecture¶
graph TB
subgraph Application["Application Layer"]
GA[GameApplication]
GE[GameEngine]
GL[GameLoop]
end
subgraph Core["Core Services (Singleton)"]
SM[SceneManager]
IC[InputContext]
AS[AudioService]
GC[GameContext]
end
subgraph Scene["Scene Scope (Per Scene)"]
S[Scene]
EW[EntityWorld]
E1[Entity 1]
E2[Entity 2]
E3[Entity 3]
end
subgraph Rendering["Rendering"]
R[Renderer]
GPU[GPU Backend]
end
GA --> GE
GE --> GL
GL --> SM
GL --> IC
GL --> AS
GL --> R
SM --> S
S --> EW
EW --> E1
EW --> E2
EW --> E3
S --> R
R --> GPU
style GA fill:#264f78,stroke:#4fc1ff,stroke-width:2px,color:#fff
style S fill:#2d5016,stroke:#4ec9b0,stroke-width:2px,color:#fff
style EW fill:#4a2d4a,stroke:#c586c0,stroke-width:2px,color:#fff
Key concept: SceneManager creates a service scope per scene, providing isolated EntityWorld instances.
Dependency Injection Scoping¶
Service Lifetimes¶
Brine2D uses three DI lifetimes (same as ASP.NET Core):
| Lifetime | Created | Destroyed | Use For |
|---|---|---|---|
| Singleton | Once (app startup) | App shutdown | Input, Audio, SceneManager, Renderer |
| Scoped | Per scene | Scene unload | EntityWorld, scene-specific services |
| Transient | Per request | Immediately | Scenes, lightweight objects |
Scoped EntityWorld (NEW!)¶
Each scene gets its own isolated EntityWorld - automatic cleanup!
graph TB
subgraph Root["Root Service Provider (Singleton Scope)"]
SM[SceneManager]
IC[InputContext]
AS[AudioService]
R[Renderer]
end
subgraph Scene1["Scene 1 Scope"]
S1[MenuScene]
EW1[EntityWorld 1]
E1A[Entity: Button1]
E1B[Entity: Button2]
E1C[Entity: Logo]
end
subgraph Scene2["Scene 2 Scope (Created)"]
S2[GameScene]
EW2[EntityWorld 2]
E2A[Entity: Player]
E2B[Entity: Enemy1]
E2C[Entity: Enemy2]
end
subgraph Scene3["Scene 3 Scope (Disposed)"]
S3["GameOverScene<br/>(disposed)"]
EW3["EntityWorld 3<br/>(disposed)"]
end
SM --> S1
SM -.->|Transition| S2
S1 --> EW1
S2 --> EW2
EW1 --> E1A
EW1 --> E1B
EW1 --> E1C
EW2 --> E2A
EW2 --> E2B
EW2 --> E2C
style SM fill:#264f78,stroke:#4fc1ff,stroke-width:2px,color:#fff
style S1 fill:#2d5016,stroke:#4ec9b0,stroke-width:2px,color:#fff
style S2 fill:#2d5016,stroke:#4ec9b0,stroke-width:2px,color:#fff
style S3 fill:#666,stroke:#999,stroke-width:1px,stroke-dasharray: 5 5,color:#999
style EW1 fill:#4a2d4a,stroke:#c586c0,stroke-width:2px,color:#fff
style EW2 fill:#4a2d4a,stroke:#c586c0,stroke-width:2px,color:#fff
style EW3 fill:#333,stroke:#666,stroke-width:1px,stroke-dasharray: 5 5,color:#666
Key benefits: - ✅ Automatic cleanup - entities destroyed when scene unloads - ✅ Isolation - scenes can't interfere with each other - ✅ No memory leaks - impossible to forget cleanup - ✅ Fresh slate - each scene starts with empty World
Pattern: This matches ASP.NET's request scope - each HTTP request gets its own scope, and Brine2D scenes work the same way!
How Scoping Works¶
Service Registration¶
public static IServiceCollection AddBrine2D(
this IServiceCollection services,
Action<Brine2DOptions>? configure = null)
{
// ✅ Singleton services (shared across all scenes)
services.AddSingleton<ISceneManager, SceneManager>();
services.AddSingleton<IInputContext, InputContext>();
services.AddSingleton<IAudioService, AudioService>();
services.AddSingleton<IRenderer, Renderer>();
// ✅ Scoped services (per scene)
services.AddScoped<IEntityWorld, EntityWorld>();
// ✅ Transient services (per request)
services.AddTransient<MenuScene>();
services.AddTransient<GameScene>();
return services;
}
Scene Loading (Creates Scope)¶
public class SceneManager : ISceneManager
{
private readonly IServiceProvider _serviceProvider;
public async Task LoadSceneAsync<TScene>() where TScene : IScene
{
// ✅ Create new scene instance from DI (transient)
var scene = _serviceProvider.GetRequiredService<TScene>();
// ✅ Set framework properties
if (scene is Scene concreteScene)
{
// Logger (typed per scene)
var loggerFactory = _serviceProvider.GetRequiredService<ILoggerFactory>();
concreteScene.Logger = loggerFactory.CreateLogger(typeof(TScene));
// ✅ EntityWorld (scoped per scene - fresh instance!)
concreteScene.World = _serviceProvider.GetRequiredService<IEntityWorld>();
// Renderer (singleton - shared)
concreteScene.Renderer = _serviceProvider.GetRequiredService<IRenderer>();
}
// Initialize scene
await scene.InitializeAsync(cancellationToken);
await scene.LoadAsync(cancellationToken);
CurrentScene = scene;
}
}
Pattern: When SceneManager resolves a scene from DI, it gets a fresh EntityWorld instance because it's registered as scoped!
Component Architecture¶
Scene Base Class¶
public abstract class Scene : IScene
{
// ✅ Framework properties (set by SceneManager)
public ILogger Logger { get; internal set; } = null!;
public IEntityWorld World { get; internal set; } = null!;
public IRenderer Renderer { get; internal set; } = null!;
// Constructor: ONLY inject YOUR services
protected Scene(IInputContext input, IAudioService audio)
{
// Your dependencies here
}
// Lifecycle methods
protected virtual Task OnInitializeAsync(CancellationToken ct) => Task.CompletedTask;
protected virtual Task OnLoadAsync(CancellationToken ct) => Task.CompletedTask;
protected virtual void OnUpdate(GameTime gameTime) { }
protected virtual void OnRender(GameTime gameTime) { }
protected virtual Task OnUnloadAsync(CancellationToken ct) => Task.CompletedTask;
}
Pattern: Framework properties injected via property injection (set by SceneManager), user dependencies via constructor injection.
Entity-Component-System¶
// Entity - container for components
public class Entity
{
public Guid Id { get; }
public string Name { get; set; }
public IEntityWorld? World { get; internal set; }
private readonly Dictionary<Type, Component> _components = new();
// ✅ Lifecycle methods (optional)
public virtual void OnInitialize() { }
public virtual void OnUpdate(GameTime gameTime)
{
// ✅ Automatically calls OnUpdate on all components
foreach (var component in _components.Values)
{
if (component.IsEnabled)
{
component.OnUpdate(gameTime);
}
}
}
public virtual void OnRender(IRenderer renderer) { }
public virtual void OnDestroy() { }
}
// Component - data + optional logic
public abstract class Component
{
public Entity? Entity { get; internal set; }
public bool IsEnabled { get; set; } = true;
// ✅ Helper methods
public T? GetComponent<T>() where T : Component => Entity?.GetComponent<T>();
public T GetRequiredComponent<T>() where T : Component => Entity!.GetRequiredComponent<T>();
// ✅ Lifecycle hooks
protected internal virtual void OnAdded() { }
protected internal virtual void OnRemoved() { }
protected internal virtual void OnEnabled() { }
protected internal virtual void OnDisabled() { }
protected internal virtual void OnUpdate(GameTime gameTime) { }
}
// EntityWorld - manages entities (scoped per scene!)
public class EntityWorld : IEntityWorld, IDisposable
{
private readonly List<Entity> _entities = new();
private readonly IServiceProvider _serviceProvider;
public IReadOnlyList<Entity> Entities => _entities.AsReadOnly();
public Entity CreateEntity(string name = "")
{
var entity = new Entity { Name = name, World = this };
_entities.Add(entity);
entity.OnInitialize();
return entity;
}
public void DestroyEntity(Entity entity)
{
entity.OnDestroy();
_entities.Remove(entity);
}
// ✅ Automatic cleanup when scene unloads
public void Dispose()
{
foreach (var entity in _entities.ToList())
{
DestroyEntity(entity);
}
_entities.Clear();
}
}
Game Loop¶
Update-Render Loop¶
sequenceDiagram
participant GL as GameLoop
participant SM as SceneManager
participant S as Scene
participant EW as EntityWorld
participant E as Entities
participant R as Renderer
loop Every Frame (~60 FPS)
GL->>GL: Poll Input Events
GL->>SM: Update(gameTime)
SM->>S: OnUpdate(gameTime)
S->>S: Update game logic
S->>EW: Update()
EW->>E: OnUpdate(gameTime)
E->>E: Update components
GL->>SM: Render(gameTime)
SM->>R: BeginFrame()
SM->>R: Clear(color)
SM->>S: OnRender(gameTime)
S->>S: Render game objects
S->>EW: Render(renderer)
EW->>E: OnRender(renderer)
E->>R: Draw sprites/shapes
SM->>R: EndFrame()
R->>R: Present to screen
end
Key concepts: - Update runs first (game logic) - Render runs second (drawing) - Input polled before update - Frame management automatic (BeginFrame/EndFrame)
Scene Lifecycle¶
Complete Lifecycle¶
stateDiagram-v2
[*] --> Unloaded
Unloaded --> Initializing: LoadSceneAsync()
Initializing --> Loading: OnInitializeAsync()
note right of Initializing
DI creates scene instance
Sets Logger, World, Renderer
Scene scope created
end note
Loading --> Active: OnLoadAsync()
note right of Loading
Load assets
Create entities
Setup game state
end note
Active --> Active: OnUpdate()\nOnRender()
note right of Active
Game loop runs
Entities updated
Components active
end note
Active --> Unloading: LoadSceneAsync(NextScene)
Unloading --> Unloaded: OnUnloadAsync()
note right of Unloading
Scene scope disposed
EntityWorld disposed
All entities destroyed
end note
Unloaded --> [*]
Pattern: Scene scope created on load, disposed on unload - all scoped services (EntityWorld) automatically cleaned up!
Service Scope Lifecycle¶
Scene Transition Example¶
// Application startup - root scope created
var builder = GameApplication.CreateBuilder(args);
builder.Services.AddBrine2D(options =>
{
options.WindowTitle = "My Game";
});
// Register scenes
builder.Services.AddScene<MenuScene>();
builder.Services.AddScene<GameScene>();
var game = builder.Build();
// --- Application Running ---
// Load MenuScene - creates scope 1
await game.RunAsync<MenuScene>();
// MenuScene running
// - EntityWorld 1 created (scoped)
// - 100 UI entities created
// - InputContext shared (singleton)
// - Renderer shared (singleton)
// Transition to GameScene - disposes scope 1, creates scope 2
await sceneManager.LoadSceneAsync<GameScene>();
// ✅ MenuScene unloaded
// - EntityWorld 1 disposed
// - All 100 UI entities destroyed automatically
// - No memory leaks!
// GameScene running
// - EntityWorld 2 created (scoped)
// - 500 game entities created
// - Fresh EntityWorld, can't access MenuScene entities
// - InputContext still shared (singleton)
// - Renderer still shared (singleton)
// Application shutdown - root scope disposed
// - All singletons disposed
// - Current scene unloaded
// - EntityWorld 2 disposed
// - All 500 game entities destroyed
Pattern: Each scene transition creates/destroys a scope, automatically cleaning up scoped services!
System Registration¶
Registering Services¶
var builder = GameApplication.CreateBuilder(args);
// ✅ Singleton services (shared across all scenes)
builder.Services.AddSingleton<IGameStateService, GameStateService>();
builder.Services.AddSingleton<ISaveManager, SaveManager>();
// ✅ Scoped services (per scene)
builder.Services.AddScoped<ISceneSpecificService, SceneSpecificService>();
// ✅ Transient services (per request)
builder.Services.AddTransient<ITransientService, TransientService>();
// Register scenes
builder.Services.AddScene<MenuScene>();
builder.Services.AddScene<GameScene>();
var game = builder.Build();
await game.RunAsync<MenuScene>();
Scene Service Resolution¶
public class GameScene : Scene
{
// ✅ YOUR services injected via constructor
private readonly IInputContext _input;
private readonly IAudioService _audio;
private readonly IGameStateService _gameState;
public GameScene(
IInputContext input,
IAudioService audio,
IGameStateService gameState)
{
_input = input;
_audio = audio;
_gameState = gameState;
}
protected override Task OnLoadAsync(CancellationToken ct)
{
// ✅ Framework properties available automatically
Logger.LogInformation("Loading game scene");
// ✅ World is fresh and empty (scoped per scene)
var player = World.CreateEntity("Player");
player.AddComponent<TransformComponent>();
Logger.LogInformation("Created {Count} entities", World.Entities.Count);
return Task.CompletedTask;
}
protected override Task OnUnloadAsync(CancellationToken ct)
{
// ✅ No cleanup needed - World disposed automatically!
return Task.CompletedTask;
}
}
Benefits of This Architecture¶
1. Testability¶
// ✅ Easy to test - inject mocks
public class GameSceneTests
{
[Fact]
public async Task PlayerMovement_Works()
{
// Arrange
var mockInput = new Mock<IInputContext>();
var mockAudio = new Mock<IAudioService>();
var mockWorld = new Mock<IEntityWorld>();
var scene = new GameScene(
mockInput.Object,
mockAudio.Object);
scene.World = mockWorld.Object; // Set framework property
// Act
await scene.LoadAsync(CancellationToken.None);
scene.Update(new GameTime(0.016));
// Assert
mockWorld.Verify(w => w.CreateEntity("Player"), Times.Once);
}
}
2. No Memory Leaks¶
// ❌ Old way (manual cleanup)
public class MenuScene : Scene
{
protected override Task OnUnloadAsync(CancellationToken ct)
{
// ❌ Manual cleanup - easy to forget!
foreach (var entity in World.Entities.ToList())
{
World.DestroyEntity(entity);
}
return Task.CompletedTask;
}
}
// ✅ New way (automatic cleanup)
public class MenuScene : Scene
{
protected override Task OnUnloadAsync(CancellationToken ct)
{
// ✅ Nothing to do - World disposed automatically!
return Task.CompletedTask;
}
}
3. Scene Isolation¶
// MenuScene
public class MenuScene : Scene
{
protected override Task OnLoadAsync(CancellationToken ct)
{
// Create 100 UI entities
for (int i = 0; i < 100; i++)
{
CreateMenuButton();
}
Logger.LogInformation("Menu has {Count} entities", World.Entities.Count); // 100
return Task.CompletedTask;
}
}
// GameScene (loaded after MenuScene)
public class GameScene : Scene
{
protected override Task OnLoadAsync(CancellationToken ct)
{
// ✅ World is fresh - no leftover menu entities!
Logger.LogInformation("Game world has {Count} entities", World.Entities.Count); // 0
// Create game entities
CreatePlayer();
CreateEnemies();
return Task.CompletedTask;
}
}
4. Familiar Patterns¶
// ASP.NET Core controller
public class WeatherController : ControllerBase
{
// ✅ DI via constructor
private readonly ILogger<WeatherController> _logger;
private readonly IWeatherService _weather;
public WeatherController(
ILogger<WeatherController> logger,
IWeatherService weather)
{
_logger = logger;
_weather = weather;
}
[HttpGet]
public async Task<IEnumerable<WeatherForecast>> Get()
{
_logger.LogInformation("Getting weather");
return await _weather.GetForecastAsync();
}
}
// Brine2D scene - SAME PATTERN!
public class GameScene : Scene
{
// ✅ DI via constructor
private readonly IInputContext _input;
private readonly IAudioService _audio;
// ✅ Framework properties (like ControllerBase.Request)
// Logger, World, Renderer set automatically
public GameScene(IInputContext input, IAudioService audio)
{
_input = input;
_audio = audio;
}
protected override Task OnLoadAsync(CancellationToken ct)
{
Logger.LogInformation("Loading game");
return Task.CompletedTask;
}
}
Summary¶
Brine2D Architecture:
| Layer | Lifetime | Purpose |
|---|---|---|
| Application | Singleton | GameApplication, GameEngine, GameLoop |
| Core Services | Singleton | SceneManager, InputContext, AudioService, Renderer |
| Scene Scope | Scoped | EntityWorld (per scene) |
| Scenes | Transient | MenuScene, GameScene, etc. |
| Entities | Scoped | Lifetime bound to EntityWorld |
Key benefits:
| Benefit | Description |
|---|---|
| Familiar | ASP.NET Core patterns (DI, configuration, hosting) |
| Testable | Constructor injection, easy to mock |
| Automatic cleanup | Scoped World per scene - no memory leaks! |
| Isolated | Scenes can't interfere with each other |
| Flexible | Hybrid ECS (components with methods + optional systems) |
Pattern: Brine2D brings web development best practices to game development!
Next Steps¶
- Dependency Injection - Deep dive into DI
- Scenes Concept - Scene lifecycle and property injection
- ECS Architecture - Hybrid ECS explained
- Game Loop - Update-render loop details
- Builder Pattern - GameApplication builder
Remember: Each scene gets its own isolated EntityWorld that's automatically cleaned up when the scene unloads. No manual cleanup, no memory leaks! 🎮