Skip to content

The threading model

All plugin code runs on one thread, the tick thread, and the game-facing API must be called from it. Event handlers, command handlers, systems, scheduled tasks, OnEnable and OnDisable are all invoked there. There is no per-plugin thread and no lock to take: while your handler runs, nothing else in your plugin runs.

Plugins run in the plugin worker process, and the server drives that process one tick at a time. The worker’s tick thread runs your code inside those ticks, so the rule is the same as in a single-threaded game loop.

Server Plugin worker process
IO threads recv / decrypt / compress / write (no network, no world access)
Tick thread 1. drain inbound packets
2. engine systems and events ◀───▶ your handlers, tasks and systems
3. build and flush outbound packets
Worker pool chunk load + generation, lighting

The consequences are the same as in any single-threaded game loop:

  • A slow handler delays the tick for every player. A tick has 50 ms, and the engine’s own work uses most of that on a busy server. Keep handlers in the microsecond range. The worker’s budgets, described below, bound the wait for your answers, but they do not make slow code free.
  • Never block the tick thread: no Thread.Sleep, no Task.Wait() / .Result, no synchronous HTTP or disk reads of unbounded size.
  • No synchronisation is needed for state that only your handlers touch: they never run concurrently.

Most events are only observed, and the server does not wait for your answer. An awaited event is one your handler can change: a cancellable event, or one with fields you can write. For those the server waits for your handler’s answer:

  • The wait is at most scripting.ipc.event_budget_us (5 ms by default). When it runs out, the event goes ahead with its default: it is not cancelled and the fields keep their values. The engine logs a warning that names the slow plugin.
  • Repeated overruns mute the plugin’s event handlers until it is reloaded.
  • The whole tick has its own budget, scripting.ipc.tick_budget_us (20 ms by default). Once it is spent, awaited events take their default without a round trip.
  • The first time an event type reaches your plugin, the server may wait longer (scripting.ipc.warmup_budget_us), because your code is loaded and compiled then.

The thread-safe members are the ones you can call from any thread:

  • Scheduler.Post, Scheduler.RunSync, Scheduler.RunAsync and the awaitables Scheduler.ToTick() and Scheduler.ToAsync().
  • Log. Logging from any thread is fine.
  • Server.IsTickThread (true only on the tick thread) and Server.CurrentTick (the engine’s tick count).
  • Http.Client, the shared HttpClient for outgoing requests. Requests may be started from any thread.
  • Config<T>.Value: the current values are an immutable snapshot, replaced as a whole on reload.
  • Completing a login deferral (see below).

Everything else (Events, Commands, Ecs, Entity, Server, Systems, Players) must be called on the tick thread.

Scheduler.Post queues an action to run on the tick thread at the start of the next tick. Use it to apply the result of slow work started elsewhere:

ProfilePlugin.cs
[PluginInfo("ProfileDemo", Version = "1.0.0")]
public sealed class ProfilePlugin : Plugin
{
protected override void OnEnable()
{
Events.On((ref PlayerJoinEvent e) =>
{
string name = e.Username.ToString(); // copy: the string is only valid during the dispatch
Player player = e.Player;
_ = Task.Run(async () =>
{
string profile = await Http.Client.GetStringAsync($"https://example.org/profile/{name}");
Scheduler.Post(() => // back on the tick thread
{
if (player.IsOnline) Log.Info($"loaded profile of {name}: {profile.Length} bytes");
});
});
});
}
}

Rules of thumb for async work:

  1. Copy everything you need before leaving the tick thread: strings (Utf8String.ToString()), numbers, the Player or Entity handle. Event structs and their borrowed fields are invalid after the handler returns.
  2. Re-validate on return: the player may have left (Player.IsOnline), the entity may be dead (Entity.IsAlive).
  3. Do not touch the game API from Task.Run, async continuations, System.Threading.Timer or FileSystemWatcher callbacks. An await resumes on the thread pool unless you hop back: use Scheduler.Post, Scheduler.RunSync or await Scheduler.ToTick().
  4. Avoid async void handlers: an exception on a pool thread has no plugin to be attributed to.

Inside an async method you can switch threads with awaitables:

private async Task SaveAsync(string path, string text)
{
await Scheduler.ToAsync(); // leave the tick thread before blocking work
await File.WriteAllTextAsync(path, text);
await Scheduler.ToTick(); // back on the tick thread at the next tick
Log.Info($"saved {path}");
}
  • Scheduler.RunAsync(Action) and RunAsync<T>(Func<T>) run work on the thread pool and return a Task. The work must not touch the game API.
  • Scheduler.RunSync(Action) and RunSync<T>(Func<T>) queue work for the tick thread and return a Task that completes after it ran (FIFO, next tick boundary).
  • A continuation waiting in ToTick() is owned by the plugin: if the plugin unloads first, it is dropped.

If a posted task or scheduled callback throws, the exception is logged with your plugin’s name and the server carries on. A task posted by a plugin that has unloaded in the meantime is dropped.

Game-facing members check the thread they are called on. Calling one from another thread throws WrongThreadException, for example Events.On, Commands.Register, Ecs.RegisterComponent, Entity.Get and Plugins.All. Use Server.IsTickThread when you need to know which thread you are on.

Not every call is checked. In the worker, a host call from another thread that the API does not check is refused: it returns the default value and writes one line to the log. Do not rely on either behaviour; keep the rule.

PlayerPreLoginEvent is the one event you can answer later. Call Defer() on the tick thread to hold the login, then complete the returned LoginDeferral from any thread, for example after a database lookup started with Scheduler.RunAsync. Completing a deferral is thread-safe and happens once. The server waits up to 30 seconds for the answer.

Systems you register run on the tick thread, in the engine’s pipeline phases (see systems). Managed systems run one after another, never at the same time as each other or as your handlers, so it is safe to write to your own state in a system and in an event handler without locks.

The IO threads and the chunk worker pool never call into plugin code. Results that come back from workers (a chunk finished generating) are integrated by the tick thread in the AsyncIntegration phase, so by the time you see a chunk or a block, it is in a consistent state.