The threading model
One rule
Section titled “One rule”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, lightingThe 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, noTask.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.
What the tick waits for
Section titled “What the tick waits for”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.
Thread-safe members
Section titled “Thread-safe members”The thread-safe members are the ones you can call from any thread:
Scheduler.Post,Scheduler.RunSync,Scheduler.RunAsyncand the awaitablesScheduler.ToTick()andScheduler.ToAsync().Log. Logging from any thread is fine.Server.IsTickThread(true only on the tick thread) andServer.CurrentTick(the engine’s tick count).Http.Client, the sharedHttpClientfor 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.
Posting work back
Section titled “Posting work back”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:
[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:
- Copy everything you need before leaving the tick thread: strings (
Utf8String.ToString()), numbers, thePlayerorEntityhandle. Event structs and their borrowed fields are invalid after the handler returns. - Re-validate on return: the player may have left (
Player.IsOnline), the entity may be dead (Entity.IsAlive). - Do not touch the game API from
Task.Run,asynccontinuations,System.Threading.TimerorFileSystemWatchercallbacks. Anawaitresumes on the thread pool unless you hop back: useScheduler.Post,Scheduler.RunSyncorawait Scheduler.ToTick(). - Avoid
async voidhandlers: an exception on a pool thread has no plugin to be attributed to.
Hopping between threads
Section titled “Hopping between threads”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)andRunAsync<T>(Func<T>)run work on the thread pool and return aTask. The work must not touch the game API.Scheduler.RunSync(Action)andRunSync<T>(Func<T>)queue work for the tick thread and return aTaskthat 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.
Thread checks
Section titled “Thread checks”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.
Login decisions that take time
Section titled “Login decisions that take time”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 and queries
Section titled “Systems and queries”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.
What about the engine’s other threads?
Section titled “What about the engine’s other threads?”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.