Understanding the Engine
The Altruist Engine is a game loop that provides frame-based execution for physics simulations, AI updates, and other real-time operations that require consistent tick rates.
Enabling the Engine
Configure the engine in config.yml:
altruist:
game:
engine:
diagnostics: true # Enable performance logging
framerateHz: 30 # 30 ticks per second
unit: "hz"
gravity: { x: 0, y: -9.81 }
Note:
When the engine is enabled, server-side game logic runs at a consistent rate regardless of client activity.
Engine configuration keys
| Key | Default | Meaning |
|---|---|---|
altruist:game:engine:framerateHz | 30 | Engine frames per second. frequency is accepted as an alias. |
altruist:game:engine:unit | ticks | Unit of the engine rate (reported by IAltruistEngine.Rate). |
altruist:game:engine:diagnostics | โ | true: the engine logs task throughput. false: no diagnostics. Both offer the same API, including SyncCommit. |
altruist:game:engine:throttle | derived | Advanced: busy-wait budget in nanoseconds. |
altruist:game:engine:world-step | worker | Where the world step runs: worker (a separate world-worker task) or inline (last phase of every engine frame, on the engine thread). See The World Step. |
altruist:game:engine:fixed-step:max-frame-delta | 0.25 | Seconds: a frame never adds more than this to a fixed-rate stepper's accumulator. |
altruist:game:engine:fixed-step:max-steps-per-frame | 8 | Most fixed steps a stepper runs in one frame. |
altruist:game:engine:fixed-step:overrun | drop | What happens when a frame hits the step cap: drop, carry or slow-motion. |
The engine is created only when the altruist:game:engine section exists.
Engine Lifecycle
Every service is constructed once per boot, on the root service provider, and the engine runs on exactly one loop:
- Services are registered (configuration classes only register, they never build a provider of their own).
- The root provider is built and every service's
[PostConstruct]runs. MethodSchedulerregisters all[Cycle]methods on the engine โ on the same instances whose[PostConstruct]ran, so state set up there is visible in your[Cycle]methods.ServerStatusstarts the engine as soon as everyIConnectableservice (database, cache, ...) reports healthy.
The startup log shows a single ๐ Starting engine... line and the scheduled methods:
๐ Starting engine...
โกโก [ENGINE 120Hz, world-step worker] Unleashed โ powerful, fast, and breaking speed limits!
๐ Scheduled methods:
โณ MyGame.GameLoop.Tick (โก every 1 frame(s))
[Cycle] methods registered after the engine started (for example by a [PostConstruct] that runs late) are picked up on the next frame.
The Frame
Each engine frame runs these steps in order, one after another (never in parallel):
- Next-tick queue โ delegates handed to
WaitForNextTick/SyncCommit/RunOffTicksince the last frame. [Cycle]tasks โ the methods that are due this frame. An async[Cycle]method never overlaps itself: while its previous run is still in flight, it is skipped.- Dynamic tasks sent with
SendTask. - Effects and timers โ
ScheduleEffect,ScheduleOnce,ScheduleAtFrame, in the order they were scheduled. - World step โ with
world-step: inline, the world coordinator steps every registered world stepper here. Withworld-step: worker(the default) the frame only hands its time to the world worker, which steps on its own task.
Which thread runs the frame depends on the world-step mode:
inline: the whole frame runs on the engine thread (EngineThread), next-tick delegates included; an async next-tick delegate is awaited in place before the next one starts.worker(default): the threading of 0.9.7 and earlier is kept โ after the timer wait the frame continues on a thread-pool thread, and async next-tick delegates are awaited asynchronously, in order.
Note:
An async delegate or [Cycle] method continues wherever its await resumes (usually the thread pool). In inline mode everything that runs synchronously inside the frame runs on the engine thread.
The World Step
The engine has one world-step call per frame: IGameWorldOrganizer.Step(dt). Its default implementation is the WorldCoordinator, a composite that steps every service registered as an IWorldStepper, in registration order:
- variable-rate steppers (
StepMode.Variable) getStep(dt)once per frame with the real frame time โ the built-in 2D and 3D world organizers are registered this way; - fixed-rate steppers (
StepMode.Fixed) getFixedStep(step)zero or more times per frame, always with the samedt = 1 / FixedHz. Each fixed stepper has its own fixed-step clock, so steppers with different rates (a 60 Hz match simulation, a 25 Hz force runtime) run side by side.
Every stepper also gets BeforeSteps(frame) and AfterSteps(frame) once per frame โ frame.FixedSteps already tells how many fixed steps follow, frame.Dt is the (clamped) time the frame represents. A stepper that throws is logged; the others keep running.
using Altruist.Gaming;
// A 60 Hz authoritative simulation that the engine steps.
[Service]
[Service(typeof(IWorldStepper))]
public class MatchSimulation : IWorldStepper
{
public StepMode Mode => StepMode.Fixed;
public int FixedHz => 60;
public void BeforeSteps(in FrameInfo frame) { /* frame.FixedSteps steps follow */ }
public void FixedStep(in FixedStep step)
{
// Exactly 1/60 s (step.Dt) every time; step.Step counts 1, 2, 3, ...
Advance(step.Dt);
if (step.Step % 2 == 0) SendSnapshots(); // 30 Hz snapshots
}
public void AfterSteps(in FrameInfo frame) { /* timers, matchmaking, bookkeeping */ }
}
The fixed-step clock
For each fixed stepper the coordinator keeps an accumulator (FixedStepClock, double precision):
- the frame time is clamped to
max-frame-delta(default 0.25 s) and added to the accumulator; - every whole step in it is one
FixedStep, at mostmax-steps-per-frame(default 8) per frame; - when a frame hits the cap, the
overrunpolicy decides what happens to the rest.
overrun | When a frame needs more than max-steps-per-frame steps |
|---|---|
drop (default) | The time left over is discarded; the accumulator restarts at 0. |
carry | The backlog is kept and stepped in the next frames (bounded by max-frame-delta), so simulated time catches up with real time. |
slow-motion | A frame never adds more than max-steps-per-frame steps of time: an overloaded server runs slower than real time without a backlog, and the sub-step phase is kept. |
The engine rate is only the sampling rate: with framerateHz: 120 and FixedHz: 60, a stepper runs 0 or 1 step per frame, 60 steps per second. The same frame times always give the same steps, so a fixed stepper's results depend only on its inputs.
altruist:
game:
engine:
framerateHz: 120 # sampling rate, >= the fastest fixed stepper
world-step: inline # commands, cycles, timers and world steps on one thread
fixed-step:
max-frame-delta: 0.25
max-steps-per-frame: 8
overrun: drop # drop | carry | slow-motion
Inline or worker
world-step: worker (default) | world-step: inline | |
|---|---|---|
| Where the world steps | a separate world-worker task | the engine thread, after the effects of each frame |
| Frame time | the frame times since the worker's last step, summed | the frame's own time |
Shared state with [Cycle] / WaitForNextTick code | needs locking (two threads) | no locking: one thread, fixed order |
| Thread of the frame itself | a thread-pool thread after each timer wait (as before 0.9.8) | the engine thread |
Choose inline when steppers and your commands touch the same state โ for example an authoritative match server whose socket handlers post inputs with WaitForNextTick and whose simulation is a fixed-rate stepper. Keep worker when a slow world step must not delay the rest of the frame. In inline mode keep next-tick delegates and [Cycle] methods short, and move I/O to RunOffTick.
Replacing the coordinator
Register your own [Service(typeof(IGameWorldOrganizer))] and the engine calls it instead of the WorldCoordinator (which is registered only while no other class provides IGameWorldOrganizer, via [ConditionalOnMissingService]). Your organizer then gets the variable frame time, as before 0.9.8. A stepper may depend on IAltruistEngine: the coordinator resolves its steppers on the first step.
Multi-core steps
A stepper that runs many independent units โ the rooms of a room host, for example โ can step them on several cores with the IStepScheduler service:
altruist:
game:
engine:
step-workers: auto # 1 (default: everything on the engine thread), a number, or auto (one per core)
public sealed class Shards(IStepScheduler scheduler) : IWorldStepper
{
private readonly List<Shard> _shards = new();
public StepMode Mode => StepMode.Fixed;
public int FixedHz => 60;
public void FixedStep(in FixedStep step) =>
scheduler.ForEach(_shards, s => s.Step()); // returns when every shard stepped
}
ForEach runs every item exactly once per call, at most step-workers at a time; every item runs even when others throw, and the failures are thrown together afterwards (an AggregateException the coordinator logs). Each unit is stepped by one thread per call, so its own simulation stays single-threaded and deterministic; whatever units share must be thread-safe. A unit may run on a different thread in the next step โ independent 2D physics worlds support that.
Engine metrics
The coordinator publishes the Altruist.Engine meter: altruist.engine.frame.duration (ms per frame), altruist.engine.stepper.duration (ms per stepper and frame, tagged stepper), altruist.engine.fixed_steps and altruist.engine.overruns (frames that hit max-steps-per-frame). See Capacity, Drain & Health for exporting them.
Scheduling Tasks with [Cycle]
The [Cycle] attribute allows methods to execute on the engine loop:
Run Every Frame
[Cycle]
public void Tick()
{
// Runs every engine frame (e.g., 120 times per second at framerateHz: 120)
}
[Cycle] and [Cycle(1)] both run every frame, whatever the engine unit.
Note:
Before 0.9.8 a plain [Cycle] ran only once every framerateHz frames (once per second) with unit: ticks. If you relied on that, use [Cycle(1, CycleUnit.Hz)].
Rate from configuration
[Cycle(Config = "mygame:matchmaking:tick-hz", Default = 10, Unit = CycleUnit.Hz)]
public void RunMatchmaking()
{
// mygame:matchmaking:tick-hz times per second (10 when the key is missing)
}
The key is read when the method is registered. A missing, empty or non-positive value uses Default; without a default the method runs every frame. Unit is CycleUnit.Ticks (frames) unless you set it.
Run at a Custom Frequency
[Cycle(120, CycleUnit.Seconds)]
public async Task RegenerateHealth()
{
// Runs 120 times per second
}
Run on a Cron Schedule
[Cycle("*/1 * * * *")]
public async Task SaveGameState()
{
// Runs every 1 minute
}
Cycle units
| Attribute | Runs |
|---|---|
[Cycle(n)] / CycleUnit.Ticks | every n engine frames ([Cycle(1)] = every frame, [Cycle(2)] = every second frame) |
[Cycle(n, CycleUnit.Hz)] | n times per second (at most the engine frame rate) |
[Cycle(n, CycleUnit.Seconds)] | n times per second |
[Cycle(n, CycleUnit.Milliseconds)] | n times per millisecond, capped by the engine frame rate |
CycleRate.Frequency returns the number you passed; CycleRate.Value is the internal period (frames for Ticks, TimeSpan ticks for Seconds and Milliseconds). Time-based cycles are checked once per frame, so they never run more often than the engine frame rate; their cadence follows the configured interval rather than the frame grid (a 30 Hz cycle on a 120 Hz engine runs every 4th frame on average, not every 5th), and after a stall it resumes without a burst of catch-up runs.
Running Code on the Game Loop
Socket handlers, HTTP controllers and background tasks run on other threads. Hand work to the game loop instead of locking shared state:
[Portal("/game")]
public class GamePortal : Portal
{
private readonly IAltruistEngine _engine;
private readonly GameState _state;
public GamePortal(IAltruistEngine engine, GameState state)
{
_engine = engine;
_state = state;
}
[Gate("move")]
public Task OnMove(MovePacket packet, string clientId)
{
// Runs at the start of the next frame, before the [Cycle] tasks.
_engine.WaitForNextTick(() => _state.QueueMove(clientId, packet));
return Task.CompletedTask;
}
}
SyncCommit does the same and returns a result, which makes it the tool for queries from outside the loop โ for example a REST endpoint that reads live game state:
[HttpGet("api/match/{playerId}")]
public async Task<IActionResult> Get(string playerId)
{
try
{
// Computed on the game loop (next frame); the await resumes off the loop.
var status = await _engine.SyncCommit(() => _state.StatusOf(playerId))
.WaitAsync(TimeSpan.FromSeconds(2));
return Ok(status);
}
catch (TimeoutException)
{
return StatusCode(503);
}
}
SyncCommit works with both engine variants (diagnostics: true and false). If the commit throws, the returned task faults with that exception.
Note:
Keep next-tick delegates short: they run inside the frame. Do database or network work off the loop with RunOffTick and handle only the result on the loop.
Off-loop work with a result: RunOffTick
// The write runs on the thread pool; the callback runs on the game loop (next-tick queue).
_engine.RunOffTick(
ct => _results.SaveMatchAsync(match, ct),
(rewards, error) =>
{
if (error is not null) _logger.LogError(error, "saving {Match} failed", match.Id);
_state.OnMatchSaved(match.Id, error is null ? rewards : null);
});
RunOffTick(Func<CancellationToken, Task> work, Action<Exception?> onTick) does the same for work without a result.
One-shot Timers
var id = _engine.ScheduleOnce(TimeSpan.FromSeconds(2), () => _state.RefillWithBot(seat));
_engine.CancelEffect(id); // e.g. the player came back
_engine.ScheduleAtFrame(_engine.Frame + 120, () => _state.EndWarmup()); // 120 frames from now
Timers run once, in the effect phase of the frame in which they are due, in the order they were scheduled. ScheduleOnce measures time on the engine clock, the same clock as the frame times and ScheduleEffect (whose UTC expiry is converted to engine time when it is scheduled). A frame number that has already passed fires on the next frame.
Exceptions Never Stop the Loop
A throwing next-tick delegate, [Cycle] method, effect or world step is logged and the engine keeps running:
- the delegates queued after a throwing next-tick delegate still run in the same frame;
- a
[Cycle]method that throws runs again when it is next due; - an effect or timer that throws is removed;
- a world stepper that throws is logged and the other steppers still step;
- repeats of the same failure are summarized in the log at most every 10 seconds.
Programmatic Task Scheduling
You can also schedule tasks programmatically via IAltruistEngine:
engine.ScheduleTask(MyUpdateMethod, new CycleRate(60, CycleUnit.Seconds)); // 60 times per second
engine.ScheduleTask(MyFrameMethod, new CycleRate(1, CycleUnit.Ticks)); // every frame
engine.ScheduleTask(MyOtherFrameMethod); // no rate = every frame
engine.RegisterCronJob(MySaveMethod, "0 * * * *"); // every hour
Testing with a Manual Clock
EngineTestDriver runs engine frames by hand on an engine that was never started: no thread, no timer, time from a ManualEngineClock. Each frame runs the full pipeline (next-tick queue โ cycles โ dynamic tasks โ effects โ world step), so a test controls exactly which frames happen:
var clock = new ManualEngineClock();
var coordinator = new WorldCoordinator(new IWorldStepper[] { simulation });
var engine = new AltruistEngine(serverStatus, services, coordinator,
framerateHz: 120, worldStep: "inline", clock: clock);
var driver = new EngineTestDriver(engine, clock);
driver.Post(() => simulation.Join("alice")); // = WaitForNextTick
driver.AdvanceFrames(120, 1.0 / 120); // one second: 60 fixed steps at 60 Hz
driver.AdvanceSeconds(6); // timers due within 6 s have fired
A WorldCoordinator can also be stepped directly (coordinator.Step(dt)) to test steppers without an engine.
Cron Expressions
| Expression | Frequency |
|---|---|
"*/1 * * * *" | Every 1 minute |
"0 * * * *" | Every hour |
"0 0 * * *" | Once per day |
"0 0 * * 0" | Once per week |
World Configuration
The engine manages game worlds defined in config:
altruist:
environment:
mode: "3D" # or "2D"
game:
worlds:
partitioner: { width: 256, height: 256, depth: 256 }
items:
- index: 0
id: "main-world"
size: { x: 1000, y: 1000, z: 1000 }
gravity: { x: 0, y: -9.81, z: 0 }
data-path: "Resources/world.json"
Each world is spatially partitioned for efficient broadcasting and collision detection.
Engine + Resilient Services
The engine will not start until all IConnectable services report healthy. If a service goes down, the engine pauses automatically and resumes when all services recover โ the [Cycle] methods and the world step continue where they left off.
Built-in steppers: the 2D/3D world organizers (variable rate, when altruist:environment:mode is set) and ForceRuntime2D / ForceRuntime3D (fixed 25 Hz, ApplySustained forces).
Note:
Higher frame rates improve responsiveness but increase CPU usage. Choose a rate that balances performance and smoothness for your game.