Performance rules
The server is built to carry far more players per process than a JVM server, and the plugin layer is built not to give that back. Most of the cost of a plugin is what your handlers allocate and how often they run, not the cost of calling them.
Why allocation is the thing to avoid
Section titled “Why allocation is the thing to avoid”- A tick has 50 ms, and the tick thread does all the work of the server’s tick: networking glue, entities, blocks, your handlers. There is no spare thread to absorb a pause.
- .NET uses a generational GC. Short-lived garbage is cheap to collect, but every allocation on a path that runs thousands of times per tick (movement, damage, block updates, chat of a busy server) adds up to collections whose pauses land in the tick. Concurrent GC is on, which helps, but a gen0 collection still stops the world briefly.
- Your plugin runs in the worker process, and the worker’s own garbage is collected there, not in the server. But a handler that allocates still makes the worker’s tick slower, and the server waits for the worker’s answer on awaited events (see threading).
The rules
Section titled “The rules”- Hot-path handlers must not allocate. Hot = anything that fires per tick, per move packet, per block or per entity:
PlayerMoveEvent,TickStartEvent/TickEndEvent,EntityDamageEvent,BlockPlaceEvent/BlockBreakEventon a busy world, systems, scheduled tasks that repeat often. - No LINQ, no
stringbuilding, no$"..."interpolation, noparamscalls, no boxing, no new lambdas on those paths. A lambda that captures variables allocates a closure each time it is created: create delegates once (inOnEnable, or asstaticlambdas) and reuse them. - Read
Utf8Stringin place. Compare bytes (EqualsAscii("..."u8)) and only callToString()when you really need a managed string (and then once, not per event). - Allocate once, reuse:
static readonlytables, pooled arrays (ArrayPool<T>),Span<T>/stackallocfor small temporaries,StringBuilderfields cleared between uses. - Use queries for bulk work. A per-entity
Get/Setin a loop costs a call into the engine each time (about 6.5 ns forGetin-process). Iterate query chunks instead: about 2.7 ns per entity in-process. In the worker, a read of an engine component is a round trip to the engine, so batch them with queries. - Subscribe to what you need.
PlayerMoveEventfires per move packet; do not subscribe to it for a feature that needs it rarely. Unsubscribe (Subscription.Dispose) when a feature is turned off. If you only care about coarse movement, raiseServer.PlayerMoveThreshold: at 0, the default, a moving player produces about 20 events a second. - Check cheaply, then work:
if (!HasWork) return;first;Events.HasSubscribers<T>()before building an event that costs something; compare the cheap field before the expensive one. In the worker,HasSubscribersonly sees your plugin’s own listeners, not those of the engine or other plugins, so do not use it to skip firing an event that someone else may handle. - Do heavy work off the tick thread and come back with
Scheduler.PostorRunSync(see threading); never block. - Do not allocate in
foreachover interfaces: iterate arrays andList<T>(struct enumerators), notIEnumerable<T>.
// Bad on a hot path: allocates a list, a closure and an iterator on every moveEvents.On((ref PlayerMoveEvent e) =>{ var nearby = _zones.Where(z => z.Contains(e.To)).ToList(); Log.Debug($"{e.Player.Name} moved to {e.To}");});
// Good: no allocation, no work unless the player left the zone grid cellprivate static readonly Zone[] s_zones = ...;private void OnMove(ref PlayerMoveEvent e){ foreach (ref readonly var z in s_zones.AsSpan()) // Span enumeration: no allocation if (z.Contains(e.To)) { /* ... */ }}// in OnEnable: Events.On<PlayerMoveEvent>(OnMove); // one delegate, created once(Events.On(OnMove) with a method group allocates its delegate once, at registration.)
What things cost
Section titled “What things cost”The figures below were measured on the reference machine (GCC 16 -O3, .NET 10.0.5, Windows 11) with the scripting runtime’s call-overhead test. They are for the in-process runtime, the plumbing between native code and managed code, and they vary with machine load.
| Path | Cost |
|---|---|
| fire an event, no subscribers | about 0.4 ns |
| fire, one native handler | about 4-8 ns |
| native to managed noop call | about 3.3 ns |
| native fire to one C# handler (ref struct, mutate a field) | about 22 ns, with the thread checks and error bookkeeping included |
managed to native call (e.g. EntityAlive) |
about 3.4 ns |
C# entity.Get<T>() |
about 6.5 ns |
C# EventBus.Fire to a C# handler |
about 12-15 ns |
| C# chunk query pass, 10 000 entities, one component | about 2.7 ns per entity |
The numbers are for the plumbing. They say a handler that does nothing is free, and that what you do inside it is what you pay for.
Costs in the plugin worker
Section titled “Costs in the plugin worker”Plugins run in the worker process, so a few costs are different from the table above:
- Events are copies. The server encodes each event for the worker, and your handler gets a decoded copy, not a view of the engine’s own struct. Changes to mutable fields are sent back with the answer.
- Every host call is a round trip between the two processes, inside the tick. No per-call figure is published for the worker, so measure your own plugin.
- Observed events are batched. An event nobody needs to answer is queued and sent with the rest of the tick in one message. Awaited events (cancellable or with mutable fields) wait for your answer, up to
scripting.ipc.event_budget_us(5 ms by default). See threading. - Plugin-defined components stay in the worker, so reading them does not cross the process boundary. Engine components are read through the engine, one request at a time.
- Logging is rate-limited: 200 lines a second by default (
scripting.ipc.log_per_second). Lines past that are dropped, so do not log per event.
Measure your own plugin
Section titled “Measure your own plugin”-
To check a handler for allocations in a test or while developing, wrap it:
long before = GC.GetAllocatedBytesForCurrentThread();Handle(ref e);long allocated = GC.GetAllocatedBytesForCurrentThread() - before; // should be 0 -
/tick queryprints the vanilla-style tick timing, so you can compare the server with and without your plugin. -
The engine’s profiler charges each plugin’s time to its own group,
plugin:<Name>, split into events, commands, tasks, reloads and systems. Look there first when the tick gets slower. -
Wrap heavy logic in a system so it shows up by name. In-process each system has its own row; in the worker a plugin’s systems are one row for the plugin.