Skip to content

Your First Game

Build a complete game from scratch in 30 minutes. You'll create an asteroid dodging game with sprites, audio, collision detection, and scoring.

What You'll Build

Asteroid Dodge - A simple but complete game where you: - Control a spaceship with WASD or arrow keys - Dodge incoming asteroids - Collect power-ups for bonus points - Compete for a high score

What you'll learn: - Loading and displaying sprites - Handling player input - Collision detection - Playing sound effects - Score tracking - Game state management


Prerequisites

Before starting:

  • ✅ Completed Quick Start
  • ✅ Basic C# knowledge
  • ✅ 30 minutes

Step 1: Project Setup

Create a new project:

dotnet new console -n AsteroidDodge
cd AsteroidDodge
dotnet add package Brine2D --version 0.9.0-beta
dotnet add package Brine2D.SDL --version 0.9.0-beta

Create folder structure:

mkdir Assets
mkdir Assets/Sprites
mkdir Assets/Sounds

Download assets (or create your own): - player.png - 32x32 spaceship sprite - asteroid.png - 32x32 asteroid sprite - powerup.png - 32x32 star sprite - explosion.wav - Collision sound effect - collect.wav - Power-up collection sound


Step 2: Program Setup

Replace Program.cs:

using Brine2D.Hosting;
using Brine2D.SDL;

var builder = GameApplication.CreateBuilder(args);

builder.Services.AddBrine2D(options =>
{
    options.WindowTitle = "Asteroid Dodge";
    options.WindowWidth = 800;
    options.WindowHeight = 600;
});

// Register our game scene
builder.Services.AddScene<GameScene>();

var game = builder.Build();
await game.RunAsync<GameScene>();

Step 3: Game State Service

Create GameState.cs - a singleton service for persistent data:

public class GameState
{
    public int Score { get; set; }
    public int HighScore { get; set; }
    public bool IsGameOver { get; set; }

    public void Reset()
    {
        Score = 0;
        IsGameOver = false;
    }

    public void AddScore(int points)
    {
        Score += points;
        if (Score > HighScore)
        {
            HighScore = Score;
        }
    }
}

Register it in Program.cs:

// Add this after AddBrine2D
builder.Services.AddSingleton<GameState>();

Why singleton? The game state persists across scenes (menu → game → game over), so it needs to survive scene changes.


Step 4: Game Scene

Create GameScene.cs:

using System.Numerics;
using Brine2D.Core;
using Brine2D.ECS;
using Brine2D.ECS.Components;
using Brine2D.Engine;
using Brine2D.Input;
using Brine2D.Audio;

public class GameScene : Scene
{
    // YOUR services (injected via constructor)
    private readonly IInputContext _input;
    private readonly IAudioService _audio;
    private readonly GameState _gameState;

    // Framework properties available automatically:
    // - Logger
    // - World (scoped per scene!)
    // - Renderer

    // Game state
    private Entity? _player;
    private readonly List<Entity> _asteroids = new();
    private readonly List<Entity> _powerups = new();

    private ITexture? _playerTexture;
    private ITexture? _asteroidTexture;
    private ITexture? _powerupTexture;

    private ISoundEffect? _explosionSound;
    private ISoundEffect? _collectSound;

    private readonly Random _random = new();
    private float _spawnTimer = 0f;
    private const float SpawnInterval = 1.5f;

    // ✅ Clean! Only inject YOUR dependencies
    public GameScene(
        IInputContext input,
        IAudioService audio,
        GameState gameState)
    {
        _input = input;
        _audio = audio;
        _gameState = gameState;
    }

    protected override Task OnInitializeAsync(CancellationToken ct)
    {
        // Reset game state
        _gameState.Reset();

        Logger.LogInformation("Game scene initialized");
        return Task.CompletedTask;
    }

    protected override async Task OnLoadAsync(CancellationToken ct)
    {
        // Load textures
        _playerTexture = await LoadTextureAsync("Assets/Sprites/player.png", ct);
        _asteroidTexture = await LoadTextureAsync("Assets/Sprites/asteroid.png", ct);
        _powerupTexture = await LoadTextureAsync("Assets/Sprites/powerup.png", ct);

        // Load sounds
        _explosionSound = await _audio.LoadSoundAsync("Assets/Sounds/explosion.wav", ct);
        _collectSound = await _audio.LoadSoundAsync("Assets/Sounds/collect.wav", ct);

        // Create player entity
        CreatePlayer();

        Logger.LogInformation("Assets loaded, {EntityCount} entities created", 
            World.Entities.Count);
    }

    private void CreatePlayer()
    {
        // World is available automatically (scoped per scene!)
        _player = World.CreateEntity("Player");

        var transform = _player.AddComponent<TransformComponent>();
        transform.Position = new Vector2(400, 300);

        var sprite = _player.AddComponent<SpriteComponent>();
        sprite.Texture = _playerTexture;
        sprite.Width = 32;
        sprite.Height = 32;

        _player.AddTag("Player");
    }

    protected override void OnUpdate(GameTime gameTime)
    {
        if (_gameState.IsGameOver)
        {
            // Handle game over input
            if (_input.IsKeyPressed(Key.R))
            {
                _gameState.Reset();
                RestartGame();
            }
            return;
        }

        var deltaTime = (float)gameTime.DeltaTime;

        // Update player
        UpdatePlayer(deltaTime);

        // Update asteroids
        UpdateAsteroids(deltaTime);

        // Update power-ups
        UpdatePowerups(deltaTime);

        // Spawn new objects
        UpdateSpawning(deltaTime);

        // Check collisions
        CheckCollisions();
    }

    private void UpdatePlayer(float deltaTime)
    {
        if (_player == null) return;

        var transform = _player.GetComponent<TransformComponent>();
        if (transform == null) return;

        const float speed = 300f;
        var movement = Vector2.Zero;

        if (_input.IsKeyDown(Key.W) || _input.IsKeyDown(Key.Up))
            movement.Y -= 1;
        if (_input.IsKeyDown(Key.S) || _input.IsKeyDown(Key.Down))
            movement.Y += 1;
        if (_input.IsKeyDown(Key.A) || _input.IsKeyDown(Key.Left))
            movement.X -= 1;
        if (_input.IsKeyDown(Key.D) || _input.IsKeyDown(Key.Right))
            movement.X += 1;

        if (movement != Vector2.Zero)
        {
            movement = Vector2.Normalize(movement);
            transform.Position += movement * speed * deltaTime;
        }

        // Clamp to screen bounds
        transform.Position = new Vector2(
            Math.Clamp(transform.Position.X, 16, Renderer.Width - 16),
            Math.Clamp(transform.Position.Y, 16, Renderer.Height - 16)
        );
    }

    private void UpdateAsteroids(float deltaTime)
    {
        for (int i = _asteroids.Count - 1; i >= 0; i--)
        {
            var asteroid = _asteroids[i];
            var transform = asteroid.GetComponent<TransformComponent>();

            // Move downward
            transform.Position += new Vector2(0, 200f * deltaTime);

            // Remove if off-screen
            if (transform.Position.Y > Renderer.Height + 32)
            {
                World.DestroyEntity(asteroid);
                _asteroids.RemoveAt(i);
            }
        }
    }

    private void UpdatePowerups(float deltaTime)
    {
        for (int i = _powerups.Count - 1; i >= 0; i--)
        {
            var powerup = _powerups[i];
            var transform = powerup.GetComponent<TransformComponent>();

            // Move downward slower
            transform.Position += new Vector2(0, 150f * deltaTime);

            // Remove if off-screen
            if (transform.Position.Y > Renderer.Height + 32)
            {
                World.DestroyEntity(powerup);
                _powerups.RemoveAt(i);
            }
        }
    }

    private void UpdateSpawning(float deltaTime)
    {
        _spawnTimer += deltaTime;

        if (_spawnTimer >= SpawnInterval)
        {
            _spawnTimer = 0f;

            // 70% chance asteroid, 30% chance power-up
            if (_random.NextDouble() < 0.7)
                SpawnAsteroid();
            else
                SpawnPowerup();
        }
    }

    private void SpawnAsteroid()
    {
        var asteroid = World.CreateEntity("Asteroid");

        var transform = asteroid.AddComponent<TransformComponent>();
        transform.Position = new Vector2(
            _random.Next(32, Renderer.Width - 32),
            -32
        );

        var sprite = asteroid.AddComponent<SpriteComponent>();
        sprite.Texture = _asteroidTexture;
        sprite.Width = 32;
        sprite.Height = 32;

        asteroid.AddTag("Asteroid");
        _asteroids.Add(asteroid);
    }

    private void SpawnPowerup()
    {
        var powerup = World.CreateEntity("Powerup");

        var transform = powerup.AddComponent<TransformComponent>();
        transform.Position = new Vector2(
            _random.Next(32, Renderer.Width - 32),
            -32
        );

        var sprite = powerup.AddComponent<SpriteComponent>();
        sprite.Texture = _powerupTexture;
        sprite.Width = 32;
        sprite.Height = 32;

        powerup.AddTag("Powerup");
        _powerups.Add(powerup);
    }

    private void CheckCollisions()
    {
        if (_player == null) return;

        var playerTransform = _player.GetComponent<TransformComponent>();
        if (playerTransform == null) return;

        // Check asteroid collisions
        foreach (var asteroid in _asteroids.ToList())
        {
            var asteroidTransform = asteroid.GetComponent<TransformComponent>();

            if (CheckCircleCollision(
                playerTransform.Position, 16,
                asteroidTransform.Position, 16))
            {
                // Game over!
                _audio.PlaySound(_explosionSound);
                _gameState.IsGameOver = true;
                Logger.LogInformation("Game Over! Final Score: {Score}", _gameState.Score);
                return;
            }
        }

        // Check power-up collisions
        for (int i = _powerups.Count - 1; i >= 0; i--)
        {
            var powerup = _powerups[i];
            var powerupTransform = powerup.GetComponent<TransformComponent>();

            if (CheckCircleCollision(
                playerTransform.Position, 16,
                powerupTransform.Position, 16))
            {
                // Collect power-up
                _audio.PlaySound(_collectSound);
                _gameState.AddScore(10);

                World.DestroyEntity(powerup);
                _powerups.RemoveAt(i);
            }
        }

        // Increase score for surviving
        _gameState.AddScore(1);
    }

    private bool CheckCircleCollision(Vector2 pos1, float radius1, Vector2 pos2, float radius2)
    {
        var distance = Vector2.Distance(pos1, pos2);
        return distance < radius1 + radius2;
    }

    private void RestartGame()
    {
        // Clear all entities manually for restart
        foreach (var asteroid in _asteroids.ToList())
        {
            World.DestroyEntity(asteroid);
        }
        _asteroids.Clear();

        foreach (var powerup in _powerups.ToList())
        {
            World.DestroyEntity(powerup);
        }
        _powerups.Clear();

        // Recreate player
        if (_player != null)
        {
            World.DestroyEntity(_player);
        }
        CreatePlayer();

        _spawnTimer = 0f;
    }

    protected override void OnRender(GameTime gameTime)
    {
        // Set background color
        Renderer.ClearColor = Color.FromArgb(10, 10, 20);

        // Draw UI
        if (_gameState.IsGameOver)
        {
            Renderer.DrawText("GAME OVER", Renderer.Width / 2 - 100, Renderer.Height / 2 - 40, 
                Color.Red, 32);
            Renderer.DrawText($"Final Score: {_gameState.Score}", Renderer.Width / 2 - 100, 
                Renderer.Height / 2, Color.White, 24);
            Renderer.DrawText($"High Score: {_gameState.HighScore}", Renderer.Width / 2 - 100, 
                Renderer.Height / 2 + 30, Color.Yellow, 24);
            Renderer.DrawText("Press R to Restart", Renderer.Width / 2 - 120, 
                Renderer.Height / 2 + 70, Color.White, 20);
        }
        else
        {
            Renderer.DrawText($"Score: {_gameState.Score}", 10, 10, Color.White, 20);
            Renderer.DrawText($"High Score: {_gameState.HighScore}", 10, 35, Color.Yellow, 16);
        }

        // Entities are rendered automatically by ECS rendering system!
    }

    protected override Task OnUnloadAsync(CancellationToken ct)
    {
        Logger.LogInformation("Game scene unloading");

        // ✅ No need to cleanup entities - World is disposed automatically!
        // All entities destroyed when scene ends

        return Task.CompletedTask;
    }
}

Understanding the Code

Property Injection Pattern

public class GameScene : Scene
{
    // ✅ Only YOUR dependencies in constructor
    public GameScene(
        IInputContext input,
        IAudioService audio,
        GameState gameState)
    {
        _input = input;
        _audio = audio;
        _gameState = gameState;
    }

    protected override async Task OnLoadAsync(CancellationToken ct)
    {
        // ✅ Framework properties available automatically!
        Logger.LogInformation("Loading assets");
        var player = World.CreateEntity("Player");
        _texture = await LoadTextureAsync("player.png", ct);
    }
}

Pattern: - Constructor: Inject YOUR services - Lifecycle methods: Use framework properties (Logger, World, Renderer)


Scoped World Pattern

protected override async Task OnLoadAsync(CancellationToken ct)
{
    // World is fresh and empty when scene loads!
    var player = World.CreateEntity("Player");
    var enemy = World.CreateEntity("Enemy");

    Logger.LogInformation("Created {Count} entities", World.Entities.Count); // 2
}

protected override Task OnUnloadAsync(CancellationToken ct)
{
    // ✅ No cleanup needed!
    // When scene ends, World is disposed automatically
    // All entities destroyed, no memory leaks!

    return Task.CompletedTask;
}

Benefits: - Fresh World per scene - Automatic cleanup - No memory leaks - Impossible to forget cleanup


Persistent Data Pattern

// ❌ Wrong - entities don't persist across scenes
private static Entity? _player; // Dies when scene ends!

// ✅ Correct - singleton service persists
public class GameState
{
    public int Score { get; set; }
    public int HighScore { get; set; }
}

// Register as singleton
builder.Services.AddSingleton<GameState>();

Pattern: - Entities: Temporary (scoped per scene) - Data: Persistent (singleton service)


Step 5: Run the Game

dotnet run

You should see: - Player spaceship in the center - Asteroids falling from top - Power-ups falling occasionally - Score increasing as you survive - Game over when hit by asteroid - Press R to restart


Enhancements

Add Difficulty Scaling

private float GetSpawnInterval()
{
    // Spawn faster as score increases
    var baseInterval = 1.5f;
    var reduction = _gameState.Score / 100f * 0.1f;
    return Math.Max(0.3f, baseInterval - reduction);
}

Add Player Lives

public class GameState
{
    public int Lives { get; set; } = 3;

    public void Reset()
    {
        Score = 0;
        Lives = 3;
        IsGameOver = false;
    }

    public bool LoseLife()
    {
        Lives--;
        if (Lives <= 0)
        {
            IsGameOver = true;
            return true;
        }
        return false;
    }
}

Add Particle Effects

private void CreateExplosion(Vector2 position)
{
    for (int i = 0; i < 10; i++)
    {
        var particle = World.CreateEntity("Particle");
        var transform = particle.AddComponent<TransformComponent>();
        transform.Position = position;

        var emitter = particle.AddComponent<ParticleEmitterComponent>();
        emitter.EmissionRate = 50f;
        emitter.ParticleLifetime = 0.5f;
        emitter.InitialVelocity = GetRandomDirection() * 200f;
    }
}

Complete Project Structure

AsteroidDodge/
├── Assets/
│   ├── Sprites/
│   │   ├── player.png
│   │   ├── asteroid.png
│   │   └── powerup.png
│   └── Sounds/
│       ├── explosion.wav
│       └── collect.wav
├── GameState.cs
├── GameScene.cs
├── Program.cs
└── AsteroidDodge.csproj

Key Takeaways

1. Property Injection

// ✅ Clean constructor - only YOUR services
public GameScene(IInputContext input, IAudioService audio)
{
    _input = input;
    _audio = audio;
}

// ✅ Framework properties available in lifecycle methods
protected override Task OnLoadAsync(CancellationToken ct)
{
    Logger.LogInfo("Loading");
    var entity = World.CreateEntity("Player");
    Renderer.ClearColor = Color.Black;
}

2. Scoped World

// ✅ World is scoped per scene - automatic cleanup!
protected override Task OnUnloadAsync(CancellationToken ct)
{
    // All entities destroyed automatically
    return Task.CompletedTask;
}

3. Persistent Data

// ✅ Use singleton service for data that survives scene changes
builder.Services.AddSingleton<GameState>();

4. Asset Loading

// ✅ Load assets in OnLoadAsync
protected override async Task OnLoadAsync(CancellationToken ct)
{
    _texture = await LoadTextureAsync("player.png", ct);
    _sound = await _audio.LoadSoundAsync("jump.wav", ct);
}

Troubleshooting

Problem: Entities from previous game still visible after restart

Cause: Not clearing entity lists.

Solution:

private void RestartGame()
{
    // Clear entity references
    foreach (var asteroid in _asteroids.ToList())
    {
        World.DestroyEntity(asteroid);
    }
    _asteroids.Clear();

    // Recreate player
    if (_player != null)
    {
        World.DestroyEntity(_player);
    }
    CreatePlayer();
}

Problem: Score resets between restarts

Cause: GameState.Reset() called on restart.

Solution:

// Keep high score, only reset current game
public void Reset()
{
    Score = 0;
    Lives = 3;
    IsGameOver = false;
    // Don't reset HighScore!
}

Problem: NullReferenceException when accessing World in constructor

Cause: World not set yet during construction.

Solution:

// ❌ Wrong - World is null in constructor!
public GameScene()
{
    var player = World.CreateEntity("Player"); // NullReferenceException!
}

// ✅ Correct - Use OnInitializeAsync or OnLoadAsync
protected override Task OnLoadAsync(CancellationToken ct)
{
    var player = World.CreateEntity("Player"); // Works!
    return Task.CompletedTask;
}

Next Steps

Now that you've built a complete game, explore more features:


Congratulations! You've built a complete game with Brine2D. You now understand: - Property injection pattern - Scoped EntityWorld - Persistent data management - Asset loading - Collision detection - Game state management

Ready to build something bigger? Check out our Tutorials and Samples!