Skip to content

Events and Callbacks ​

Event Bridge (EventBridge): concurrent/serial modes, event data, handler operations and mode selection; unified callback system.

EventBridge ​

Paper-based event triggered
  → EventBridge: check JS subscriptions
  → eventData() extract fields (primitive types, no platform object references)
  → SyncCallbackHelper.register(cbId)
  → queue.sendJs($hm(t="cb", p=cbId, r={event data})) → JS thread
  → **Observer (eventWatch)**: delivery returns immediately; the runtime does not wait — no _eventId, no write-back
  → **Interceptor (eventIntercept)**: main thread spin-waits (no budget to drain scheduler queue — event.complete is not starved by tick time-slice budget):
      while (not completed && not timed out) {
          runtime.getScheduler().drainAll();
          Thread.onSpinWait();
      }
  → JS $hm → _hm → _cbs[cbId].h(r)
  → yeow-api callback:
      Auto mode:
        Synchronous handler: executes → collects modifications (return value mods + direct assignment of event parameters) → $send('task', 'event.complete', {params:{eventId, mods}})
        Returns Promise: → immediately $send('task', 'event.complete', {params:{eventId, mods}}), only synchronous-phase modifications take effect
      Manual mode:
        handler(e, complete) → user calls complete(mods)
  → Scheduler → Tasks.execute('event.complete')
  → SyncCallbackHelper.complete(cbId, mods)
  → applyMods(): applies write-back fields (cancelled / joinMessage / quitMessage / message / format / to / respawnLocation / deathMessage / newFoodLevel / damage / amount / target / clickedItem / cursorItem / serverPing motd, etc. — full list see api/event.md event write-back)

Observe vs Intercept (two registrations) ​

Observers are the default offered to beginners: eventWatch is read-only observation — the runtime delivers and immediately continues, with no pend and no spin. Use eventIntercept only when you really need to change the event.

Subscribe eventWatchIntercept eventIntercept
Semanticsread-only observationmodify / cancel
Runtimedelivery continues immediately (no pend, no wait)registers a pend and spin-waits for event.complete
Event datano _eventIdincludes _eventId
Write-backnone (typed Readonly)applyMods applied
Risknever blocks the game threada slow/throwing handler can push the event into its timeout (5s backstop)
Typical uselogging, metrics, forwarding, state synccancel, change messages, MOTD, permission checks

One event can have both observers and interceptors: deliver observers first (without waiting), then handle interceptors (waiting). Permission checks were extracted into a separate request/response API (onPermissionCheck; the handler must return { allowed } synchronously) and are no longer part of the event system.

Concurrent Event Handling ​

When multiple plugins subscribe to the same event, Yeow supports both serial (default) and concurrent modes:

ModeBehaviorruntime/config.yml Setting
SerialSends the event to each plugin one by one, waiting for each to complete before sending to the nextconcurrent-events: false
Concurrent (Experimental)Sends to all subscribed plugins simultaneously, waits for the slowest to completeconcurrent-events: true (default)

In concurrent mode, each plugin uses an independent callbackId, and a CountDownLatch waits for all plugins to complete. Event data shares the same snapshot across all plugins. Since game operations are serialized through the scheduler, no race conditions occur. The cancelled merge strategy: if any plugin cancels, the event is cancelled.

Event Data ​

All event fields are primitive types (string/number/boolean/object). On the JS side, yeow-api's adaptEvent() automatically wraps them:

  • player UUID → new Player(uuid) constructed directly (zero round-trips; name is lazily fetched synchronously and cached on first access).
  • from/to/respawnLocation → Location objects.
  • All fields are wrapped with getters/setters — handlers can directly assign (e.deathMessage = ..., etc.) and the assignment is recorded as a write-back mod (merged with the return value; direct assignment takes priority). cancelled can only cancel the event where it is exposed.

WARNING

playerChat write-backs have no effect on Paper 1.21.4: this event bridges to Bukkit's legacy AsyncPlayerChatEvent, while chat broadcast is driven by the Adventure (AsyncChatEvent) pipeline — the event fires with a correct payload, but e.message = … / return { message } / e.cancelled = true do not affect the broadcast. Use a command or player.sendMessage instead when the change must take effect. This limitation is baked into the e2e assertion expectations (see tests/README.md).

Operations in Event Handlers ​

js
// Auto mode (default)
eventIntercept('blockBreak', (e) => {
    // ✅ e.player is already a Player object (uuid directly constructed, zero round-trips; name lazily fetched on first access)
    const p = e.player;

    // ✅ Cancel the event — takes effect immediately
    e.cancelled = true;

    // ✅ Async operation (event has been released, but async API calls are unaffected)
    post('player.sendMessage', {...});
});

// async handler — returning a Promise immediately completes
eventIntercept('blockBreak', async (e) => {
    e.cancelled = true;  // ✅ Takes effect in synchronous phase
    await someTask();    // Event has ended, setting values after this is ineffective
    e.cancelled = false; // ❌ Does not take effect
});

// Manual mode — full control over complete timing
eventIntercept('blockBreak', { manualRelease: true }, (e, complete) => {
    doAsyncCheck(result => {
        e.cancelled = result;
        complete({ cancelled: result });
    });
});

Event Reentrant Deadlock ​

This is the direct reason the sync API was removed in 0.8.0. A plugin owns exactly one JS thread, and a synchronous call blocks it while waiting for the operation; many game operations fire a server event while completing (playerTeleport, playerCommand, serverCommand). If this plugin subscribes to that event, the runtime must dispatch it to the very thread that is blocked and wait for it to return => the event can never be handled => both sides wait until the event timeout (5s default).

Independent of the call site: event handlers, command callbacks, timers, await continuations, worker message callbacks — even onLoad initialization code. Independent of the platform (Paper and Folia alike).

Typical shape (event reentrancy): while spinning for the JS handler the event thread drains the scheduler queue; the executed sync task fires another event this plugin subscribes to, and that event can only wait for the already-blocked JS thread:

blockBreak event -> event thread spins
  -> drains the queue, picks up a sync task -> executes in place
  -> player runs a command -> fires playerCommand
  -> waits for JS to handle playerCommand
  -> but the JS thread is blocked inside that sync call (future.get)
  -> self-deadlock until the event timeout (5s default)

Use the async API inside event handlers (await xxx()). A blocking call prevents the event from completing until it times out. Background: Why the sync API was removed.

Event Handler Mode Selection ​

In auto mode, returning a Promise from an event handler immediately releases the event. This means setting event.cancelled after an await in an async handler is ineffective.

But this does not mean async handlers have no value. The vast majority of events are used to trigger logic rather than modify results:

eventWatch('playerJoin', async (e) => {
    // Query database → send welcome message → log the event
    // These operations don't need to block the main thread waiting
    const msg = await db.getWelcome(e.player.uuid);
    e.player.sendMessage(msg);
    log.info(e.player.name + ' joined');
});

In the example above, the event does not need to be cancelled, nor does it need to return any mods. The async handler allows plugins to freely use async APIs without blocking the main thread spin-wait. This is the recommended approach:

ScenarioRecommended ModeReason
Only need to trigger logic (send messages, modify data, log events)Auto mode + asyncDoes not block the main thread, clean code
Need to synchronously determine the result (cancel, modify death message/MOTD, etc.)Auto mode + synchronous handlerDirectly assign event fields (or return mods), takes effect before await
Need to asynchronously fetch data before determining the resultManual mode + complete()User controls the timing of $send('task', 'event.complete', {params:{eventId, mods}})

Rule: If your event handler logic does not need to block the main thread waiting for results, boldly use async. The main thread cannot process ticks, AI, physics, etc. while spin-waiting for JS results, and prolonged spin-waits affect server performance.