Game Loop¶
The game loop is the heart of every game engine. It's the endless cycle that keeps your game running, processing input, updating game logic, and rendering graphics at a consistent rate.
Game Loop Overview¶
sequenceDiagram
participant GL as GameLoop
participant IN as InputService
participant ILM as InputLayerManager
participant SM as SceneManager
participant S as Scene
participant R as Renderer
Note over GL: Start Loop (60 FPS target)
loop Every Frame (~16.67ms)
GL->>GL: Calculate Delta Time
GL->>IN: Update() - Poll SDL Events
IN->>GL: Input Ready
GL->>ILM: ProcessInput()
ILM->>ILM: UI consumes first?
ILM->>GL: Input Layers Processed
Note over GL: Check for Quit Event
GL->>GL: Create GameTime
GL->>SM: Update(gameTime)
SM->>S: OnUpdate(gameTime)
S->>S: Game Logic
GL->>SM: Render(gameTime)
SM->>S: OnRender(gameTime)
S->>R: Draw Calls
R->>R: Present Frame
GL->>GL: Frame Limiting
Note over GL: Sleep + SpinWait for precision
end
Game Loop Phases¶
Phase 1: Input Processing¶
Purpose: Poll hardware devices and prepare input state
// Update input (polls SDL events)
_inputService.Update();
// Process input layers (like middleware)
_inputLayerManager.ProcessInput();
// Check for quit event from window close
if (_inputService.IsQuitRequested)
{
_gameContext.RequestExit();
}
What happens: 1. SDL events are polled (keyboard, mouse, gamepad, window events) 2. Input layers process in priority order (UI first, then game) 3. Quit events are detected (window close, Alt+F4, etc.)
Phase 2: Time Calculation¶
Purpose: Track elapsed time for frame-rate independent gameplay
var currentTime = _stopwatch.Elapsed;
var elapsedTime = currentTime - lastFrameTime;
lastFrameTime = currentTime;
totalTime += elapsedTime;
var gameTime = new GameTime(totalTime, elapsedTime);
GameTime Structure:
public readonly struct GameTime
{
public TimeSpan TotalTime { get; init; } // Total elapsed since start
public TimeSpan ElapsedTime { get; init; } // Time since last frame
public double DeltaTime => ElapsedTime.TotalSeconds; // Seconds as double
public double TotalSeconds => TotalTime.TotalSeconds; // Total as seconds
}
Usage in scenes:
protected override void OnUpdate(GameTime gameTime)
{
var deltaTime = (float)gameTime.DeltaTime; // ~0.0166 at 60 FPS
// Frame-rate independent movement
position += velocity * speed * deltaTime;
// Animation timing
_animator.Update(deltaTime);
}
Phase 3: Update Logic¶
Purpose: Execute game logic, physics, AI, etc.
// Update game context time
if (_gameContext is GameContext context)
{
context.GameTime = gameTime;
}
// Update current scene
_sceneManager.Update(gameTime);
Scene Update Flow:
graph TD
A["GameLoop.RunAsync()"] --> B["SceneManager.Update(gameTime)"]
B --> C["CurrentScene.Update(gameTime)"]
C --> D["OnUpdate(gameTime)"]
D --> E["Process Input"]
D --> F["Update Physics"]
D --> G["Update Animations"]
D --> H["Update AI"]
D --> I["Check Collisions"]
style A fill:#1e3a5f,stroke:#569cd6,stroke-width:2px,color:#fff
style B fill:#2d5016,stroke:#4ec9b0,stroke-width:2px,color:#fff
style C fill:#2d5016,stroke:#4ec9b0,stroke-width:2px,color:#fff
style D fill:#3d3d2a,stroke:#dcdcaa,stroke-width:2px,color:#fff
style E fill:#4a3d1f,stroke:#ce9178,stroke-width:2px,color:#fff
style F fill:#4a3d1f,stroke:#ce9178,stroke-width:2px,color:#fff
style G fill:#4a3d1f,stroke:#ce9178,stroke-width:2px,color:#fff
style H fill:#4a3d1f,stroke:#ce9178,stroke-width:2px,color:#fff
style I fill:#4a3d1f,stroke:#ce9178,stroke-width:2px,color:#fff
Phase 4: Rendering¶
Purpose: Draw all visual elements to the screen
// Render current scene
_sceneManager.Render(gameTime);
Rendering Flow:
graph TD
A["GameLoop.RunAsync()"] --> B["SceneManager.Render(gameTime)"]
B --> C["CurrentScene.Render(gameTime)"]
C --> D["OnRender(gameTime)"]
D --> E["_renderer.Clear(color)"]
D --> F["_renderer.BeginFrame()"]
D --> G["Draw Background"]
D --> H["Draw Game Objects"]
D --> I["Draw UI"]
D --> J["_renderer.EndFrame()"]
J --> K["SDL.RenderPresent()"]
K --> L["Display on Screen"]
style A fill:#1e3a5f,stroke:#569cd6,stroke-width:2px,color:#fff
style B fill:#2d5016,stroke:#4ec9b0,stroke-width:2px,color:#fff
style C fill:#2d5016,stroke:#4ec9b0,stroke-width:2px,color:#fff
style D fill:#3d3d2a,stroke:#dcdcaa,stroke-width:2px,color:#fff
style E fill:#4a2d4a,stroke:#c586c0,stroke-width:2px,color:#fff
style F fill:#4a2d4a,stroke:#c586c0,stroke-width:2px,color:#fff
style G fill:#4a3d1f,stroke:#ce9178,stroke-width:2px,color:#fff
style H fill:#4a3d1f,stroke:#ce9178,stroke-width:2px,color:#fff
style I fill:#4a3d1f,stroke:#ce9178,stroke-width:2px,color:#fff
style J fill:#4a2d4a,stroke:#c586c0,stroke-width:2px,color:#fff
style K fill:#4a2d4a,stroke:#c586c0,stroke-width:2px,color:#fff
style L fill:#264f78,stroke:#4fc1ff,stroke-width:2px,color:#fff
Phase 5: Frame Limiting¶
Purpose: Maintain consistent frame rate (default 60 FPS)
var targetFrameTime = TimeSpan.FromSeconds(1.0 / TargetFramesPerSecond);
// Frame limiting
var frameTime = _stopwatch.Elapsed - currentTime;
if (frameTime < targetFrameTime)
{
var sleepTime = targetFrameTime - frameTime;
// Sleep for most of the time (OS scheduler)
if (sleepTime > TimeSpan.FromMilliseconds(1))
{
Thread.Sleep(sleepTime - TimeSpan.FromMilliseconds(1));
}
// Spin for precision timing on the last millisecond
while (_stopwatch.Elapsed - currentTime < targetFrameTime)
{
Thread.SpinWait(100);
}
}
Why two-phase timing?
1. Thread.Sleep() - Efficient but imprecise (~15ms resolution on Windows)
2. Thread.SpinWait() - Precise but burns CPU (~1ms precision)
Hybrid approach: - Sleep for most of the time (save CPU) - Spin-wait for the last millisecond (precision)
Frame Timing¶
Target Frame Rate¶
Default: 60 FPS (16.67ms per frame)
public int TargetFramesPerSecond { get; set; } = 60;
Frame budgets:
| FPS | Frame Time | Use Case |
|---|---|---|
| 30 | 33.33ms | Low-power devices, slow games |
| 60 | 16.67ms | Standard (smooth gameplay) |
| 120 | 8.33ms | High-refresh displays |
| 144 | 6.94ms | Competitive gaming |
| Uncapped | Variable | Benchmarking, menu screens |
Frame Budget Breakdown¶
At 60 FPS (16.67ms per frame):
graph LR
A["Frame Budget: 16.67ms"] --> B["Input: <1ms"]
A --> C["Update: ~10ms"]
A --> D["Render: ~5ms"]
A --> E["Frame Limit: ~1ms"]
style A fill:#1e3a5f,stroke:#569cd6,stroke-width:3px,color:#fff
style B fill:#2d5016,stroke:#4ec9b0,stroke-width:2px,color:#fff
style C fill:#3d3d2a,stroke:#dcdcaa,stroke-width:2px,color:#fff
style D fill:#4a3d1f,stroke:#ce9178,stroke-width:2px,color:#fff
style E fill:#4a2d4a,stroke:#c586c0,stroke-width:2px,color:#fff
| Phase | Time Budget | What Can Go Wrong |
|---|---|---|
| Input | <1ms | Rare, unless reading massive controller DB |
| Update | ~10ms | Complex physics, AI, too many entities |
| Render | ~5ms | Draw calls, texture uploads, shader compilation |
| Frame Limiting | ~1ms | Precision timing overhead |
Delta Time¶
Frame-rate independent gameplay requires delta time:
Without Delta Time (Bad)¶
// BAD: Speed depends on frame rate
position += velocity; // Faster at 120 FPS, slower at 30 FPS
With Delta Time (Good)¶
// GOOD: Speed is consistent at any frame rate
var deltaTime = (float)gameTime.DeltaTime;
position += velocity * speed * deltaTime;
Example:
| FPS | Delta Time | Movement per Frame | Movement per Second |
|---|---|---|---|
| 30 | 0.0333s | 200 * 0.0333 = 6.67 | 200 pixels |
| 60 | 0.0166s | 200 * 0.0166 = 3.33 | 200 pixels |
| 120 | 0.0083s | 200 * 0.0083 = 1.67 | 200 pixels |
Speed is always 200 pixels/second regardless of frame rate!
Game Loop Lifecycle¶
stateDiagram-v2
[*] --> Created: GameLoop Constructor
Created --> Starting: RunAsync() Called
Starting --> Running: IsRunning = true
Running --> Running: Every Frame
Running --> Stopping: Stop() or Cancel
Stopping --> Stopped: IsRunning = false
Stopped --> [*]
Running --> Error: Exception
Error --> Stopping: Catch & Log
Startup Sequence¶
var gameLoop = Services.GetRequiredService<IGameLoop>();
// Start the loop
await gameLoop.RunAsync(cancellationToken);
Initialization flow:
GameApplication.RunAsync<TScene>()calledGameEngine.InitializeAsync()- Initialize subsystemsSceneManager.LoadSceneAsync<TScene>()- Load first sceneGameLoop.RunAsync()- Start main loop
Shutdown Sequence¶
// Graceful shutdown
_gameContext.RequestExit();
// Or force stop
gameLoop.Stop();
Cleanup:
1. Loop detects _gameContext.IsRunning == false
2. Loop breaks out
3. finally block executes
4. _stopwatch.Stop()
5. IsRunning = false
6. Scene unloads (resources cleaned up)
Thread Safety¶
Important: The game loop is single-threaded by design.
SDL3 Requirement¶
SDL3 must run on the main thread:
public Task RunAsync<TScene>(CancellationToken ct) where TScene : IScene
{
// Block synchronously to keep SDL on the same thread
_host.StartAsync(ct).GetAwaiter().GetResult();
// Initialize game engine (SDL initialization)
engine.InitializeAsync(ct).GetAwaiter().GetResult();
// Start game loop - MUST stay on this thread
gameLoop.RunAsync(ct).GetAwaiter().GetResult();
}
Why synchronous?
- SDL3 window/rendering must stay on main thread
- Async/await can resume on different threads
- .GetAwaiter().GetResult() blocks on current thread
Async Loading¶
You can still use async for loading:
protected override async Task OnLoadAsync(CancellationToken ct)
{
// CPU-bound work (parsing JSON) - can run on thread pool
var data = await Task.Run(() => LoadHeavyData(), ct);
// SDL work (texture creation) - back on main thread
_texture = await _textureLoader.LoadTextureAsync("sprite.png", ct);
}
Rule: SDL calls happen on main thread, but prep work can be parallel.
Customizing the Game Loop¶
Change Target FPS¶
var gameLoop = Services.GetRequiredService<IGameLoop>();
gameLoop.TargetFramesPerSecond = 120; // High refresh rate
await gameLoop.RunAsync();
Uncapped Frame Rate¶
gameLoop.TargetFramesPerSecond = 0; // No limiting (vsync only)
Use cases: - Benchmarking - Menu screens (don't need 60 FPS) - High-refresh displays (120Hz, 144Hz)
Custom Game Loop¶
You can implement your own:
public class MyCustomGameLoop : IGameLoop
{
public bool IsRunning { get; private set; }
public int TargetFramesPerSecond { get; set; } = 60;
public async Task RunAsync(CancellationToken ct)
{
IsRunning = true;
while (IsRunning && !ct.IsCancellationRequested)
{
// Your custom loop logic
// 1. Input
// 2. Update
// 3. Render
// 4. Frame limit
}
await Task.CompletedTask;
}
public void Stop() => IsRunning = false;
}
// Register
builder.Services.AddSingleton<IGameLoop, MyCustomGameLoop>();
Performance Monitoring¶
Measuring Frame Time¶
protected override void OnUpdate(GameTime gameTime)
{
var deltaTime = gameTime.DeltaTime;
var fps = 1.0 / deltaTime;
Logger.LogDebug("Frame time: {Ms}ms, FPS: {Fps}",
deltaTime * 1000, fps);
}
Profiling Phases¶
var stopwatch = Stopwatch.StartNew();
// Measure update time
var updateStart = stopwatch.Elapsed;
_sceneManager.Update(gameTime);
var updateTime = stopwatch.Elapsed - updateStart;
// Measure render time
var renderStart = stopwatch.Elapsed;
_sceneManager.Render(gameTime);
var renderTime = stopwatch.Elapsed - renderStart;
_logger.LogDebug("Update: {U}ms, Render: {R}ms",
updateTime.TotalMilliseconds,
renderTime.TotalMilliseconds);
Common Patterns¶
Fixed Time Step (Physics)¶
For deterministic physics:
private double _accumulator;
private const double FixedTimeStep = 1.0 / 60.0; // 60 Hz physics
protected override void OnUpdate(GameTime gameTime)
{
_accumulator += gameTime.DeltaTime;
// Run physics at fixed 60 Hz
while (_accumulator >= FixedTimeStep)
{
UpdatePhysics(FixedTimeStep);
_accumulator -= FixedTimeStep;
}
// Render uses variable delta time
UpdateAnimations((float)gameTime.DeltaTime);
}
Why? - Physics needs consistent timestep for stability - Rendering can vary without issues
Interpolation¶
Smooth rendering between physics steps:
var alpha = _accumulator / FixedTimeStep;
var renderPosition = Vector2.Lerp(_previousPosition, _currentPosition, (float)alpha);
Best Practices¶
DO¶
-
Always use delta time for movement/animation
position += velocity * deltaTime; // ✅ Good -
Keep Update and Render separate
OnUpdate(gameTime) { /* Logic only */ } OnRender(gameTime) { /* Drawing only */ } -
Profile performance regularly
var fps = 1.0 / gameTime.DeltaTime; -
Exit gracefully
_gameContext.RequestExit(); // ✅ Clean shutdown
DON'T¶
-
Don't use fixed values
position += velocity; // ❌ Bad - frame-rate dependent -
Don't do expensive work in Update
// ❌ Bad - loads every frame! OnUpdate(gameTime) { var texture = LoadTexture("sprite.png"); } // ✅ Good - load once OnLoadAsync(ct) { _texture = await LoadTextureAsync("sprite.png", ct); } -
Don't render in Update
OnUpdate(gameTime) { _renderer.DrawRectangle(...); // ❌ Wrong phase! } -
Don't block the main thread
Thread.Sleep(1000); // ❌ Freezes entire game!
Troubleshooting¶
Low Frame Rate¶
Symptom: Game runs below target FPS
Causes: - Too many draw calls - Complex physics calculations - Unoptimized collision detection - Large Update logic
Solutions:
1. Profile with Stopwatch to find bottleneck
2. Reduce entity count
3. Use spatial partitioning for collisions
4. Batch draw calls
Stuttering¶
Symptom: Inconsistent frame times
Causes: - Garbage collection spikes - Asset loading during gameplay - VSync disabled - Background processes
Solutions: 1. Enable VSync 2. Pre-load assets 3. Object pooling (reduce GC) 4. Close background apps
Input Lag¶
Symptom: Delayed response to inputs
Causes: - Triple buffering - High frame time - Input polling after rendering
Solutions: 1. Disable triple buffering 2. Optimize Update phase 3. Input is polled first (✅ Brine2D does this)
Summary¶
| Concept | Key Point |
|---|---|
| Loop Structure | Input → Update → Render → Limit |
| Frame Rate | Default 60 FPS (16.67ms/frame) |
| Delta Time | Essential for frame-rate independence |
| Threading | Single-threaded (SDL3 requirement) |
| Phase Budget | Input (1ms), Update (10ms), Render (5ms) |
| Precision | Sleep + SpinWait hybrid timing |
Next Steps¶
- Scene Management - Understand scene lifecycle
- Input System - Master input handling
- Rendering - Optimize draw calls
- Performance - Profile and optimize
Master the game loop and you master game development!