What is LogicLooper?
masterTask-based driving mechanisms.repository·master·Indexed 42 days ago
https://github.com/cysharp/logiclooperA .NET library for building high-performance server applications, such as game servers, using a loop-action programming model. It provides tools for aggregating logic updates within a controlled frame rate via LogicLooper, multi-core distribution with LogicLooperPool, and manual frame execution for unit testing with ManualLogicLooper. Features include coroutine support via ctx.RunCoroutine, target frame rate overrides, and diagnostics via the LogicLooper.Diagnostics package.
Task-based driving mechanisms.For multi-core servers, use LogicLooperPool to manage multiple LogicLooper instances across several threads. This allows you to scale processing by hosting multiple loops. You interact with the pool via its API rather than managing individual loopers. You can specify a balancer, such as RoundRobinLogicLooperPoolBalancer.Instance.
using Cysharp.Threading;
const int targetFps = 60;
// Use the number of available processor cores for the pool size
var looperCount = Environment.ProcessorCount;
using var looperPool = new LogicLooperPool(targetFps, looperCount, RoundRobinLogicLooperPoolBalancer.Instance);
// Register an action to the pool
await looperPool.RegisterActionAsync((in LogicLooperActionContext ctx) =>
{
if (/* condition to stop */) { return false; }
// Perform per-frame logic here...
return true;
});LogicLooper supports a coroutine pattern similar to Unity. You can start a coroutine within a loop action using ctx.RunCoroutine.
Important Constraints:
await the following methods: DelayFrame, DelayNextFrame, and Delay.Task or other Task-like objects inside a coroutine will throw an exception.IsCompleted property on the LogicLooperCoroutine object.using var looper = new LogicLooper(60);
var coroutine = default(LogicLooperCoroutine);
await looper.RegisterActionAsync((in LogicLooperActionContext ctx) =>
{
if (/* condition to start */)
{
coroutine = ctx.RunCoroutine(async coCtx =>
{
await coCtx.DelayFrame(60); // Wait 60 frames
// ... logic ...
await coCtx.DelayNextFrame(); // Wait for next frame
// ... logic ...
await coCtx.Delay(TimeSpan.FromMilliseconds(16.66666)); // Wait ~16ms
});
}
if (coroutine != null && coroutine.IsCompleted)
{
// Handle coroutine completion
}
return true;
});A LogicLooper binds to a single thread and runs a main loop at a specified target FPS. You can register multiple loop actions to the same looper, similar to multiple Update methods in a game engine.
To stop the loop for a specific action, return false from the action delegate. To continue to the next frame, return true.
using Cysharp.Threading;
// Create a looper.
const int targetFps = 60;
using var looper = new LogicLooper(targetFps);
// Register a action to the looper and wait for completion.
await looper.RegisterActionAsync((in LogicLooperActionContext ctx) =>
{
// If you want to stop/complete the loop, return false to stop.
if (...) { return false; }
// some stuff to do ...
return true; // wait for a next update.
});You can override the target frame rate for individual actions using LooperActionOptions. This is useful for running certain tasks (like low-frequency updates) at a different frequency than the main loop.
Note: The actual execution granularity depends on the main loop's frequency; the override may be less precise than the requested rate.
using var looper = new LogicLooper(60); // Main loop at 60 fps
// This action runs at 60 fps
await looper.RegisterActionAsync((in LogicLooperActionContext ctx) =>
{
return true;
});
// This action runs at 10 fps
await looper.RegisterActionAsync((in LogicLooperActionContext ctx) =>
{
return true;
}, LooperActionOptions.Default with { TargetFrameRateOverride = 10 });For multi-core servers, LogicLooperPool provides multiple loopers to distribute workload efficiently. You can specify the number of loopers (e.g., matching Environment.ProcessorCount) and a balancer (e.g., RoundRobinLogicLooperPoolBalancer.Instance) to manage action distribution.
using Cysharp.Threading;
// Create a looper pool.
// If your machine has 4-cores, the LooperPool creates 4-Looper instances.
const int targetFps = 60;
var looperCount = Environment.ProcessorCount;
using var looperPool = new LogicLooperPool(targetFps, looperCount, RoundRobinLogicLooperPoolBalancer.Instance);
// Register a action to the looper and wait for completion.
await looperPool.RegisterActionAsync((in LogicLooperActionContext ctx) =>
{
// If you want to stop/complete the loop, return false to stop.
if (...) { return false; }
// some stuff to do ...
return true; // wait for a next update.
});For unit testing or manual frame updates, use ManualLogicLooper or ManualLogicLooperPool. Calling .Tick() manually advances the frame, and ElapsedTimeFromPreviousFrame will be fixed based on the provided target frame rate.
var looper = new ManualLogicLooper(60.0); // `ElapsedTimeFromPreviousFrame` will be fixed to `1000 / FrameTargetFrameRate`.
var count = 0;
var t1 = looper.RegisterActionAsync((in LogicLooperActionContext ctx) =>
{
count++;
return count != 3;
});
looper.Tick(); // Update frame
Console.WriteLine(count); // => 1
looper.Tick(); // Update frame
Console.WriteLine(count); // => 2
looper.Tick(); // Update frame (t1 will be completed)
Console.WriteLine(count); // => 3
looper.Tick(); // Update frame (no action)
Console.WriteLine(count); // => 3LogicLooper supports coroutine-like operations via ctx.RunCoroutine. Inside a coroutine, you can await specific delay methods.
Important: Do NOT await standard Task or Task-like objects inside a coroutine; doing so will cause the coroutine to throw an exception. Instead, use the provided delay methods.
using var looper = new LogicLooper(60);
var coroutine = default(LogicLooperCoroutine);
await looper.RegisterActionAsync((in LogicLooperActionContext ctx) =>
{
if (/* ... */)
{
// Launch a coroutine in the looper that same as the loop action.
coroutine = ctx.RunCoroutine(async coCtx =>
{
// NOTE: `DelayFrame`, `DelayNextFrame`, `Delay` methods are allowed and awaitable in the coroutine.
// If you await a Task or Task-like, the coroutine throws an exception.
await coCtx.DelayFrame(60);
// some stuff to do ...
await coCtx.DelayNextFrame();
// some stuff to do ...
await coCtx.Delay(TimeSpan.FromMilliseconds(16.66666));
});
}
if (coroutine.IsCompleted)
{
// When the coroutine has completed, you can do some stuff ...
}
return true;
});You can install LogicLooper via NuGet using PowerShell or the dotnet CLI.
PS> Install-Package LogicLooper$ dotnet add package LogicLooperYou can use LooperActionOptions.Default with { TargetFrameRateOverride = N } to specify a different execution frequency for a particular action. This allows mixing high-frequency and low-frequency tasks (e.g., a 60fps main loop with a 5fps background task) within the same looper.
Note: The actual granularity of execution depends on the main loop's frequency, so accuracy may be slightly lower than the requested override.
using var looper = new LogicLooper(60); // 60 fps
await looper.RegisterActionAsync((in LogicLooperActionContext ctx) =>
{
// Something to do ...
return true;
}); // The action will be called at 60fps.
await looper.RegisterActionAsync((in LogicLooperActionContext ctx) =>
{
// Something to do (low priority) ...
return true;
}, LooperActionOptions.Default with { TargetFrameRateOverride = 10 }); // The action will be called at 10fps.To collect metrics such as loop execution time and running action counts, install the LogicLooper.Diagnostics package and call AddLogicLooperMetrics in your service configuration.
services.AddLogicLooperMetrics();For unit testing or manual frame-by-frame execution, use ManualLogicLooper or ManualLogicLooperPool. Instead of running on a background thread, you manually advance the loop by calling .Tick().
var looper = new ManualLogicLooper(60.0); // ElapsedTimeFromPreviousFrame is fixed to 1000 / FrameTargetFrameRate
var count = 0;
var t1 = looper.RegisterActionAsync((in LogicLooperActionContext ctx) =>
{
count++;
return count != 3;
});
looper.Tick(); // Update frame -> count is 1
looper.Tick(); // Update frame -> count is 2
looper.Tick(); // Update frame -> count is 3 (t1 completes)
looper.Tick(); // Update frame (no actions to run) -> count is 3