Event subscriptions, dispatch, cancellation, overrides, and commands

Events

How Bukkit events are captured, sent to Python, and how Python can cancel them or override their values. Also covers command registration, which follows a similar player-triggered dispatch model.


Subscription

When you use @event in Python, the bridge registers a Bukkit event listener on the Java side:

@event
async def player_join(event):
    await event.player.send_message("Welcome!")

Wire message (Python → Java):

{
  "type": "subscribe",
  "event": "player_join",
  "priority": "NORMAL",
  "once_per_tick": false,
  "throttle_ms": 0
}

Priority

Maps to Bukkit's EventPriority — controls the order handlers run relative to other plugins:

Priority When to use
LOWEST Read event data before anyone modifies it
LOW Early processing
NORMAL Default. Most handlers should use this
HIGH Override other plugins' decisions
HIGHEST Final say before MONITOR
MONITOR Read-only observation. Do not modify the event
@event(priority="HIGH")
async def player_chat(event):
    return f"[VIP] {event.message}"  # Override after other plugins

Throttling

Two mechanisms prevent event spam:

once_per_tick — Only dispatch the first occurrence per server tick. Useful for events that fire many times per tick (e.g. entity_move):

@event(once_per_tick=True)
async def entity_move(event):
    pass  # Called at most once per tick per subscription

throttle_ms — Minimum milliseconds between dispatches. Nanosecond-precision cooldown:

@event(throttle_ms=100)
async def player_move(event):
    pass  # At most 10 dispatches per second

Dispatch Flow

1. Bukkit event fires (e.g. PlayerJoinEvent)
         │
2. Java EventSubscription executor runs
   ├── Check once_per_tick: skip if already fired this tick
   └── Check throttle_ms: skip if cooldown not elapsed
         │
3. Java EventDispatcher serializes the event
   ├── Auto-extract fields via reflection (player, entity, block, etc.)
   └── For cancellable events: assign event ID, create PendingEvent
         │
4. Java → Python:
   {"type": "event", "event": "player_join", "payload": {...}, "id": 42}
         │
5. Python _dispatch_event() runs all registered @event handlers
   ├── Both sync and async handlers supported
   ├── All handlers for same event run concurrently (asyncio.gather)
   └── Exceptions are caught and logged, don't crash the script
         │
6. Python → Java (in order):
   a. {"type": "event_cancel", "id": 42}         (if event.cancel() called)
   b. {"type": "event_result", "id": 42, ...}    (if handler returned a value)
   c. {"type": "event_done", "id": 42}           (always sent last)
         │
7. Java applies modifications to the Bukkit event
   ├── Cancel: event.setCancelled(true)
   ├── Chat override: replace message text
   ├── Damage override: event.setDamage(value)
   └── Respawn override: event.setRespawnLocation(location)

Payload auto-extraction

The Java EventDispatcher uses reflection to extract common event fields. It tries multiple getter names for compatibility across Bukkit versions:

tryAddPayload(payload, event, "player", "getPlayer", "getWhoClicked");
tryAddPayload(payload, event, "entity", "getEntity");
tryAddPayload(payload, event, "block", "getBlock", "getClickedBlock");
tryAddPayload(payload, event, "inventory", "getInventory", "getClickedInventory");

Fields are serialized with the full bridge serializer, so you get proper proxy objects in Python (not raw JSON).


Blocking Behavior

The Java event handler thread blocks while waiting for Python to respond. This is necessary for cancellation/modification to work — Java must wait for Python's answer before the event proceeds.

Timeout: 1 second. If Python doesn't respond in time, the event continues unmodified.

While blocking, Java calls drainMainThreadQueue() in a loop. This means Python's event handler can make API calls back to Java and they'll be processed — the event dispatch doesn't deadlock the main thread queue.

while (System.currentTimeMillis() < deadline && pending.latch.getCount() > 0) {
    plugin.drainMainThreadQueue();  // Process API calls while waiting
    pending.latch.await(5, TimeUnit.MILLISECONDS);
}

Implications


Cancellation

Call event.cancel() inside any handler to cancel a cancellable event:

@event
async def player_chat(event):
    if "bad_word" in event.message.lower():
        event.cancel()
        await event.player.send_message("§cWatch your language!")

Python sends {"type": "event_cancel", "id": 42} before event_done. Java sets event.setCancelled(true) on the Bukkit event.

Not all Bukkit events are cancellable. Non-cancellable events (like player_join) ignore cancel requests.


Result Overrides

Handlers can modify event data by returning values:

Chat message override

Return a string from a chat event handler to replace the message:

@event
async def async_chat(event):
    return f"[{event.player.name}] {event.message}"

Sends: {"type": "event_result", "id": 42, "result": "[Steve] Hello", "result_type": "chat"}

Damage override

Return a number from a damage event handler to change the damage amount:

@event
async def entity_damage(event):
    if event.entity.type == "PLAYER":
        return event.damage * 0.5  # Half damage

Sends: {"type": "event_result", "id": 42, "result": 2.5, "result_type": "damage"}

Respawn location override

Return a Location from a respawn event handler to change where the player respawns:

@event
async def player_respawn(event):
    return Location(0, 100, 0, event.player.world)  # Spawn at world center

Sends: {"type": "event_result", "id": 42, "result": {"__value__": "Location", "fields": {...}}, "result_type": "respawn"}

Multiple handlers

If multiple handlers return values, the last one wins. All handlers run concurrently via asyncio.gather, so ordering isn't guaranteed.


Block Explosion Special Handling

Block explosion events (block_explode, entity_explode) get special treatment. Instead of one event for the entire explosion, Java creates one event per affected block and sends them as a batch:

{"type": "event_batch", "event": "block_explode", "payloads": [{...}, {...}, ...]}

Each payload gets dispatched individually on the Python side. Cancelled blocks are removed from the explosion's block list — you can selectively protect blocks:

@event
async def block_explode(event):
    if await event.block.get_type() == "DIAMOND_ORE":
        event.cancel()  # Protect diamond ore from explosions

Event Name Mapping

Bukkit event class names are converted to snake_case for Python. The mapping strips common suffixes:

Bukkit Class Python Event Name
PlayerJoinEvent player_join
AsyncChatEvent async_chat
EntityDamageByEntityEvent entity_damage_by_entity
BlockBreakEvent block_break
InventoryClickEvent inventory_click

The Event suffix is always stripped. CamelCase is split into snake_case.


Command Registration

Commands follow the same pattern as events — a player action triggers a dispatch from Java to Python.

The @command decorator

Python scripts register commands using the @command decorator:

from bridge import command

@command
def greet(player, name: str, count: int = 1):
    for i in range(count):
        player.send_message(f"Hello, {name}!")

Signature parsing

The decorator inspects the function's signature:

  1. First parameter — Always the command sender (Player). Not included in usage.
  2. Subsequent parameters — Command arguments
  3. Type annotationsstr, int, float for auto-conversion
  4. Default values — Make arguments optional

Usage string auto-generated: /greet <name> [count]

Java-side registration

When Python registers a command, it sends:

{"type": "register_command", "name": "greet", "usage": "/greet <name> [count]", "description": "..."}

Java creates a ScriptCommand and registers it on Bukkit's CommandMap:

ScriptCommand cmd = new ScriptCommand(name, usage, description, scriptInstance);
Bukkit.getCommandMap().register("pjb", cmd);

Tab completion

Commands support tab completion. When a player presses Tab, Java sends a completion request to Python, which returns suggestions based on the current argument position and type.

Command cleanup on shutdown

When a script is stopped or reloaded, its commands are unregistered:

void unregisterCommands() {
    for (ScriptCommand cmd : registeredCommands) {
        cmd.unregister(Bukkit.getCommandMap());
    }
    registeredCommands.clear();
}

This means after a hot reload, commands are re-registered fresh from the new script.