Hytale Event Bus: Priorities, Cancellation, and Async Dispatch
A source-grounded reference for Hytale's server event bus: listener registration, EventPriority ordering, ICancellable events, keyed dispatch, and the split between synchronous and CompletableFuture-based async buses.
Every interesting thing a Hytale plugin does eventually flows through one object: the event bus. When a world is added, a player connects, or a chunk loads, the server constructs an event and pushes it through EventBus, which fans it out to every registered listener in a deterministic order. Understanding that order — and the difference between a synchronous listener that can veto an action and an asynchronous listener that transforms a CompletableFuture pipeline — is the difference between a plugin that cooperates with the server and one that races it.
This reference is based on Hytale server version 2026.03.26 and reads directly from the decompiled com.hypixel.hytale.event package. The single concrete bus, EventBus, is created once per server in HytaleServer (new EventBus(...)) and reached from anywhere via HytaleServer.get().getEventBus(). Plugins usually go through PluginBase.getEventRegistry(), which returns an EventRegistry that wraps the same bus and ties every listener to the plugin's lifecycle. We will work from the type hierarchy down to dispatch, then look at how the real server uses it.
#The Event Type Hierarchy
All events implement IBaseEvent<KeyType>. That generic KeyType is the spine of the whole system: it determines what value you can scope a listener to (a world name, a UUID, or Void for "no key"). Two interfaces split events into the synchronous and asynchronous worlds, and both extend IBaseEvent:
| Interface | Extends | Bus that handles it | Listener shape |
|---|---|---|---|
IEvent<KeyType> | IBaseEvent<KeyType> | SyncEventBusRegistry | Consumer<EventType> |
IAsyncEvent<KeyType> | IBaseEvent<KeyType> | AsyncEventBusRegistry | Function<CompletableFuture<E>, CompletableFuture<E>> |
EventBus.getRegistry(...) performs the routing decision with a single check — IAsyncEvent.class.isAssignableFrom(eventClass) — so an event is async purely because it implements IAsyncEvent. Nothing else.
Two small interfaces decorate events with extra behavior:
ICancellable— addsboolean isCancelled()andvoid setCancelled(boolean). The bus itself does not read this flag; it is a contract between listeners and whoever fired the event (more on that below).IProcessedEvent— addsvoid processEvent(String). After each listener runs, the registry callsprocessEvent(consumer.getConsumerString()), letting an event record exactly which listener touched it — useful for tracing.
Here is a real cancellable event from the world subsystem, lightly trimmed:
// com.hypixel.hytale.server.core.universe.world.events
public abstract class WorldEvent implements IEvent<String> { // KeyType = world name
private final World world;
public WorldEvent(World world) { this.world = world; }
public World getWorld() { return this.world; }
}
public class AddWorldEvent extends WorldEvent implements ICancellable {
private boolean cancelled = false;
@Override public boolean isCancelled() { return this.cancelled; }
@Override public void setCancelled(boolean cancelled) { this.cancelled = cancelled; }
}WorldEvent keys on String (the world name). AddWorldEvent opts into cancellation. That is the entire pattern.
#EventPriority and Listener Ordering
Listeners run in priority order, lowest value first. The EventPriority enum carries a short for each named tier:
| Constant | getValue() |
|---|---|
FIRST | -21844 |
EARLY | -10922 |
NORMAL | 0 |
LATE | 10922 |
LAST | 21844 |
The enum is a convenience: every registration method that takes an EventPriority immediately calls priority.getValue() and forwards the raw short. There are also overloads that accept a short directly, so you can slot a listener between the named tiers if you must. Registering without a priority defaults to (short)0 — i.e. NORMAL.
Internally the ordering is exact and lock-light. Each EventConsumerMap holds a Short2ObjectConcurrentHashMap from priority to a CopyOnWriteArrayList of consumers, plus an AtomicReference<short[]> of the sorted set of active priorities. When a listener is added at a new priority, the array is rebuilt via binary search and a compareAndSet retry loop. Dispatch then simply iterates getPriorities() in ascending order and runs each bucket's list in registration order.
Priority is not cancellation order
A LAST listener still runs even if an EARLY listener called setCancelled(true). The bus does not short-circuit on the cancel flag. If your listener should respect a prior veto, check isCancelled() yourself at the top of the listener. Priority only controls sequence, not whether you run.
#Registering Listeners: Keyed, Global, and Unhandled
IEventRegistry exposes three families of registration, each with sync (register*) and async (registerAsync*) variants, and each overloaded for default / EventPriority / raw-short priority and for Void / typed keys. The three families differ only in which events reach the listener:
| Family | Method | Fires for |
|---|---|---|
| Keyed | register(priority, eventClass, key, consumer) | events dispatched with that exact key |
| Global | registerGlobal(priority, eventClass, consumer) | every event of that class, regardless of key |
| Unhandled | registerUnhandled(priority, eventClass, consumer) | only events that no keyed or global listener handled |
The "unhandled" tier is a genuine fallback. In SyncEventBusRegistry.dispatchEventMap, a listener that runs successfully sets handled = true; the unhandled consumers fire only when nothing else did. This is how you register a default behavior that yields the moment a more specific listener exists.
A keyed registration scopes a listener to one value of KeyType. For WorldEvent (keyed on String), that means one world:
// conceptual usage inside a plugin's setup()
EventRegistry events = getEventRegistry();
// Fires only when AddWorldEvent is dispatched for the world named "spawn":
events.register(EventPriority.EARLY, AddWorldEvent.class, "spawn", event -> {
if (event.isCancelled()) return; // respect an earlier veto
getLogger().info("spawn world is being added");
});
// Fires for AddWorldEvent on ANY world:
events.registerGlobal(AddWorldEvent.class, event ->
getLogger().info("a world was added: " + event.getWorld()));The server itself uses the global tier for exactly this kind of cross-cutting concern. PluginManager, when it detects outdated mods, registers a global listener so it can warn the relevant player on any world they join:
// com.hypixel.hytale.server.core.plugin.PluginManager (trimmed)
HytaleServer.get().getEventBus().registerGlobal(
AddPlayerToWorldEvent.class,
event -> {
Player player = event.getHolder().getComponent(Player.getComponentType());
if (player != null && player.hasPermission("hytale.mods.outdated.notify")) {
// notify the player their mods are out of date
}
});Every register* call returns an EventRegistration<KeyType, EventType> (or null). That handle extends Registration and exposes unregister() plus an isEnabled BooleanSupplier. Call unregister() to tear a single listener down; EventRegistration.combine(...) bundles several handles so they enable and unregister as a unit. When you register through a plugin's EventRegistry, these handles are tracked for you and cleaned up with the plugin.
#Synchronous Dispatch and Cooperative Cancellation
Firing a sync event is a two-step call: get a dispatcher for a key, then dispatch an instance.
IEventBus bus = HytaleServer.get().getEventBus();
AddWorldEvent event = bus
.dispatchFor(AddWorldEvent.class, name) // IEventDispatcher scoped to this world name
.dispatch(new AddWorldEvent(world)); // returns the same event after all listeners ran
if (!event.isCancelled()) {
// proceed: actually add the world
}This is verbatim how Universe adds a world. dispatch(...) runs every matching listener synchronously and returns the same event object, mutated. Because AddWorldEvent is ICancellable, any listener can call setCancelled(true), and the producer — Universe — checks event.isCancelled() afterward and skips the world insertion. This is the entire cancellation mechanism: the bus transports the event, listeners mutate the flag, and the caller decides what cancellation means. The same pattern guards RemoveWorldEvent.
dispatchFor is also an optimization. EventBus.dispatchFor looks up the registry and, if there are no listeners for that class at all, returns a shared SyncEventBusRegistry.NO_OP dispatcher whose hasListener() is false and whose dispatch just returns the event untouched. Firing an event with zero subscribers costs almost nothing. When listeners do exist, SyncEventConsumerMap.dispatch runs the keyed bucket, then the global bucket, and finally the unhandled bucket if nothing handled the event.
Listener exceptions are swallowed
If a listener throws, dispatchEventMap catches the Throwable, logs it at SEVERE via the registry's HytaleLogger, and continues to the next listener. One broken plugin will not abort dispatch or crash the producer — but it also will not surface as a thrown exception at the call site. Watch the server log, and never rely on an exception to halt a chain; use setCancelled(true) for control flow.
#Asynchronous Dispatch with CompletableFuture
An IAsyncEvent is dispatched into a CompletableFuture pipeline instead of a straight-line loop. Each async listener is a Function<CompletableFuture<E>, CompletableFuture<E>> — it receives the in-flight future and returns the next stage, so listeners chain rather than merely observe:
// dispatch side
CompletableFuture<SendCommonAssetsEvent> result = bus
.dispatchForAsync(SendCommonAssetsEvent.class)
.dispatch(new SendCommonAssetsEvent(packetHandler, assets));
// listener side: transform the pipeline, return the next future stage
events.registerAsync(SendCommonAssetsEvent.class, future ->
future.thenCompose(event -> loadExtraAssets(event)
.thenApply(unused -> event))); // must return the (same) event downstreamSendCommonAssetsEvent implements IAsyncEvent<Void> in the real source, which is why it lands on the async bus. AsyncEventConsumerMap.dispatch starts from CompletableFuture.completedFuture(event) and thenComposeAsync-es each priority bucket onto it in order, so priority still governs sequence — but the work happens off the calling thread and can suspend on I/O. The async bus also offers a plain Consumer overload of register: internally it wraps your consumer as future -> future.thenApply(e -> { consumer.accept(e); return e; }), giving you a simple "observe each event" listener without writing future plumbing. As with the sync bus, an event class with no async listeners gets AsyncEventBusRegistry.NO_OP, which returns an already-completed future.
Async events use Void keys in practice
The convenience overloads dispatchAsync(...) and registerAsync(eventClass, fn) are typed for IAsyncEvent<Void> — the common case. Keyed async events exist (the registry is fully generic over KeyType), but most async events you will meet, like SendCommonAssetsEvent, are keyless. Prefer Void unless you genuinely need per-key async listeners.
#Timing, Debugging, and Lifecycle
The EventBus constructor takes a timeEvents flag, wired in HytaleServer to the --event-debug launch option (Options.EVENT_DEBUG). When enabled, each registry swaps in a timed consumer that wraps the listener in System.nanoTime() measurements and accumulates them into a per-consumer Metric. That gives you per-listener latency for hunting down a slow plugin — at the cost of the timing overhead, so it is off by default.
Registry lifecycle is explicit. EventBus.shutdown() calls shutdown() on every EventBusRegistry, which flips a shutdown flag and clears its consumer map. After that, isAlive() returns false, dispatchFor short-circuits to NO_OP, and any further register* call throws IllegalArgumentException("EventRegistry is shutdown!"). Registries are created lazily: the first getRegistry for an event class computeIfAbsents a SyncEventBusRegistry or AsyncEventBusRegistry keyed by the event class, so you never register a bus manually — touching an event class is enough.
#Putting It Together
A modder's mental model: pick the right event interface, register at the right priority and scope, and respect the cancellation contract.
IEventvsIAsyncEventdecides the bus. Sync listeners areConsumers that run inline; async listeners areFunctions that transform aCompletableFuturechain off-thread.EventPriorityorders listeners (FIRST→LAST), but never short-circuits. CheckisCancelled()yourself if order matters to you.- Keyed / global / unhandled decides which events reach you. Keyed scopes to one
KeyTypevalue; global sees everything; unhandled is the fallback when nobody else handled it. - Cancellation is cooperative.
ICancellable.setCancelled(true)only matters because the producer checks it afterdispatch(...)returns. Setting it on a non-cancellable event, or for a producer that ignores the flag, does nothing.
Event bus modder checklist
- Implement
IEvent<K>for synchronous events,IAsyncEvent<K>for futures-based ones; addICancellableonly if a listener should be able to veto. - Register through
getEventRegistry()so listeners are unbound when your plugin unloads. - Pass an
EventPriority(or rawshort) when you need to run before or after other listeners; default isNORMAL. - Use the keyed overload to scope to one world/entity/UUID; use global for cross-cutting concerns; reserve unhandled for fallbacks.
- At the top of a listener on a cancellable event, early-return on
isCancelled()to honor earlier vetoes. - For async listeners, always return the (same) event downstream —
future.thenApply(e -> e)— so later stages still receive it. - Keep the returned
EventRegistrationif you need tounregister()a single listener early. - Launch with
--event-debugto collect per-listener timing when a plugin feels slow.
Build it yourself
Turn an idea into a working Hytale mod
Describe what you want in plain English and get a real, installable build. No boilerplate, no setup.
Start building