Brine2D 1.0.0

dotnet add package Brine2D --version 1.0.0
                    
NuGet\Install-Package Brine2D -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Brine2D" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Brine2D" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Brine2D" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Brine2D --version 1.0.0
                    
#r "nuget: Brine2D, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Brine2D@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Brine2D&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Brine2D&version=1.0.0
                    
Install 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.


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 GPU-instanced particles, trails, sub-emitters, turbulence
AnimationSystem Update 400 Sprite animation, state machines, layers, blend trees
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 then World.AddSystem<Box2DPhysicsSystem>() in your scene's OnEnter. 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 OnAdded()
        => _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

Persistence (World / Entity Snapshots)

Brine2D ships two serializers. Both produce the same JSON format and support the same round-trip API. Choose based on your publish requirements.

EntitySerializer — zero setup, reflection-based (default choice)

// Inject via constructor (or new up directly)
private readonly EntitySerializer _serializer;

// Save
await _serializer.SaveWorldAsync(world, "save.json");

// Load and restore in one call
await _serializer.LoadAndRestoreWorldAsync(world, "save.json");

// Or snapshot / restore manually
WorldSnapshot snapshot = _serializer.CreateWorldSnapshot(world);
_serializer.RestoreWorldFromSnapshot(world, snapshot);

EntitySerializer discovers and serializes all public component properties automatically. No registration required.

AotEntitySerializer — explicit registration, AOT-friendly

// One-time setup (e.g., in Program.cs or a factory)
var registry = new ComponentTypeRegistry();
registry.RegisterBrineComponents();                   // all built-in engine components
registry.RegisterAllComponents(GetType().Assembly);   // all your game components

var serializer = new AotEntitySerializer(registry);

// API is identical to EntitySerializer
await serializer.SaveWorldAsync(world, "save.json");
await serializer.LoadAndRestoreWorldAsync(world, "save.json");

RegisterAllComponents scans an assembly for every concrete Component subclass and registers it in one call. For trimmed / NativeAOT publishing, use Register<T>(JsonTypeInfo<T>) per component type with a source-generated JsonSerializerContext instead.

What is and isn't serialized:

Persisted Skipped
All serializable component properties Behaviors
Entity name, tags, IsActive Runtime-only properties ([JsonIgnore])
Parent–child hierarchy Entity IDs (remapped on restore)
Component property values Interface handles (ITexture, IMusic, etc.)

Note: Behaviors must be re-added after restore (e.g., via a prefab factory). Entity IDs in component fields are stale after restore — re-resolve cross-entity references by name or tag.


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);

Animation

Add AnimatorComponent alongside SpriteComponent. AnimationSystem is a default system and runs automatically.

Building a clip manually:

var clip = new AnimationClip { Name = "run", PlaybackMode = PlaybackMode.Loop };
clip.AddFrame(new SpriteFrame(new Rectangle(0,   0, 48, 48)));
clip.AddFrame(new SpriteFrame(new Rectangle(48,  0, 48, 48)));
clip.AddFrame(new SpriteFrame(new Rectangle(96,  0, 48, 48)));
clip.AddFrame(new SpriteFrame(new Rectangle(144, 0, 48, 48)));

var idle = new AnimationClip { Name = "idle", PlaybackMode = PlaybackMode.Loop };
idle.AddFrame(new SpriteFrame(new Rectangle(0, 48, 48, 48), duration: 0.5f));

var attack = new AnimationClip { Name = "attack", PlaybackMode = PlaybackMode.OnceHoldLast };
attack.AddFrame(new SpriteFrame(new Rectangle(0,  96, 48, 48)));
attack.AddFrame(new SpriteFrame(new Rectangle(48, 96, 48, 48)));

Setting up the entity:

var entity = World.CreateEntity("Player")
    .AddComponent<TransformComponent>(t => t.Position = new Vector2(400, 300))
    .AddComponent<SpriteComponent>(s => s.TexturePath = "assets/images/player.png")
    .AddComponent<AnimatorComponent>();

var anim = entity.GetComponent<AnimatorComponent>()!;
anim.Animator.AddAnimation(idle);
anim.Animator.AddAnimation(clip);
anim.Animator.AddAnimation(attack);

Playback modes:

PlaybackMode Description
Loop Loops indefinitely (default)
OnceHoldLast Plays once, freezes on last frame
OnceHoldFirst Plays once, freezes on first frame
OnceStop Plays once, then clears the current frame (CurrentFramenull)
PingPong Loops forward→backward indefinitely
PingPongOnce One full forward→backward cycle, then stops

Playback control:

anim.Animator.Play("run");
anim.Animator.Play("attack");                           // hard cut
anim.Animator.PlayWithCrossFade("run", crossFadeDuration: 0.1f);
anim.Animator.PlayFromFrame("run", startFrame: 2);
anim.Animator.PlayFromNormalizedTime("run", normalizedTime: 0.5f);
anim.Animator.PlayQueued("idle");                       // plays after current non-looping clip ends
anim.Animator.Pause();
anim.Animator.Resume();
anim.Animator.Stop();
anim.Animator.Speed    = 1.5f;                          // playback speed multiplier
anim.Animator.Reversed = true;                          // play in reverse

Animator events:

anim.Animator.OnAnimationStart    += clip => { };
anim.Animator.OnAnimationComplete += clip => { };       // non-looping clips only
anim.Animator.OnLoopComplete      += clip => { };       // each wrap, ping-pong reverse, or RepeatCount pass
anim.Animator.OnFrameChanged      += frame => { };

State machine (automatic transitions):

var sm = anim.StateMachine;

sm.SetDefaultState("idle");

// Condition-based transitions
sm.AddTransition("idle", "run",
    condition: () => _speed > 10f,
    canInterrupt: false,
    crossFadeDuration: 0.08f);

sm.AddTransition("run", "idle",
    condition: () => _speed <= 10f,
    crossFadeDuration: 0.08f);

// AnyState → attack when trigger is armed
sm.AddAnyTriggerTransition("attack", anim.Parameters, "AttackTrigger",
    canInterrupt: false, crossFadeDuration: 0.05f);

// Return to idle automatically when "attack" finishes
sm.AddOnCompleteTransition("attack", "idle");

// Arm a trigger from a behavior or system
anim.Parameters.SetTrigger("AttackTrigger");

Per-state callbacks:

sm.OnStateEnter("run",    prev => StartFootstepAudio());
sm.OnStateExit("attack",  next => ResetHitbox());
sm.OnStateChanged += (prev, next) => Debug.WriteLine($"{prev} → {next}");

Animation layers (independent tracks — e.g., body + upper-body overlay):

var upperBody = anim.AddLayer("upper-body", priority: 1);
upperBody.Mask      = AnimationLayerMask.SourceRect | AnimationLayerMask.FlipX;
upperBody.Weight    = 1f;
upperBody.BlendMode = AnimationLayerBlendMode.Override; // or Additive

upperBody.Animator.AddAnimation(aimClip);
upperBody.Animator.Play("aim");

// Tint-flash layer (additive, drives only Tint)
var tintLayer = anim.AddLayer("hit-flash", priority: 2);
tintLayer.Mask      = AnimationLayerMask.Tint;
tintLayer.BlendMode = AnimationLayerBlendMode.Additive;
tintLayer.Animator.AddAnimation(flashClip);
tintLayer.Animator.Play("flash");

1D blend selector (continuous parameter → clip selection):

var tree = new AnimationBlendSelector1D(anim.Animator);
tree.AddNode(threshold: 0f,   clipName: "idle", speed: 0f);
tree.AddNode(threshold: 150f, clipName: "walk", speed: 1f);
tree.AddNode(threshold: 400f, clipName: "run",  speed: 1.5f);
tree.CrossFadeDuration      = 0.08f; // smooth clip transitions
tree.RespectNonLoopingClips = true;  // don't interrupt attack/hurt clips

anim.BlendSelector1D = tree;

// Set each frame from a behavior or system
anim.BlendSelector1D.Value = _velocity.Length();

2D blend selector (two-axis directional selection):

var tree2d = new AnimationBlendSelector2D(anim.Animator);
tree2d.AddNode(new Vector2( 0,  1), "walk-up");
tree2d.AddNode(new Vector2( 0, -1), "walk-down");
tree2d.AddNode(new Vector2(-1,  0), "walk-left");
tree2d.AddNode(new Vector2( 1,  0), "walk-right");

anim.BlendSelector2D       = tree2d;
anim.BlendSelector2D.Value = new Vector2(_inputX, _inputY);

Per-frame hit boxes:

// Set on a frame when building the clip
frame.HitBox = new Rectangle(8, 4, 32, 40);            // primary hit box
frame.SetHitBox("sword", new Rectangle(32, 8, 24, 8)); // named hit box

// Read back at runtime
var box   = anim.CurrentHitBox;
var sword = anim.GetCurrentHitBox("sword");

Per-frame clip events:

clip.AddEvent(new ClipEvent { Time = 0.1f, Name = "footstep" });

// Events surface via OnFrameChanged; inspect clip.Events to match by Name / Time
anim.Animator.OnFrameChanged += frame => { };

Particles

ParticleSystem is a default system. Add ParticleEmitterComponent to any entity with a TransformComponent.

World.CreateEntity("Fire")
    .AddComponent<TransformComponent>(t => t.Position = new Vector2(400, 300))
    .AddComponent<ParticleEmitterComponent>(e =>
    {
        e.EmissionRate   = 40f;
        e.MaxParticles   = 200;
        e.ParticleLifetime   = 1.5f;
        e.LifetimeVariation  = 0.4f;
        e.StartSize      = 6f;
        e.EndSize        = 0f;
        e.StartColor     = new Color(255, 180, 60, 220);
        e.EndColor       = new Color(255, 80, 0, 0);
        e.InitialVelocity = new Vector2(0, -80f);
        e.VelocitySpread  = 35f;
        e.Gravity         = new Vector2(0, -20f);
        e.BlendMode       = BlendMode.Additive;
    });

Emitter shapes:

EmitterShape Properties used Notes
Point All particles spawn at the same position
Circle SpawnRadius, SpawnOnPerimeter Uniform disk fill, or ring perimeter
Box ShapeSize, BoxAngle Rotatable rectangular spawn area
Line LineLength / ShapeSize.X, LineAngle Particles spawn along a rotatable segment
Cone SpawnRadius, ConeAngle, SpawnOnPerimeter Directional cone; uses InitialVelocity as axis

Key emitter properties:

Property Description
EmissionRate Particles per second (continuous emitters)
IsBurst / BurstCount Single-frame burst instead of continuous emission
Duration / Loop Auto-stop after N seconds; re-arm with Loop = true
Delay Seconds before first emission (re-applied each loop)
WarmupDuration Pre-simulates N seconds on first activation; useful for ambient effects
MaxParticles Hard cap on live particles for this emitter
ColorGradient Multi-stop Color[] sampled over lifetime; overrides StartColor/EndColor
StartColorVariation / EndColorVariation Per-channel random nudge at spawn
SizeVariation / EndSizeVariation Per-particle random size range
StartSpeedMultiplier / EndSpeedMultiplier Speed curve over lifetime (1/1 = no change)
Damping Exponential drag: velocity *= exp(-Damping * dt)
TurbulenceStrength / TurbulenceFrequency Coherent value-noise velocity perturbation
SimulateInLocalSpace Particles move with the entity; good for exhaust and auras
VelocityInheritance Fraction of the entity's velocity added to new particles at spawn
RenderLayer Draw order relative to other emitters (lower = further back)
BlendMode Alpha, Additive, etc.
ParticleTexture / ParticleAtlasRegion Optional sprite; untextured particles use an SDF soft circle
ParticleFrames AtlasRegion[] animation strip distributed evenly over lifetime

Burst emitter:

emitter.IsBurst   = true;
emitter.BurstCount = 60;
emitter.Loop      = false;                    // fires once; entity disables itself when particles expire
emitter.OnEmitterFinished += () => entity.Destroy();

Playback control:

var e = entity.GetComponent<ParticleEmitterComponent>()!;

e.Play();    // start or restart from clean state
e.Pause();   // freeze aging, movement, and emission
e.Resume();  // unfreeze
e.Stop();    // clear all live particles on the next update

// Snapshot/restore configuration (e.g., after runtime tweaks)
e.CaptureDefaultState();
e.ResetToDefaultState();    // throws if CaptureDefaultState was never called
e.TryResetToDefaultState(); // safe version; returns false if no snapshot exists

Trails:

emitter.EnableTrails     = true;
emitter.TrailLength      = 8;         // history slots
emitter.TrailHeadAlpha   = 0.9f;      // alpha of newest trail segment
emitter.TrailTailAlpha   = 0.0f;      // alpha of oldest trail segment
emitter.TrailHeadSizeRatio = 1.0f;
emitter.TrailTailSizeRatio = 0.3f;
emitter.TrailMode        = TrailMode.Sprites; // or TrailMode.Lines (untextured only)

Trail particles fall back to the batch renderer; GPU instancing is used for non-trail particles.

Sub-emitters (birth, death, lifetime-fraction):

var spark = new SubEmitterConfig
{
    BurstCount     = 8,
    ParticleLifetime = 0.3f,
    StartSize      = 3f,
    EndSize        = 0f,
    InitialVelocity = Vector2.Zero,
    VelocitySpread  = 360f,
    StartColor     = Color.White,
    EndColor       = new Color(255, 255, 255, 0),
    BlendMode      = BlendMode.Additive,
    MaxParticles   = 400,             // shared cap across all bursts of this config
};

emitter.DeathSubEmitters = [spark];   // burst at each particle's death position
emitter.BirthSubEmitters = [spark];   // burst at each particle's spawn position

// Trigger at 50% lifetime
emitter.LifetimeFractionSubEmitters =
[
    new LifetimeFractionSubEmitter { Fraction = 0.5f, Config = spark }
];

Sub-emitter particles are managed internally — no extra entities or components needed. Sub-emitters do not chain.

Custom forces (IParticleForce):

public class VortexForce : IParticleForce
{
    public Vector2 Center { get; set; }
    public float   Strength { get; set; } = 200f;

    public Vector2 Evaluate(Vector2 particleWorldPosition, float deltaTime)
    {
        var diff = Center - particleWorldPosition;
        var perp = new Vector2(-diff.Y, diff.X);
        return Vector2.Normalize(perp) * Strength * deltaTime;
    }
}

emitter.Forces = [new VortexForce { Center = new Vector2(400, 300) }];

Forces are summed into BaseVelocity every frame and are subject to Damping and StartSpeedMultiplier/EndSpeedMultiplier.

Fire-and-forget burst (no entity required):

// Inject ParticleSystem via constructor
_particleSystem.Burst(worldPosition, sparkConfig);

Callbacks:

emitter.OnParticleSpawned  += p => { };  // called immediately after spawn
emitter.OnParticleDied     += p => { };  // called at natural expiry (not Stop())
emitter.OnEmitterFinished  += ()  => { };  // called when a duration/burst emitter finishes

Budget monitoring:

int total = World.GetSystem<ParticleSystem>()!.TotalParticleCount;
int own   = emitter.ParticleCount;
var live  = emitter.ActiveParticles; // IReadOnlyList<Particle>

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 OnAdded()
        => _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 CreateCachedQuery for any query that runs every frame
  • Use .WithinRadius or .WithinBounds to 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 Box2DPhysicsSystem to scenes that have no physics bodies — it has near-zero overhead when idle, but the intent is clearer
  • options.ECS.EnableMultiThreading = true for 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: GPU-instanced rendering, SDF soft circles, trails (sprites and line ribbon), sub-emitters (birth / death / lifetime-fraction triggers), coherent turbulence, IParticleForce, animated sprite frames, multi-stop color gradients, local-space simulation, warmup pre-simulation, object pooling
  • Sprite animation: AnimatorComponent with SpriteAnimator, code-driven state machine, animation layers (independent tracks with mask, weight, blend mode), 1D and 2D blend trees, cross-fade, ping-pong, per-frame hit boxes and clip events
  • Tilemap: Tiled (.tmj) orthogonal maps; embedded and external (.tsj) tilesets; tile, object, image, and group layers (with inherited parallax, opacity, tint, and offset); tile animation; tile flipping (H/V/diagonal); multi-tileset GID resolution; solid and one-way platform collision rect generation with greedy merge; world↔tile coordinate conversion with full parallax support; custom properties on maps, layers, tilesets, tiles, and objects; CSV and base64+zlib/gzip/zstd tile data encodings; ECS integration via TilemapComponent + TilemapSystem

Loading and rendering a tilemap

// In your scene's OnLoadAsync:
var loader = Services.GetRequiredService<ITilemapLoader>();
var tilemap = await loader.LoadAsync("maps/level1.tmj");

var entity = World.CreateEntity()
    .AddComponent<TilemapComponent>(c => c.Tilemap = tilemap);

// TilemapSystem handles async texture loading and rendering automatically.
// The component's IsLoaded flag goes true once textures are ready.

Collision rects from solid tiles

Mark tiles as solid in Tiled with a boolean custom property named solid (or isSolid). Enable collision on the layer with a boolean property named collision (or hasCollision).

// One rect per tile:
var rects = tilemap.GenerateCollisionRects("Collision");

// Greedy-merged rects (fewer, larger rects — better for physics solvers):
var merged = tilemap.MergeCollisionRects("Collision");

// One-way platforms work the same way, using the isSolid + isOneWayPlatform properties:
var platforms = tilemap.MergeOneWayPlatformRects("Collision");

Reading objects

// All objects in a layer:
var triggers = tilemap.GetObjects("Triggers");

// By ID (unique per map in Tiled):
var spawn = tilemap.GetObjectById(4);

// By type/class:
var enemies = tilemap.GetObjectsByType("Enemy");

// Read a custom property:
string dialogue = spawn?.CustomProperties.Get<string>("dialogue") ?? "";

Coordinate conversion

// World position → tile grid coords (accounts for layer offset + parallax):
var (tx, ty) = tilemap.WorldToTile(player.X, player.Y, layer, camera.Position);

// Tile grid → world position:
var (wx, wy) = tilemap.TileToWorld(tx, ty, layer);
  • UI framework: canvas, buttons, labels, scroll views

Developer Experience

  • ASP.NET Core DI container
  • Microsoft.Extensions.Logging structured logging
  • Engine options validated at Build() via DataAnnotations; 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
  • EntitySerializer: reflection-based world/entity snapshot persistence, zero setup
  • AotEntitySerializer + ComponentTypeRegistry: AOT/trimming-friendly persistence with opt-in source-generated component registration
  • Brine2D.Build: optional NuGet package that generates compile-time asset path constants from your asset folders

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:

  1. 01-HelloBrine: Window and first render
  2. 02-SceneBasics: Lifecycle and scene transitions
  3. 03-DependencyInjection: Services, DI, and configuration
  4. 04-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 (published to NuGet as Brine2D.Build)
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 Scene without constructor injection, matching ASP.NET's ControllerBase pattern.
  • Lifecycle separation: OnLoadAsync for I/O, OnEnter for logic. Default systems are in place by the time OnEnter runs.
  • 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 CI Status
Windows Vulkan / Direct3D 12 ✅ Tested
macOS Metal ✅ CI green, runtime untested
Linux Vulkan ✅ CI green, runtime untested

SDL3 provides the cross-platform layer. macOS and Linux build and test clean in CI. In-game runtime testing on those platforms is community-sourced — reports 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 1.0. All core features working.

✅ Working:

  • Scene management, transitions, loading screens
  • Hybrid ECBS with scene-scoped worlds
  • Zero-allocation parallel queries
  • Unified asset loader with AssetManifest support
  • 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
  • Entity/world persistence (EntitySerializer, AotEntitySerializer)
  • Brine2D.Build MSBuild tooling package (auto-generated asset constants)
  • Cross-platform CI (Windows, macOS, Linux)

⚠️ Known limitations:

  • macOS and Linux untested in production (CI green)
  • Documentation site in progress
  • NativeAOT/trimmed-publish path for built-in engine components in progress (post-1.0)

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


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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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
1.0.0 46 7/9/2026
0.9.7-beta 76 5/7/2026
0.9.6-beta 71 4/18/2026
0.9.5-beta 79 4/8/2026
0.9.0-beta 95 1/22/2026