Table of Contents

Runtime

The KeenEyes.Runtime package provides application runtime and main loop management through a fluent builder pattern.

Overview

The WorldRunnerBuilder abstracts main loop setup, enabling backend-agnostic game loops. This means you can swap rendering backends (Silk.NET, SDL, etc.) without rewriting your loop setup code.

Installation

Reference KeenEyes.Runtime in your project:

<PackageReference Include="KeenEyes.Runtime" />

Or add a project reference:

<ProjectReference Include="..\..\src\KeenEyes.Runtime\KeenEyes.Runtime.csproj" />

Basic Usage

using KeenEyes;
using KeenEyes.Runtime;
using KeenEyes.Graphics.Silk;

using var world = new World();
world.InstallPlugin(new SilkGraphicsPlugin(config));

// Simple usage - auto-calls world.Update() each frame
world.CreateRunner()
    .OnReady(() => CreateScene(world))
    .Run();

ILoopProvider Interface

The WorldRunnerBuilder works with any plugin that implements ILoopProvider:

public interface ILoopProvider
{
    event Action? OnReady;           // Once when ready
    event Action<float>? OnUpdate;   // Every frame
    event Action<float>? OnRender;   // Every frame
    event Action<int, int>? OnResize; // When resized
    event Action? OnClosing;         // When closing

    void Initialize();
    void Run();
    bool IsInitialized { get; }
}

Currently, IGraphicsContext extends ILoopProvider, so any graphics plugin (like SilkGraphicsPlugin) automatically provides this interface.

Builder Methods

OnReady

Called once when the loop is ready (window created, graphics context available):

world.CreateRunner()
    .OnReady(() =>
    {
        var graphics = world.GetExtension<IGraphicsContext>();

        // Create resources
        var mesh = graphics.CreateCube();

        // Set up scene
        world.Spawn()
            .With(Transform3D.Identity)
            .With(new Renderable(mesh.Id, 0))
            .Build();
    })
    .Run();

OnUpdate

Called every frame for update logic. If not set, world.Update(dt) is called automatically.

// Auto-update (recommended for most cases)
world.CreateRunner()
    .OnReady(() => CreateScene())
    .Run();  // world.Update() called automatically

// Explicit control (for custom logic)
world.CreateRunner()
    .OnReady(() => CreateScene())
    .OnUpdate((dt) =>
    {
        // Pre-update logic
        ProcessInput();

        // Manual world update
        world.Update(dt);

        // Post-update logic
        UpdateUI();
    })
    .Run();

OnRender

Called every frame for additional rendering (beyond what systems do):

world.CreateRunner()
    .OnReady(() => CreateScene())
    .OnRender((dt) =>
    {
        // Additional rendering after systems
        RenderDebugOverlay();
    })
    .Run();

OnResize

Called when the window/viewport is resized:

world.CreateRunner()
    .OnReady(() => CreateScene())
    .OnResize((width, height) =>
    {
        Console.WriteLine($"Window resized to {width}x{height}");
        // CameraSystem handles aspect ratio updates automatically
    })
    .Run();

OnClosing

Called when the loop is closing:

world.CreateRunner()
    .OnReady(() => CreateScene())
    .OnClosing(() =>
    {
        Console.WriteLine("Goodbye!");
        // World disposal is handled by 'using' statement
    })
    .Run();

Complete Example

using System.Numerics;
using KeenEyes;
using KeenEyes.Common;
using KeenEyes.Graphics;
using KeenEyes.Graphics.Abstractions;
using KeenEyes.Graphics.Silk;
using KeenEyes.Runtime;

// Configure graphics
var config = new SilkGraphicsConfig
{
    WindowTitle = "WorldRunnerBuilder Demo",
    WindowWidth = 1280,
    WindowHeight = 720,
    VSync = true
};

// Create world
using var world = new World();
world.InstallPlugin(new SilkGraphicsPlugin(config));

// Add systems
world.AddSystem<CameraSystem>(SystemPhase.EarlyUpdate);
world.AddSystem<RenderSystem>(SystemPhase.Render);

// Run with builder
world.CreateRunner()
    .OnReady(() =>
    {
        Console.WriteLine("Graphics initialized!");

        var graphics = world.GetExtension<IGraphicsContext>();

        // Camera
        world.Spawn()
            .With(new Transform3D(new Vector3(0, 2, 5), Quaternion.Identity, Vector3.One))
            .With(Camera.CreatePerspective(60f, 16f/9f, 0.1f, 100f))
            .WithTag<MainCameraTag>()
            .Build();

        // Light
        world.Spawn()
            .With(Transform3D.Identity)
            .With(Light.Directional(Vector3.One, 1f))
            .Build();

        // Cube
        var cube = graphics.CreateCube();
        world.Spawn()
            .With(Transform3D.Identity)
            .With(new Renderable(cube.Id, 0))
            .With(new Material
            {
                ShaderId = graphics.LitShader.Id,
                TextureId = graphics.WhiteTexture.Id,
                Color = Vector4.One
            })
            .Build();

        Console.WriteLine("Scene created!");
    })
    .OnResize((w, h) =>
    {
        Console.WriteLine($"Resized: {w}x{h}");
    })
    .OnClosing(() =>
    {
        Console.WriteLine("Closing...");
    })
    .Run();

Console.WriteLine("Application ended.");

Threading: call Run() on the process main thread

Run() is what creates the OS window, and macOS only allows that on the process main thread — AppKit terminates the process with NSWindow should only be instantiated on the main thread! rather than raising a catchable error. The trap is an await anywhere before Run(): in an async Main, everything after the first await resumes on a thread-pool thread, so the window ends up being created there. Windows and Linux accept it, which is why this shows up only once someone runs your game on a Mac. Do startup work synchronously before Run() (block with GetAwaiter().GetResult() if it is async), or move it into OnReady/after Run() returns — awaiting is fine there, because the window already exists. The Silk loop provider checks the calling thread on macOS immediately before window creation and throws an InvalidOperationException naming this cause, so the failure is a managed exception instead of a process abort.

// Wrong on macOS: the continuation - including Run() - resumes off the main thread.
await bridgeServer.StartAsync();
world.CreateRunner().Run();

// Right: startup stays synchronous, so Run() still runs where the process started.
bridgeServer.StartAsync().GetAwaiter().GetResult();
world.CreateRunner().Run();
await bridgeServer.StopAsync();   // after Run() returns, awaiting is safe

Error Handling

If no ILoopProvider is registered, CreateRunner() throws:

try
{
    world.CreateRunner().Run();
}
catch (InvalidOperationException ex)
{
    // "No ILoopProvider found. Install a plugin that provides a main loop..."
    Console.WriteLine(ex.Message);
}

Benefits

Benefit Description
Backend-agnostic Same code works with any ILoopProvider
Less boilerplate Auto world.Update() by default
Explicit when needed OnUpdate callback for custom control
Consistent style Matches EntityBuilder, QueryBuilder patterns
Testable Can mock ILoopProvider for testing

Comparison

Before (backend-specific)

var graphics = world.GetExtension<IGraphicsContext>();

graphics.OnReady += () => CreateScene();
graphics.OnUpdate += (dt) => world.Update(dt);
graphics.OnRender += (dt) => { };
graphics.OnResize += (w, h) => Console.WriteLine($"Resized: {w}x{h}");
graphics.OnClosing += () => Console.WriteLine("Closing...");

graphics.Initialize();
graphics.Run();

After (backend-agnostic)

world.CreateRunner()
    .OnReady(() => CreateScene())
    .OnResize((w, h) => Console.WriteLine($"Resized: {w}x{h}"))
    .OnClosing(() => Console.WriteLine("Closing..."))
    .Run();  // Auto-calls world.Update()

Custom Loop Providers

To create a custom backend, implement ILoopProvider:

public class MyCustomLoopProvider : ILoopProvider
{
    public event Action? OnReady;
    public event Action<float>? OnUpdate;
    public event Action<float>? OnRender;
    public event Action<int, int>? OnResize;
    public event Action? OnClosing;

    public bool IsInitialized { get; private set; }

    public void Initialize()
    {
        // Set up your loop infrastructure
        IsInitialized = true;
        OnReady?.Invoke();
    }

    public void Run()
    {
        while (/* running */)
        {
            float dt = /* calculate delta time */;
            OnUpdate?.Invoke(dt);
            OnRender?.Invoke(dt);
        }
        OnClosing?.Invoke();
    }
}

// Register in a plugin
public class MyPlugin : IWorldPlugin
{
    public string Name => "MyPlugin";

    public void Install(IPluginContext context)
    {
        var provider = new MyCustomLoopProvider();
        context.SetExtension<ILoopProvider>(provider);
    }

    public void Uninstall(IPluginContext context)
    {
        context.RemoveExtension<ILoopProvider>();
    }
}