Skip to content

The scheduler

The scheduler is tick-based: 20 ticks are one second, and a scheduled task always runs on the tick thread. There is no separate “async task” concept: for slow work use the thread pool (Scheduler.RunAsync) and get back with Scheduler.Post or RunSync (see threading).

SchedulerDemo.cs
[PluginInfo("SchedulerDemo", Version = "1.0.0")]
public sealed class SchedulerDemoPlugin : Plugin
{
private ScheduledTask? _autosave;
protected override void OnEnable()
{
// once, 5 seconds from now
Scheduler.RunLater(100, () => Server.Broadcast("five seconds later"));
// every minute, first run after 10 ticks
_autosave = Scheduler.RunEvery(20 * 60, () => Log.Info("autosave"), initialDelayTicks: 10);
}
protected override void OnDisable()
{
_autosave?.Cancel(); // optional: unloading cancels it too
}
}
// from another thread: run on the tick thread at the start of the next tick
Scheduler.Post(() => Apply(result));
Member Behaviour
RunLater(delayTicks, action) runs once. The delay is at least 1 tick (a smaller value is raised to 1)
RunEvery(periodTicks, action, initialDelayTicks = 0) repeats. periodTicks must be positive (ArgumentOutOfRangeException otherwise); the first run is after max(1, initialDelayTicks) ticks
Post(action) thread-safe. Runs at the start of the next tick, FIFO, before due scheduled tasks
RunSync(action) / RunSync<T>(func) thread-safe. Runs on the tick thread at the next tick boundary and returns a Task (with the result for RunSync<T>). The task faults if the action throws, and never completes if the plugin unloads first. Its continuations run on the thread pool, so use ToTick() to return to the tick thread
RunAsync(action) / RunAsync<T>(func) runs on the thread pool and returns a Task. It must not touch the game API
ToTick() / ToAsync() awaitables: await Scheduler.ToTick(); resumes on the tick thread at the next tick, await Scheduler.ToAsync(); on a thread-pool thread. A continuation of an unloaded plugin is dropped
Delay(ticks) returns a Task that completes after ticks ticks (at least 1). Its continuations run on the thread pool, as for RunSync; await Scheduler.ToTick() to return
ScheduledTask.Cancel() / Dispose() stop it; IsCancelled tells. Safe to call twice

Every tick, the scheduler runs in this order:

  1. posted actions (those queued before the tick started), oldest first, and the RunSync work with them;
  2. every scheduled task whose due tick has arrived, ordered by due tick and then by creation order.

A repeating task is rescheduled for now + period after it ran, so a slow task drifts later instead of piling up. If an action throws, the exception is logged with the plugin’s name and the task keeps its schedule (a repeating task runs again; a one-shot is finished anyway).

In the plugin worker, scheduled tasks run when the tick’s window opens, before the engine’s own systems of that tick. In-process they ran after the engine’s pipeline. Do not depend on the order between your tasks and engine systems.

Tasks belong to the plugin that created them. When the plugin unloads (or hot reloads) its pending scheduled and posted tasks are discarded, so no delegate from your collectible assembly survives. A repeating task from the old version does not carry over to the new one: re-create it in OnEnable.

  • Counting time: derive durations from tick counts, not wall clocks. A lagging server stretches a tick, and your timers should stretch with it (a 5 second cooldown is 5 * 20 ticks). Server.CurrentTick is the engine’s tick count and is thread-safe, so you can compare it with a stored value.
  • Do not allocate per run in a frequently repeating task: hoist delegates into fields, avoid LINQ and string building (see performance).
  • Heavy work (disk, network) goes to the thread pool and returns with Post or RunSync; do not run it inside a scheduled task.
  • For per-tick work over many entities prefer a system with a query over a scheduled task that loops.