![]() |
VOOZH | about |
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" />Directory.Packages.props
<PackageReference Include="Brine2D" />Project file
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&prereleaseInstall as a Cake Addin
#tool nuget:?package=Brine2D&version=0.9.7-beta&prereleaseInstall as a Cake Tool
<div align="center"> <img src=".github/images/logo.png" alt="Brine2D - 2D Game Engine for .NET" width="200">
<br /> <br />
👁 .NET
👁 Build Status
👁 codecov
👁 License: MIT
</div>
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.
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);
}
}
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 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) |
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.
// 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.
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 |
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 |
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 |
// 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);
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);
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.
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;
}
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.
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.
// 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();
[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
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]
// 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();
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:
CreateCachedQuery for any query that runs every frame.WithinRadius or .WithinBounds to narrow spatial queries instead of filtering manuallyParticleSystem) in scenes that don't need themBox2DPhysicsSystem to scenes that have no physics bodies — it has near-zero overhead when idle, but the intent is cleareroptions.ECS.EnableMultiThreading = true for large scenes on multi-core hardwareForEach, cached queriesFixedUpdateSystemBase, OnFixedUpdate, deterministic simulationISDL3PostProcessEffectPlayerControllerSystem: WASD + gamepad movement, diagonal normalization, custom action mapsOnCollisionEnter, OnTriggerEnter, etc.)MoveAndSlide, MoveAndCollide, grounded state, snap-to-floor, moving platforms.tmj) integrationMicrosoft.Extensions.Logging structured loggingBuild() via DataAnnotations; bad config fails fast with a clear errorAssetManifest: typed, compile-time-safe asset declarations# 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 renderingFeature demos (interactive):
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:
IEntityWorld, auto-disposed on exit. No entity leaks between scenes.Scene without constructor injection, matching ASP.NET's ControllerBase pattern.OnLoadAsync for I/O, OnEnter for logic. Default systems are in place by the time OnEnter runs.Build() validates options and scene dependencies before any window opens.| 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.
SDL3-CS.*)Version 0.9.x-beta. All core features working; API may change before 1.0.
✅ Working:
AssetManifest support⚠️ Known limitations:
Coming in 1.0:
Brine2D.Build: optional NuGet for auto-generated asset path constantsdotnet test
dotnet test --collect:"XPlat Code Coverage"
dotnet test tests/Brine2D.Tests
Contributions welcome. See .
Most useful right now:
MIT - see .
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 net10.0 is compatible. net10.0-android net10.0-android was computed. net10.0-browser net10.0-browser was computed. net10.0-ios net10.0-ios was computed. net10.0-maccatalyst net10.0-maccatalyst was computed. net10.0-macos net10.0-macos was computed. net10.0-tvos net10.0-tvos was computed. net10.0-windows net10.0-windows was computed. |
This package is not used by any NuGet packages.
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.9.7-beta | 66 | 5/7/2026 |
| 0.9.6-beta | 65 | 4/18/2026 |
| 0.9.5-beta | 75 | 4/8/2026 |
| 0.9.0-beta | 89 | 1/22/2026 |