Your First Game¶
Now that you've completed the Quick Start, let's build a complete game from scratch. We'll create a simple but fun game where you control a player sprite, avoid obstacles, and collect coins.
What You'll Build¶
By the end of this tutorial, you'll have:
- ✅ A movable player sprite
- ✅ Obstacle collision detection
- ✅ Collectible coins
- ✅ Score tracking
- ✅ Game over logic
- ✅ Proper scene lifecycle management
Estimated time: 20-30 minutes
Prerequisites¶
- Completed the Quick Start guide
- Basic understanding of C# and OOP
- Brine2D repository cloned and building
Step 1: Project Setup¶
Create a new console application:
dotnet new console -n CoinCollector
cd CoinCollector
Add references to Brine2D (adjust paths as needed):
dotnet add reference ../Brine2D/src/Brine2D.Core/Brine2D.Core.csproj
dotnet add reference ../Brine2D/src/Brine2D.Engine/Brine2D.Engine.csproj
dotnet add reference ../Brine2D/src/Brine2D.Hosting/Brine2D.Hosting.csproj
dotnet add reference ../Brine2D/src/Brine2D.Rendering/Brine2D.Rendering.csproj
dotnet add reference ../Brine2D/src/Brine2D.Rendering.SDL/Brine2D.Rendering.SDL.csproj
dotnet add reference ../Brine2D/src/Brine2D.Input/Brine2D.Input.csproj
dotnet add reference ../Brine2D/src/Brine2D.Input.SDL/Brine2D.Input.SDL.csproj
Step 2: Create the Game Scene¶
Replace Program.cs with this code:
using Brine2D.Core;
using Brine2D.Engine;
using Brine2D.Hosting;
using Brine2D.Input;
using Brine2D.Input.SDL;
using Brine2D.Rendering;
using Brine2D.Rendering.SDL;
using Microsoft.Extensions.Logging;
using System.Numerics;
// Create builder
var builder = GameApplication.CreateBuilder(args);
// Configure services
builder.Services.AddSDL3Rendering(options =>
{
options.WindowTitle = "Coin Collector";
options.WindowWidth = 800;
options.WindowHeight = 600;
options.VSync = true;
});
builder.Services.AddSDL3Input();
builder.Services.AddScene<GameScene>();
// Build and run
var game = builder.Build();
await game.RunAsync<GameScene>();
// Game Scene
public class GameScene : Scene
{
private readonly IRenderer _renderer;
private readonly IInputService _input;
private readonly IGameContext _gameContext;
// Game state
private Vector2 _playerPosition;
private float _playerSpeed = 200f;
private int _score = 0;
private bool _isGameOver = false;
// Game objects
private readonly List<Vector2> _obstacles = new();
private readonly List<Vector2> _coins = new();
public GameScene(
IRenderer renderer,
IInputService input,
IGameContext gameContext,
ILogger<GameScene> logger
) : base(logger)
{
_renderer = renderer;
_input = input;
_gameContext = gameContext;
_playerPosition = new Vector2(400, 300);
}
protected override void OnInitialize()
{
Logger.LogInformation("Coin Collector initialized!");
// Create obstacles
_obstacles.Add(new Vector2(200, 150));
_obstacles.Add(new Vector2(600, 200));
_obstacles.Add(new Vector2(300, 400));
_obstacles.Add(new Vector2(500, 450));
// Create coins
_coins.Add(new Vector2(100, 100));
_coins.Add(new Vector2(700, 100));
_coins.Add(new Vector2(100, 500));
_coins.Add(new Vector2(700, 500));
_coins.Add(new Vector2(400, 50));
}
protected override void OnUpdate(GameTime gameTime)
{
if (_isGameOver)
{
// Press R to restart
if (_input.IsKeyPressed(Keys.R))
{
RestartGame();
}
if (_input.IsKeyPressed(Keys.Escape))
{
_gameContext.RequestExit();
}
return;
}
var deltaTime = (float)gameTime.DeltaTime;
// Player movement
var movement = Vector2.Zero;
if (_input.IsKeyDown(Keys.W)) movement.Y -= 1;
if (_input.IsKeyDown(Keys.S)) movement.Y += 1;
if (_input.IsKeyDown(Keys.A)) movement.X -= 1;
if (_input.IsKeyDown(Keys.D)) movement.X += 1;
if (movement != Vector2.Zero)
{
movement = Vector2.Normalize(movement);
_playerPosition += movement * _playerSpeed * deltaTime;
// Keep player in bounds
_playerPosition.X = Math.Clamp(_playerPosition.X, 20, 780);
_playerPosition.Y = Math.Clamp(_playerPosition.Y, 20, 580);
}
// Check collision with obstacles
foreach (var obstacle in _obstacles)
{
if (CheckCollision(_playerPosition, obstacle, 20, 30))
{
_isGameOver = true;
Logger.LogInformation("Game Over! Final Score: {Score}", _score);
return;
}
}
// Check collision with coins
for (int i = _coins.Count - 1; i >= 0; i--)
{
if (CheckCollision(_playerPosition, _coins[i], 20, 15))
{
_coins.RemoveAt(i);
_score += 10;
Logger.LogInformation("Coin collected! Score: {Score}", _score);
// Win condition
if (_coins.Count == 0)
{
Logger.LogInformation("You Win! Final Score: {Score}", _score);
_isGameOver = true;
}
}
}
// Exit
if (_input.IsKeyPressed(Keys.Escape))
{
_gameContext.RequestExit();
}
}
protected override void OnRender(GameTime gameTime)
{
_renderer.Clear(new Color(20, 20, 30)); // Dark blue background
_renderer.BeginFrame();
if (!_isGameOver)
{
// Draw obstacles (red squares)
foreach (var obstacle in _obstacles)
{
_renderer.DrawRectangle(
obstacle.X - 15,
obstacle.Y - 15,
30,
30,
new Color(200, 50, 50)
);
}
// Draw coins (yellow circles - using squares for now)
foreach (var coin in _coins)
{
_renderer.DrawRectangle(
coin.X - 7,
coin.Y - 7,
15,
15,
new Color(255, 215, 0)
);
}
// Draw player (blue square)
_renderer.DrawRectangle(
_playerPosition.X - 10,
_playerPosition.Y - 10,
20,
20,
new Color(50, 150, 255)
);
// Draw score
_renderer.DrawText($"Score: {_score}", 10, 10, Color.White);
_renderer.DrawText($"Coins: {_coins.Count}", 10, 30, Color.White);
}
else
{
// Game over screen
if (_coins.Count == 0)
{
_renderer.DrawText("YOU WIN!", 300, 250, new Color(0, 255, 0));
}
else
{
_renderer.DrawText("GAME OVER", 280, 250, new Color(255, 0, 0));
}
_renderer.DrawText($"Final Score: {_score}", 300, 300, Color.White);
_renderer.DrawText("Press R to Restart", 270, 350, Color.White);
_renderer.DrawText("Press ESC to Exit", 280, 380, Color.White);
}
_renderer.EndFrame();
}
private bool CheckCollision(Vector2 pos1, Vector2 pos2, float radius1, float radius2)
{
var distance = Vector2.Distance(pos1, pos2);
return distance < (radius1 + radius2);
}
private void RestartGame()
{
_playerPosition = new Vector2(400, 300);
_score = 0;
_isGameOver = false;
// Reset coins
_coins.Clear();
_coins.Add(new Vector2(100, 100));
_coins.Add(new Vector2(700, 100));
_coins.Add(new Vector2(100, 500));
_coins.Add(new Vector2(700, 500));
_coins.Add(new Vector2(400, 50));
Logger.LogInformation("Game restarted!");
}
}
Step 3: Run Your Game¶
Build and run:
dotnet run
You should see: - A blue square (player) in the center - Red squares (obstacles) scattered around - Yellow squares (coins) to collect - Score counter at the top
Controls:¶
- WASD - Move player
- R - Restart (when game over)
- ESC - Exit game
Understanding the Code¶
Let's break down what we built:
1. Game State¶
private Vector2 _playerPosition;
private float _playerSpeed = 200f;
private int _score = 0;
private bool _isGameOver = false;
private readonly List<Vector2> _obstacles = new();
private readonly List<Vector2> _coins = new();
We track the player's position, speed, score, and game state. Lists hold obstacle and coin positions.
2. Initialization¶
protected override void OnInitialize()
{
Logger.LogInformation("Coin Collector initialized!");
// Create obstacles
_obstacles.Add(new Vector2(200, 150));
// ... more obstacles
// Create coins
_coins.Add(new Vector2(100, 100));
// ... more coins
}
OnInitialize() runs once when the scene loads. We set up our game world here.
3. Update Loop¶
protected override void OnUpdate(GameTime gameTime)
{
var deltaTime = (float)gameTime.DeltaTime;
// Player movement
var movement = Vector2.Zero;
if (_input.IsKeyDown(Keys.W)) movement.Y -= 1;
// ... handle input
_playerPosition += movement * _playerSpeed * deltaTime;
// Check collisions
// ... collision logic
}
OnUpdate() runs every frame. We:
1. Get input
2. Move the player
3. Check collisions
4. Update game state
Key concept: We multiply movement by deltaTime to make it frame-rate independent.
4. Render Loop¶
protected override void OnRender(GameTime gameTime)
{
_renderer.Clear(new Color(20, 20, 30));
_renderer.BeginFrame();
// Draw game objects
_renderer.DrawRectangle(...);
_renderer.EndFrame();
}
OnRender() draws everything. Always call:
1. Clear() - Clear the screen
2. BeginFrame() - Start drawing
3. Draw your stuff
4. EndFrame() - Present to screen
5. Collision Detection¶
private bool CheckCollision(Vector2 pos1, Vector2 pos2, float radius1, float radius2)
{
var distance = Vector2.Distance(pos1, pos2);
return distance < (radius1 + radius2);
}
Simple circle-circle collision using distance between centers.
Step 4: Enhance Your Game¶
Now let's make it better!
Add Particle Effects on Coin Collection¶
// At the top of GameScene class
private readonly List<Particle> _particles = new();
// Particle class (add after GameScene)
public class Particle
{
public Vector2 Position;
public Vector2 Velocity;
public float Lifetime;
public Color Color;
}
// In OnUpdate, when collecting a coin:
if (CheckCollision(_playerPosition, _coins[i], 20, 15))
{
// Spawn particles
for (int p = 0; p < 5; p++)
{
_particles.Add(new Particle
{
Position = _coins[i],
Velocity = new Vector2(
Random.Shared.NextSingle() * 100 - 50,
Random.Shared.NextSingle() * 100 - 50
),
Lifetime = 1.0f,
Color = new Color(255, 215, 0)
});
}
_coins.RemoveAt(i);
_score += 10;
}
// Update particles (add after player movement):
for (int i = _particles.Count - 1; i >= 0; i--)
{
var p = _particles[i];
p.Position += p.Velocity * deltaTime;
p.Lifetime -= deltaTime;
if (p.Lifetime <= 0)
{
_particles.RemoveAt(i);
}
}
// In OnRender, draw particles (after drawing coins):
foreach (var p in _particles)
{
var alpha = (byte)(255 * (p.Lifetime / 1.0f));
_renderer.DrawRectangle(
p.Position.X - 2,
p.Position.Y - 2,
4,
4,
new Color(p.Color.R, p.Color.G, p.Color.B, alpha)
);
}
Add a Timer¶
// Add field
private double _gameTime = 0;
// In OnUpdate (when not game over):
_gameTime += gameTime.DeltaTime;
// In OnRender:
_renderer.DrawText($"Time: {(int)_gameTime}s", 10, 50, Color.White);
Add Smooth Movement¶
// Add fields
private Vector2 _velocity = Vector2.Zero;
private float _acceleration = 500f;
private float _friction = 0.9f;
// Replace movement in OnUpdate:
var input = Vector2.Zero;
if (_input.IsKeyDown(Keys.W)) input.Y -= 1;
if (_input.IsKeyDown(Keys.S)) input.Y += 1;
if (_input.IsKeyDown(Keys.A)) input.X -= 1;
if (_input.IsKeyDown(Keys.D)) input.X += 1;
if (input != Vector2.Zero)
{
input = Vector2.Normalize(input);
_velocity += input * _acceleration * deltaTime;
}
// Apply friction
_velocity *= _friction;
// Move player
_playerPosition += _velocity * deltaTime;
What You've Learned¶
✅ Scene lifecycle - OnInitialize(), OnUpdate(), OnRender()
✅ Input handling - Keyboard input with IInputService
✅ Game state - Managing player, obstacles, and collectibles
✅ Collision detection - Simple circle-based collision
✅ Game flow - Win/lose conditions and restart logic
✅ Delta time - Frame-rate independent movement
✅ Rendering - Drawing shapes and text
✅ Logging - Using ILogger<T> for debugging
Next Steps¶
Now that you have a complete game, you can:
- Learn about Sprites - Replace shapes with actual graphics
- Add Animations - Animate your player character
- Use the Collision System - More robust collision detection
- Build a Platformer - Create a side-scrolling game
Complete Source Code¶
You can find the complete source code for this tutorial in the samples/BasicGame directory.
Congratulations! 🎉 You've built your first complete game with Brine2D. You now understand the core concepts and are ready to build more complex games.