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
- Event handlers should be fast. Long operations delay the event (and the server tick).
- Each script receives events independently with its own timeout.
- Non-cancellable events (no
idfield) are fire-and-forget — Java doesn't block.
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:
- First parameter — Always the command sender (Player). Not included in usage.
- Subsequent parameters — Command arguments
- Type annotations —
str,int,floatfor auto-conversion - 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.