Brine2D 0.9.7-beta
dotnet add package Brine2D --version 0.9.7-beta
NuGet\Install-Package Brine2D -Version 0.9.7-beta
<PackageReference Include="Brine2D" Version="0.9.7-beta" />
<PackageVersion Include="Brine2D" Version="0.9.7-beta" />
<PackageReference Include="Brine2D" />
paket add Brine2D --version 0.9.7-beta
#r "nuget: Brine2D, 0.9.7-beta"
#:package Brine2D@0.9.7-beta
#addin nuget:?package=Brine2D&version=0.9.7-beta&prerelease
#tool nuget:?package=Brine2D&version=0.9.7-beta&prerelease
<div align="center"> <img src=".github/images/logo.png" alt="Brine2D - 2D Game Engine for .NET" width="200">
<br /> <br />
A modern, opinionated 2D game engine for .NET 10, built on SDL3 and designed for C# developers who want a great experience without an editor or a content pipeline.
If you've built web applications with ASP.NET Core, Brine2D will feel immediately familiar. If you've ever wanted a .NET game engine that feels like the rest of the modern .NET ecosystem, this is for you.
Why Brine2D?
Brine2D is a full engine, not just a rendering library. Scene management, an entity system, audio, input, Box2D physics, particles, UI, and a DI container all work together out of the box. Everything you'd otherwise build yourself in the first few weeks is already there.
var builder = GameApplication.CreateBuilder(args);
builder.Configure(options =>
{
options.Window.Title = "My Game";
options.Window.Width = 1280;
options.Window.Height = 720;
options.Rendering.VSync = true;
});
builder.AddScene<MainMenuScene>();
builder.AddScene<GameScene>();
await using var game = builder.Build();
await game.RunAsync<MainMenuScene>();
That's a complete entry point. Build() validates that every scene's dependencies are registered before the window opens. A missing service means a clear error message at startup, not a NullReferenceException mid-game.
No content pipeline. No editor. No special build steps.
Drop assets into a folder and load them. That's it.
public class LevelAssets : AssetManifest
{
public readonly AssetRef<ITexture> Tileset = Texture("assets/images/tileset.png");
public readonly AssetRef<ISoundEffect> Jump = Sound("assets/audio/jump.wav");
public readonly AssetRef<IMusic> Theme = Music("assets/audio/music/theme.ogg");
public readonly AssetRef<IFont> HUD = Font("assets/fonts/ui.ttf", size: 20);
}
public class GameScene : Scene
{
private readonly IAssetLoader _assetLoader;
private readonly LevelAssets _manifest = new();
public GameScene(IAssetLoader assetLoader) => _assetLoader = assetLoader;
protected override async Task OnLoadAsync(CancellationToken ct, IProgress<float>? progress = null)
=> await _assetLoader.PreloadAsync(_manifest, cancellationToken: ct);
protected override void OnEnter()
{
_player.Sprite.Texture = _manifest.Tileset;
Audio.PlayMusic(_manifest.Theme);
}
}
Quick Start
dotnet new console -n MyGame
cd MyGame
dotnet add package Brine2D
Program.cs:
using Brine2D.Hosting;
var builder = GameApplication.CreateBuilder(args);
builder.Configure(options =>
{
options.Window.Title = "My First Game";
options.Window.Width = 1280;
options.Window.Height = 720;
});
await using var game = builder.Build();
await game.RunAsync<GameScene>();
GameScene.cs:
using Brine2D.Core;
using Brine2D.Engine;
using Brine2D.Input;
public class GameScene : Scene
{
protected override void OnEnter()
{
Renderer.ClearColor = Color.DarkSlateBlue;
World.CreateEntity("Player")
.AddComponent<TransformComponent>(t => t.Position = new Vector2(640, 360))
.AddComponent<SpriteComponent>()
.AddBehavior<PlayerMovementBehavior>();
}
protected override void OnUpdate(GameTime gameTime)
{
if (Input.IsKeyPressed(Key.Escape))
Game.RequestExit();
}
protected override void OnRender(GameTime gameTime)
{
Renderer.DrawText("Hello, Brine2D!", 10, 10, Color.White);
}
}
dotnet run
ASP.NET Patterns You Already Know
| ASP.NET Core | Brine2D |
|---|---|
WebApplication.CreateBuilder() |
GameApplication.CreateBuilder() |
builder.Services.AddDbContext<T>() |
builder.Services.AddPhysics() |
ControllerBase properties |
Scene properties (Input, Audio, Renderer) |
Request-scoped DbContext |
Scene-scoped IEntityWorld (auto-disposed on exit) |
ILogger<T> |
ILogger<T> (same interface, same DI container) |
| Middleware pipeline | ECS systems (ordered, auto-added) |
Core Concepts
Scene Lifecycle
public class GameScene : Scene
{
private readonly IAssetLoader _assetLoader;
private LevelAssets _assets = new();
public GameScene(IAssetLoader assetLoader) => _assetLoader = assetLoader;
// 1. OnLoadAsync: I/O only. Runs while loading screen is visible.
protected override async Task OnLoadAsync(CancellationToken ct, IProgress<float>? progress = null)
{
await _assetLoader.PreloadAsync(_assets, cancellationToken: ct);
}
// 2. OnEnter: Scene logic. Assets are ready. Default systems already added.
protected override void OnEnter()
{
Audio.PlayMusic(_assets.Theme);
World.CreateEntity("Player")
.AddComponent<TransformComponent>(t => t.Position = new Vector2(400, 300))
.AddComponent<SpriteComponent>(s => s.Texture = _assets.Tileset)
.AddBehavior<PlayerMovementBehavior>();
// Disable systems you don't need
World.GetSystem<ParticleSystem>()!.IsEnabled = false;
}
// 3. OnUpdate: Every frame
protected override void OnUpdate(GameTime gameTime) { }
// 4. OnFixedUpdate: Fixed timestep (default 60 Hz). Zero or more times per frame.
protected override void OnFixedUpdate(GameTime fixedTime) { }
// 5. OnRender: Every frame, after systems render
protected override void OnRender(GameTime gameTime) { }
// 6. OnExit: Before unload
protected override void OnExit()
{
Audio.StopMusic();
}
// 7. OnUnloadAsync: Release resources
protected override Task OnUnloadAsync(CancellationToken ct) => Task.CompletedTask;
}
Framework properties (always available, no constructor needed):
| Property | Type | Description |
|---|---|---|
World |
IEntityWorld |
Scene-scoped entity world, auto-disposed |
Renderer |
IRenderer |
Draw calls and render state |
Input |
IInputContext |
Keyboard, mouse, gamepad |
Audio |
IAudioService |
Music and sound effects |
Logger |
ILogger |
Scoped to your scene type |
Game |
IGameContext |
Frame time, frame count |
Inject only what's yours:
public class GameScene : Scene
{
private readonly IPlayerService _playerService;
// Only inject YOUR services; framework properties handle the rest
public GameScene(IPlayerService playerService)
{
_playerService = playerService;
}
}
Default systems (added automatically in execution order):
| System | Pipeline | Order | Purpose |
|---|---|---|---|
SpriteRenderingSystem |
Render | 0 | Sprite batching and frustum culling |
AudioSystem |
Update | 0 | Spatial audio processing |
ParticleSystem |
Both | 250 / 100 | Particle effects with object pooling |
CameraSystem |
Update | 500 | Camera follow and zoom |
DebugRenderer |
Render | 1000 | Debug visualization (disabled by default) |
Physics systems are opt-in. Call
builder.Services.AddPhysics()and thenWorld.AddSystem<Box2DPhysicsSystem>()in your scene'sOnEnter. See the Physics section below.
Scene Navigation
// Simple load (inject ISceneManager via constructor)
_sceneManager.LoadScene<GameScene>();
// With a fade transition
_sceneManager.LoadScene<GameScene>(
new FadeTransition(duration: 0.5f, color: Color.Black));
// With a loading screen (scene loads in background, window never freezes)
_sceneManager.LoadScene<GameScene, MyLoadingScreen>(
new FadeTransition(duration: 1f));
// With a factory, for passing runtime data DI can't provide
_sceneManager.LoadScene(sp =>
new LevelScene(sp.GetRequiredService<IRenderer>(), levelNumber: 3));
Calling LoadScene from inside OnUpdate is safe; the transition is deferred to the frame boundary automatically.
Hybrid ECBS Architecture
Brine2D uses a hybrid Entity–Component–Behavior–System model. The distinction matters:
Component = pure data, no logic
public class HealthComponent : Component
{
public int HP { get; set; } = 100;
public int MaxHP { get; set; } = 100;
}
Behavior = entity-specific logic, full DI support
public class PlayerMovementBehavior : Behavior
{
private readonly IInputContext _input;
private TransformComponent _transform = null!;
public PlayerMovementBehavior(IInputContext input) => _input = input;
protected override void OnAttached()
=> _transform = Entity.GetRequiredComponent<TransformComponent>();
public override void Update(GameTime gameTime)
{
if (_input.IsKeyDown(Key.W))
_transform.Position -= Vector2.UnitY * 200f * (float)gameTime.DeltaTime;
}
// Also supports FixedUpdate for deterministic physics/simulation logic
public override void FixedUpdate(GameTime fixedTime) { }
}
System = batch processing across many entities
public class GravitySystem : UpdateSystemBase
{
public override int UpdateOrder => SystemUpdateOrder.Physics;
public override void Update(IEntityWorld world, GameTime gameTime)
{
world.Query()
.With<TransformComponent>()
.With<RigidbodyComponent>()
.ForEach((entity, transform, body) =>
{
body.Velocity += new Vector2(0, 980f) * (float)gameTime.DeltaTime;
transform.Position += body.Velocity * (float)gameTime.DeltaTime;
});
}
}
When to use what:
| Behavior | System | |
|---|---|---|
| Scope | One entity | Many entities |
| DI | ✅ Full injection | ✅ Constructor injection |
| Examples | Player input, boss AI | Physics, rendering, audio |
| Runs every frame | ✅ Automatic | ✅ Automatic |
Asset Loading
No content pipeline. No build step. Drop files into assets/ and load them.
Option 1: Typed manifest (recommended for scenes)
Declare your assets once as a class. Load them all in parallel with one call.
public class LevelAssets : AssetManifest
{
public readonly AssetRef<ITexture> Tileset = Texture("assets/images/tileset.png", TextureScaleMode.Nearest);
public readonly AssetRef<ITexture> Player = Texture("assets/images/player.png");
public readonly AssetRef<ISoundEffect> Jump = Sound("assets/audio/jump.wav");
public readonly AssetRef<ISoundEffect> Hurt = Sound("assets/audio/hurt.wav");
public readonly AssetRef<IMusic> Theme = Music("assets/audio/music/level1.ogg");
public readonly AssetRef<IFont> HUDFont = Font("assets/fonts/ui.ttf", size: 20);
}
private readonly IAssetLoader _assetLoader;
private readonly LevelAssets _assets = new();
public GameScene(IAssetLoader assetLoader) => _assetLoader = assetLoader;
protected override async Task OnLoadAsync(CancellationToken ct, IProgress<float>? progress = null)
{
// All assets loaded in parallel
await _assetLoader.PreloadAsync(_assets, cancellationToken: ct);
}
protected override void OnEnter()
{
// Implicit conversion, no .Value needed
_player.Sprite.Texture = _assets.Player;
Audio.PlayMusic(_assets.Theme);
}
Option 2: Direct loading (quick scripts, one-off assets)
var tex = await _assetLoader.GetOrLoadTextureAsync("assets/images/logo.png");
var sfx = await _assetLoader.GetOrLoadSoundAsync("assets/audio/click.wav");
var font = await _assetLoader.GetOrLoadFontAsync("assets/fonts/mono.ttf", size: 14);
All three share the same thread-safe cache, so loading the same path twice returns the cached instance.
Asset types and their loader methods:
| Type | Method | Cached? |
|---|---|---|
ITexture |
GetOrLoadTextureAsync |
✅ Yes |
ISoundEffect |
GetOrLoadSoundAsync |
✅ Yes |
IMusic |
GetOrLoadMusicAsync |
✅ Yes |
IFont |
GetOrLoadFontAsync(path, size) |
✅ Yes |
Queries
Fluent one-shot query:
// Finds all active enemies within 200px of the player, ordered by distance
World.Query()
.With<TransformComponent>()
.With<EnemyComponent>()
.Without<DeadComponent>()
.WithTag("active")
.WithinRadius(playerPos, 200f)
.ForEach<TransformComponent, EnemyComponent>((entity, transform, enemy) =>
{
enemy.Alert();
});
Cached query (for systems that run every frame):
// Declare in OnEnter; cache rebuilds only when components change
private CachedEntityQuery<TransformComponent, EnemyComponent> _enemyQuery = null!;
protected override void OnEnter()
{
_enemyQuery = World.CreateCachedQuery<TransformComponent, EnemyComponent>()
.WithTag("active")
.Build();
}
// Use in Update: zero allocation per frame
public override void Update(IEntityWorld world, GameTime gameTime)
{
_enemyQuery.ForEach((entity, transform, enemy) =>
{
// Process...
});
}
Supported filters:
| Method | Description |
|---|---|
.With<T>(filter?) |
Must have component, optional value filter |
.Without<T>() |
Must not have component |
.WithTag(tag) |
Must have tag |
.WithoutTag(tag) |
Must not have tag |
.WithAllTags(...) |
Must have all tags |
.WithAnyTag(...) |
Must have at least one tag |
.WithinRadius(center, r) |
Spatial circle query |
.WithinBounds(rect) |
Spatial AABB query |
.Where(predicate) |
Custom predicate |
.OrderBy(selector) |
Sort results |
.Take(n) / .Skip(n) |
Pagination |
.Random(n) |
Random selection |
.OnlyActive() |
Skip inactive entities |
Camera
// Follow the player with smooth lag
player.AddComponent<CameraFollowComponent>(c =>
{
c.CameraName = "main";
c.Smoothing = 5f; // 0 = instant snap, 2 = dreamy, 15 = tight
c.Deadzone = new Vector2(50, 30); // Won't move within this range
c.Offset = new Vector2(0, -50); // Look slightly ahead
});
// Zoom with smoothing
player.GetComponent<CameraFollowComponent>()!.TargetZoom = 1.5f;
player.GetComponent<CameraFollowComponent>()!.ZoomSmoothing = 3f;
// Control directly
_camera.Position = new Vector2(640, 360);
_camera.Zoom = 2f;
// Camera shake (from any system or behavior)
_camera.Shake(duration: 0.3f, intensity: 8f);
Physics
Brine2D integrates Box2D 3.x for rigid-body physics. Register physics services once at startup, then add the system to any scene that needs it.
Registration (Program.cs):
builder.Services.AddPhysics(options =>
{
options.Gravity = new Vector2(0, 980); // pixels/s² — Y-down screen space
options.PixelsPerMeter = 100f; // process-wide; all AddPhysics calls must match
options.SubStepCount = 4; // higher = more accurate, more CPU
});
// Optional: named layers for readable collision filtering
builder.Services.AddPhysicsLayers(layers =>
{
layers.Register("Default", 0);
layers.Register("Player", 1);
layers.Register("Enemies", 2);
layers.Register("Terrain", 3);
layers.Register("Triggers", 4);
});
Scene setup:
protected override void OnEnter()
{
World.AddSystem<Box2DPhysicsSystem>();
// Optional: kinematic character controller (two instances required)
World.AddSystem<PrePhysicsKinematicCharacterSystem>();
World.AddSystem<PostPhysicsKinematicCharacterSystem>();
// Optional: debug overlay (visualizes shapes, contacts, AABBs)
World.AddSystem<Box2DDebugDrawSystem>();
}
Adding a physics body to an entity:
World.CreateEntity("Crate")
.AddComponent<TransformComponent>(t => t.Position = new Vector2(400, 100))
.AddComponent<SpriteComponent>()
.AddComponent<PhysicsBodyComponent>(b =>
{
b.Shape = new BoxShape(48, 48);
b.BodyType = PhysicsBodyType.Dynamic;
b.Mass = 1f;
b.SurfaceFriction = 0.5f;
b.Restitution = 0.2f;
b.Layer = 0;
b.CollisionMask = ulong.MaxValue;
});
Body types:
| Type | Description |
|---|---|
Dynamic |
Fully simulated; affected by gravity, forces, and collisions |
Static |
Never moves; other bodies push off it (terrain, walls) |
Kinematic |
Moved by code, not forces; pushes dynamic bodies out |
Shape types: CircleShape, BoxShape, CapsuleShape, PolygonShape, ChainShape, SegmentShape
Collision events:
var body = entity.GetComponent<PhysicsBodyComponent>()!;
body.OnCollisionEnter += (other, contact) =>
{
Debug.WriteLine($"Hit {other.Entity?.Name} at speed {contact.ImpactSpeed:F1}");
};
body.OnCollisionExit += other => { };
body.OnCollisionStay += (other, contact) => { };
// Trigger (sensor) events
body.IsTrigger = true;
body.OnTriggerEnter += other => { };
body.OnTriggerExit += other => { };
Applying forces and impulses (from FixedUpdate):
body.ApplyLinearImpulse(new Vector2(0, -500)); // jump
body.ApplyForce(new Vector2(200, 0)); // wind
body.ApplyTorque(50f);
Queries (raycasts and shape overlaps):
// Inject PhysicsWorld via constructor
private readonly PhysicsWorld _physics;
// Raycast
var hit = _physics.RaycastClosest(origin, direction, maxDistance,
new PhysicsQueryFilter { ExcludeSensors = true });
// Shape cast (sweep a circle)
var hit = _physics.ShapeCastClosest(origin, radius: 24f, direction, maxDistance);
// Overlap check
Span<OverlapHit> results = stackalloc OverlapHit[16];
int count = _physics.OverlapCircle(center, radius: 100f, results);
// Filter helpers
PhysicsQueryFilter.SolidOnly // excludes sensors
PhysicsQueryFilter.ForLayer(layerIndex) // single layer
PhysicsQueryFilter.SolidLayer(layerIndex) // solid shapes on one layer
Kinematic character controller:
World.CreateEntity("Player")
.AddComponent<TransformComponent>(t => t.Position = new Vector2(400, 300))
.AddComponent<PhysicsBodyComponent>(b =>
{
b.Shape = new CapsuleShape(center1: new Vector2(0, -16), center2: new Vector2(0, 16), radius: 16f);
b.BodyType = PhysicsBodyType.Kinematic;
b.CollisionMask = ulong.MaxValue;
})
.AddComponent<KinematicCharacterBody>(c =>
{
c.FloorAngleLimit = 0.8f; // ~46° — steeper slopes count as walls
c.SnapDistance = 8f; // snap-to-floor on steps and slopes
c.MaxSpeed = 400f;
})
.AddBehavior<PlayerMovementBehavior>();
public class PlayerMovementBehavior : Behavior
{
private readonly IInputContext _input;
private KinematicCharacterBody _character = null!;
private const float Speed = 300f;
private const float JumpVY = -600f;
public PlayerMovementBehavior(IInputContext input) => _input = input;
protected override void OnAttached()
=> _character = Entity.GetRequiredComponent<KinematicCharacterBody>();
public override void FixedUpdate(GameTime fixedTime)
{
var vel = _character.Velocity;
vel.X = _input.IsKeyDown(Key.Right) ? Speed
: _input.IsKeyDown(Key.Left) ? -Speed
: 0f;
if (_input.IsKeyPressed(Key.Space) && _character.IsGrounded)
vel.Y = JumpVY;
else
vel.Y += 980f * (float)fixedTime.DeltaTime; // manual gravity
_character.MoveAndSlide(vel);
}
}
One-way platforms:
platform.AddComponent<PhysicsBodyComponent>(b =>
{
b.Shape = new BoxShape(200, 16);
b.BodyType = PhysicsBodyType.Static;
b.IsOneWayPlatform = true;
b.PlatformNormalDirection = new Vector2(0, -1); // solid from above
});
Ignoring collisions between two bodies:
_physicsWorld.IgnoreCollision(bodyA, bodyB);
_physicsWorld.RestoreCollision(bodyA, bodyB);
Teleporting a body without a velocity spike:
body.Teleport(new Vector2(100, 200));
body.Teleport(new Vector2(100, 200), rotation: 0f);
Configuration
builder.Configure(options =>
{
// Window
options.Window.Title = "My Game";
options.Window.Width = 1280;
options.Window.Height = 720;
options.Window.Fullscreen = false;
// Rendering
options.Rendering.VSync = true;
options.Rendering.TargetFPS = 60; // 0 = unlimited
options.Rendering.PreferredGPUDriver = GPUDriver.Vulkan; // D3D12, Metal, Auto
// ECS
options.ECS.EnableMultiThreading = true;
options.ECS.ParallelEntityThreshold = 100; // auto-parallel at 100+ entities
options.ECS.WorkerThreadCount = null; // null = all CPU cores
options.ECS.FixedTimeStepMs = 1000.0 / 60.0; // ~16.67ms = 60 Hz
options.ECS.MaxFixedStepsPerFrame = 8; // caps catch-up after long frames
// Loading screens
options.LoadingScreenMinimumDisplayMs = 200; // 0 = disable flash prevention
// Headless mode: no window, no audio (for servers and testing)
options.Headless = false;
});
Invalid configuration throws at Build() with a clear, specific error message, not at runtime.
Custom Systems
public class CameraShakeSystem : UpdateSystemBase
{
// Execution phase constants (use these instead of magic numbers)
public override int UpdateOrder => SystemUpdateOrder.LateUpdate; // 800
public override void Update(IEntityWorld world, GameTime gameTime)
{
world.Query()
.With<CameraShakeComponent>()
.ForEach<CameraShakeComponent>((entity, shake) =>
{
shake.Remaining -= (float)gameTime.DeltaTime;
if (shake.Remaining <= 0)
entity.RemoveComponent<CameraShakeComponent>();
});
}
}
Ordering constants:
| Constant | Value | Use for |
|---|---|---|
SystemUpdateOrder.Input |
-100 | Input processing |
SystemUpdateOrder.Update |
0 | Main update logic |
SystemUpdateOrder.Physics |
100 | Physics simulation |
SystemUpdateOrder.Collision |
200 | Collision detection |
SystemUpdateOrder.Animation |
400 | Animation updates |
SystemUpdateOrder.LateUpdate |
800 | Post-physics cleanup |
Fixed update systems run at a fixed timestep (deterministic physics, networking):
public class PhysicsIntegrationSystem : FixedUpdateSystemBase
{
public override int FixedUpdateOrder => SystemFixedUpdateOrder.Physics; // 0
public override void FixedUpdate(IEntityWorld world, GameTime fixedTime)
{
world.Query()
.With<TransformComponent>()
.With<RigidbodyComponent>()
.ForEach((entity, transform, body) =>
{
transform.Position += body.Velocity * (float)fixedTime.DeltaTime;
});
}
}
Fixed update ordering constants:
| Constant | Value | Use for |
|---|---|---|
SystemFixedUpdateOrder.EarlyFixedUpdate |
-100 | Force application, input-driven velocities |
SystemFixedUpdateOrder.PrePhysics |
-50 | Constraint setup |
SystemFixedUpdateOrder.Physics |
0 | Position integration |
SystemFixedUpdateOrder.PostPhysics |
50 | Physics cleanup |
SystemFixedUpdateOrder.Collision |
100 | Collision detection and resolution |
SystemFixedUpdateOrder.LateFixedUpdate |
200 | Post-collision cleanup |
protected override void OnEnter()
{
World.AddSystem<CameraShakeSystem>();
// Remove a default system you don't need
World.RemoveSystem<ParticleSystem>();
// Configure a default system
World.GetSystem<DebugRenderer>()!.IsEnabled = true;
World.GetSystem<DebugRenderer>()!.ShowColliders = true;
}
Project-Wide Scene Configuration
Apply settings to every scene's world without modifying each scene:
// In Program.cs, runs after default systems are added to every scene
builder.ConfigureScene(world =>
{
world.GetSystem<DebugRenderer>()!.IsEnabled = true;
world.AddSystem<AnalyticsSystem>();
});
// Add a custom system to every scene as a default
builder.AddDefaultSystem<FogOfWarSystem>();
builder.AddDefaultSystem<FogOfWarSystem>(s => s.Radius = 200f); // with configuration
// Permanently exclude a default system project-wide (avoids construction cost entirely)
builder.ExcludeDefaultSystem<ParticleSystem>();
builder.ExcludeDefaultSystem<CollisionDetectionSystem>();
ExcludeDefaultSystem removes the system from every scene. To conditionally disable a system at runtime instead, use ConfigureScene with IsEnabled = false.
Scene Registration
Optional, but catches missing DI dependencies at startup rather than at runtime:
// Validated at Build() -- throws if a dependency isn't registered
builder.AddScene<MainMenuScene>();
builder.AddScene<GameScene>();
// Multi-constructor scenes: annotate the one DI should use
[ActivatorUtilitiesConstructor]
public GameScene(IPlayerService playerService, IInputContext input) { ... }
Unregistered scenes still load via ActivatorUtilities. You'll just get a warning in the log.
Fallback scene for load failures:
// Replace the built-in error scene with your own
builder.UseFallbackScene<MyErrorScene>();
public class MyErrorScene : Scene
{
private readonly ISceneLoadErrorInfo _error;
public MyErrorScene(ISceneLoadErrorInfo error) => _error = error;
protected override void OnEnter()
{
Logger.LogError(_error.Exception, "Failed to load {Scene}", _error.FailedSceneName);
}
}
If a scene load fails and no SceneLoadFailed event handler queues a recovery transition, the fallback scene is shown automatically.
Dependency Injection
// Register your services
builder.Services.AddSingleton<IPlayerService, PlayerService>();
builder.Services.AddSingleton<ISaveSystem, LocalSaveSystem>();
// Optional features
builder.ConfigureBrine2D(b => b.UseInputLayers()); // context-sensitive input routing
builder.Services.AddPhysics(); // Box2D rigid-body physics
builder.Services.AddPhysicsLayers(layers => { ... }); // named layer registry
builder.Services.AddPostProcessing();
builder.Services.AddTextureAtlasing();
builder.Services.AddTilemapServices();
builder.Services.AddUICanvas();
builder.Services.AddPerformanceMonitoring();
Testing with Headless Mode
[Fact]
public async Task Player_TakingDamage_Dies_At_Zero_HP()
{
var builder = GameApplication.CreateBuilder();
builder.Configure(o => o.Headless = true); // No window, no SDL
builder.Services.AddSingleton<IPlayerService, PlayerService>();
await using var game = builder.Build();
// Run your scene on a background thread; test thread stays free
var runTask = game.RunAsync<GameScene>();
// ... assert things ...
game.Services.GetRequiredService<GameLoop>().Stop();
await runTask;
}
// Shutdown behaviour (useful for test environments)
options.ShutdownTimeoutSeconds = 5; // wait before forcing shutdown
options.ForceShutdownGracePeriodSeconds = 2; // grace period after forced stop
Rich Text
Renderer.DrawText(
"[b]Score:[/b] [color=#FFD700]9,999[/color]\n[size=14][i]Personal best![/i][/size]",
x: 10, y: 10,
new TextRenderOptions
{
ParseMarkup = true,
Color = Color.White,
MaxWidth = 300,
ShadowOffset = new Vector2(2, 2),
ShadowColor = new Color(0, 0, 0, 128)
});
Supported tags: [color=#RRGGBB], [size=n], [b], [i], [u], [s]
Advanced Rendering
// Post-processing (register via builder.Services.AddPostProcessing() in Program.cs)
// Off-screen render target
using var minimap = Renderer.CreateRenderTarget(256, 256);
Renderer.PushRenderTarget(minimap);
RenderMinimapContent();
Renderer.PopRenderTarget();
Renderer.DrawTexture(minimap.Texture, x: 10, y: 10);
// Scissor rectangle (UI scroll views, clipping)
Renderer.PushScissorRect(new Rectangle(10, 10, 300, 200));
DrawScrollableContent();
Renderer.PopScissorRect();
Performance
Built-in diagnostics: press F3 in any scene:
FPS: 60 (16.67ms) Draw Calls: 12 Entities: 1,247 Systems: 8
F4 shows per-system frame timings. F5 shows a rolling frame time graph.
How zero-allocation queries work:
ForEach iterates directly over ComponentPool<T> snapshots rented from ArrayPool<T>. The hot path touches only entities that have the queried components, not the full entity list. Cached queries (CreateCachedQuery) rebuild only when components are added or removed; on frames with no structural changes, they iterate a pre-built list with zero setup.
Characteristics:
| Entity count | Notes |
|---|---|
| < 1,000 | Single-threaded, negligible cost |
| 1,000–10,000 | Auto-parallelizes ForEach queries |
| 10,000–50,000 | Component pools and cached queries shine |
| 50,000+ | Achievable with cached queries; profiling recommended |
Tips:
- Use
CreateCachedQueryfor any query that runs every frame - Use
.WithinRadiusor.WithinBoundsto narrow spatial queries instead of filtering manually - Disable default systems you don't use (
ParticleSystem) in scenes that don't need them - Don't add
Box2DPhysicsSystemto scenes that have no physics bodies — it has near-zero overhead when idle, but the intent is clearer options.ECS.EnableMultiThreading = truefor large scenes on multi-core hardware
Features
Core Engine
- Hybrid ECBS: Components (data), Behaviors (entity logic + DI), Systems (batch processing)
- Scene management: async loading, transitions, loading screens, frame-boundary deferral
- Fluent entity queries: spatial indexing, zero-allocation
ForEach, cached queries - Event bus: type-safe pub/sub
- Fixed timestep pipeline:
FixedUpdateSystemBase,OnFixedUpdate, deterministic simulation - Ordered system execution with named phase constants
- Headless mode: full engine without a window, for dedicated servers and unit tests
- Delta time clamping: frame spikes from debugger pauses can't corrupt simulation
Rendering
- SDL3 GPU backend: Vulkan, Direct3D 12, Metal
- Sprite batching with automatic frustum culling
- Post-processing pipeline: Blur, Grayscale, custom effects via
ISDL3PostProcessEffect - Off-screen render targets
- Scissor rectangles
- Rich text with BBCode markup and shadow support
- Camera system: smooth follow, deadzone, zoom, shake
Audio
- Spatial 2D audio via SDL3_mixer
- Music streaming with crossfade support
- Sound effect pooling with priority-based track eviction
- Per-track volume, pan, and pitch control
- Bus-based audio grouping (pause/stop entire buses)
- Master, music, and sound volume channels
Input
- Keyboard, mouse, multi-gamepad with automatic slot management
- Input layer manager: priority-based consumption with cleanup pass for lower layers
- Action maps: named, toggleable action groups with runtime rebinding
- 10+ binding types: key, key-axis, composite (Ctrl+S), mouse button, scroll, mouse delta, gamepad button, axis, trigger, stick (radial deadzone)
- Built-in
PlayerControllerSystem: WASD + gamepad movement, diagonal normalization, custom action maps - Gamepad features: radial and per-axis deadzones, rumble (standard + trigger), multi-gamepad lobby support
- Text input mode with full Unicode/IME support
Gameplay
- Box2D 3.x rigid-body physics: dynamic, static, and kinematic bodies
- Five shape types: circle, box, capsule, polygon, chain
- Collision and sensor events with sub-shape detail (
OnCollisionEnter,OnTriggerEnter, etc.) - Raycasts, shape casts, and overlap queries with layer filtering
- Kinematic character controller:
MoveAndSlide,MoveAndCollide, grounded state, snap-to-floor, moving platforms - One-way platforms, collision groups, per-body gravity overrides
- Joints: revolute, distance, weld, prismatic, motor, wheel, mouse
- Particle system with object pooling
- Frame-based sprite animation
- Tilemap support: Tiled (
.tmj) integration - UI framework: canvas, buttons, labels, scroll views
Developer Experience
- ASP.NET Core DI container
Microsoft.Extensions.Loggingstructured logging- Engine options validated at
Build()viaDataAnnotations; bad config fails fast with a clear error - Unified asset loader: one service, all types, thread-safe cache
AssetManifest: typed, compile-time-safe asset declarations- Startup-time dependency validation for registered scenes
- Fallback scenes for graceful error recovery on load failures
Samples
# Getting started -- step-by-step tutorials
cd samples/GettingStarted/01-HelloBrine && dotnet run
# Feature showcase -- interactive demos of every system
cd samples/FeatureDemos && dotnet run
Getting Started tutorials:
01-HelloBrine: Window and first render02-SceneBasics: Lifecycle and scene transitions03-DependencyInjection: Services, DI, and configuration04-InputAndText: Input and rich text rendering
Feature demos (interactive):
- ECS query system: fluent queries, spatial indexing, caching
- Particles: GPU-accelerated effects
- Texture atlasing: runtime sprite packing
- Physics: Box2D rigid bodies, character controller, joints, raycasts
- Spatial audio: 2D positional sound
- Post-processing: real-time shader effects
- Scissor rectangles: UI clipping and scroll views
- Transitions: fade, slide, custom
- UI framework: complete component demos
- Sprite benchmark: 50,000+ sprite stress test with performance overlay
Architecture
src/
Brine2D/ - core engine (published to NuGet as Brine2D)
Brine2D.Build/ - optional MSBuild tooling (Brine2D.Build, coming in 1.0)
samples/
GettingStarted/ - numbered tutorials
FeatureDemos/ - interactive feature showcase
tests/
Brine2D.Tests/ - unit tests
Brine2D.Integration.Tests/ - integration tests
Design principles:
- Scene-scoped worlds: each scene gets its own
IEntityWorld, auto-disposed on exit. No entity leaks between scenes. - Framework properties: common services available on
Scenewithout constructor injection, matching ASP.NET'sControllerBasepattern. - Lifecycle separation:
OnLoadAsyncfor I/O,OnEnterfor logic. Default systems are in place by the timeOnEnterruns. - Convention over configuration: sensible defaults everywhere; power users can replace, remove, or reorder anything.
- Fail fast:
Build()validates options and scene dependencies before any window opens.
Platform Support
| Platform | GPU Backend | Status |
|---|---|---|
| Windows | Vulkan / Direct3D 12 | ✅ Tested |
| macOS | Metal | ⚠️ Untested |
| Linux | Vulkan | ⚠️ Untested |
SDL3 provides the cross-platform layer. macOS and Linux should work. Community testing welcome.
Requirements
- .NET 10 SDK
- SDL3, SDL3_image, SDL3_mixer, SDL3_ttf (all included via NuGet as
SDL3-CS.*) - No other native dependencies to install manually
Current Status
Version 0.9.x-beta. All core features working; API may change before 1.0.
✅ Working:
- Scene management, transitions, loading screens
- Hybrid ECBS with scene-scoped worlds
- Zero-allocation parallel queries
- Unified asset loader with
AssetManifestsupport - SDL3 GPU and legacy renderers
- Rich text with BBCode
- Post-processing, render targets, scissor rects
- Spatial audio
- Box2D 3.x physics (rigid bodies, character controller, joints, raycasts, sensors)
- Particle system
- UI framework
- Tilemap support
- Headless mode
- Startup dependency validation
⚠️ Known limitations:
- macOS and Linux untested
- Documentation site in progress
- Test coverage ~20% (target: 80% for 1.0)
- API stability not guaranteed until 1.0
Coming in 1.0:
- Stable API
- Complete documentation at brine2d.com
- macOS and Linux CI
- 80%+ test coverage
-
Brine2D.Build: optional NuGet for auto-generated asset path constants
Testing
dotnet test
dotnet test --collect:"XPlat Code Coverage"
dotnet test tests/Brine2D.Tests
Contributing
Contributions welcome. See CONTRIBUTING.md.
Most useful right now:
- Testing on macOS or Linux and reporting results
- Adding test coverage
- Building a sample game and documenting rough edges
- Trying the getting-started path as a new user and filing issues where it's unclear
Community
- Discussions: GitHub Discussions
- Issues: Issue Tracker
- Docs: brine2d.com
License
MIT - see LICENSE.
Credits
Built on:
Brine2D is part of the .NET game development ecosystem and stands on the shoulders of the community that proved C# is a great language for games.
Made with ❤️ by CrazyPickle Studios. Modern .NET, no editor required.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Box2D.NET.Release (>= 3.1.0)
- Microsoft.Extensions.Hosting (>= 10.0.5)
- Microsoft.Extensions.Logging (>= 10.0.5)
- Microsoft.Extensions.ObjectPool (>= 10.0.5)
- SDL3-CS (>= 3.5.0-preview.20260213-150035)
- SDL3-CS.Native (>= 3.5.0-preview.20260205-174353)
- SDL3-CS.Native.Image (>= 3.5.0)
- SDL3-CS.Native.Mixer (>= 3.2.0)
- SDL3-CS.Native.Shadercross (>= 3.0.0)
- SDL3-CS.Native.TTF (>= 3.3.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.9.7-beta | 42 | 5/7/2026 |
| 0.9.6-beta | 53 | 4/18/2026 |
| 0.9.5-beta | 65 | 4/8/2026 |
| 0.9.0-beta | 82 | 1/22/2026 |