Listener System
The Event/Listener system is a prioritized, event-driven architecture designed to decouple the game core from script-based logic. It allows developers to "hook" into specific game events (like combat, character login, or item creation) and execute custom code without modifying the core engine's source code directly.
Core Concepts
The system is built on four main pillars:
- EventType: An enumeration (constant) representing a specific occurrence in the game (e.g.,
CREATURE_KILL,PLAYER_LOGIN,NPC_TALK). - BaseEvent: The data carrier. Each event type has a corresponding implementation of
BaseEvent(e.g.,CreatureKillevent) that contains all relevant information about that specific occurrence. - ListenersContainer: A specialized storage container that holds listeners for various events. Many objects in L2J (Players, NPCs, Castles, Clans) implement or own a
ListenersContainer. - EventDispatcher: The engine that manages the notification process, ensuring listeners are called in the correct order of priority.
How it Works: The Execution Lifecycle
When an action happens in the world (for example, a Player attacks a Monster):
- Event Instantiation: The core engine creates a new
AttackableAttackevent object, populating it with the attacker, target, damage, and skill used. - Dispatching: The core calls
EventDispatcher.getInstance().notifyEvent(event, targetContainer). - Listener Retrieval: The dispatcher fetches all registered listeners for that
EventTypefrom:- Local Container: The specific target involved (e.g., the hit Monster).
- Global Containers: Centralized containers like
Containers.Global()orContainers.Players().
- Priority Sorting: Listeners are sorted by their
priorityvalue (higher values execute first). - Execution: The dispatcher iterates through the listeners and executes them sequentially.
- Return/Abort: If a listener returns a
TerminateReturnor uses theabort()flag, the dispatcher can stop looking for further listeners or signal the core to cancel the event.
Registering Listeners
There are three ways to register a listener, ranging from the most common (annotations) to manual control.
1. Annotation-Based (Recommended for Scripts)
In any class extending AbstractScript (like AI scripts or Quests), you can use annotations to register methods. This is the cleanest and most common method.
@RegisterEvent(EventType.CREATURE_KILL)
@RegisterType(ListenerRegisterType.NPC)
@Id(20432) // Elpy
@Priority(100)
public void onElpyKill(CreatureKill event) {
LOG.info("An Elpy was killed by {}", event.attacker().getName());
}
Available Filtering Annotations:
@Id(int...): Filters by a specific NPC, Item, or Castle ID.@Ids({@Id(1), @Id(2)}): Allows multiple IDs.@Range(from=X, to=Y): Filters by a range of IDs.@NpcLevelRange(from=X, to=Y): Filters by the level of the NPC.@Priority(int): Controls execution order (default is 0).
2. Functional (Lambda) Registration
Useful for quick registration during a constructor.
public MyScript() {
registerConsumer((PlayerLogin event) -> {
event.player().sendMessage("Welcome back!");
}, EventType.PLAYER_LOGIN, ListenerRegisterType.GLOBAL_PLAYERS);
}
3. Manual Registration
Rarely used, but powerful for attaching listeners to specific instances dynamically.
L2PcInstance player = ...;
player.addListener(new ConsumerEventListener(player, EventType.ATTACK, (Attack event) -> {
// Custom logic for this specific player
}, this));
Register Types (Scope)
The ListenerRegisterType defines where the listener is attached:
| Type | Description |
|---|---|
GLOBAL | Listens to the event everywhere in the world. |
NPC | Attached to NPC Templates. Triggers for all NPCs of that ID. |
ITEM | Attached to Item Templates. |
PLAYER | Attached to a specific player instance. |
GLOBAL_NPCS | Listens to events for ALL NPCs globally. |
GLOBAL_MONSTERS | Listens to events for ALL monsters globally. |
GLOBAL_PLAYERS | Listens to events for ALL players globally. |
CASTLE / FORT | Attached to a specific residence. |
ZONE | Attached to a specific zone. |
Controlling Execution: TerminateReturn
The system allows listeners to return values to the core. A TerminateReturn can be used to cancel an action entirely or interrupt the chain of listeners.
Example: Preventing Death
By setting a high priority, you can intercept an event before it happens.
@RegisterEvent(EventType.CREATURE_KILL)
@RegisterType(ListenerRegisterType.GLOBAL_PLAYERS)
@Priority(Integer.MAX_VALUE) // Ensure this runs FIRST
public TerminateReturn onPlayerDeath(CreatureKill event) {
if (event.target().isGM()) {
event.target().setCurrentHp(event.target().getMaxHp());
return new TerminateReturn(true, true, true); // Abort the death!
}
return null;
}
The three booleans in TerminateReturn(boolean abort, boolean override, boolean value) represent:
- Abort: Stop notifying other listeners.
- Override: Replace any previous return value.
- Value: The actual boolean result to return to the core (e.g., "Was this action successful?").
Advanced Example: Tracking Respawn
L2J does not have a single PLAYER_RESPAWN event because respawning is a complex process involving multiple steps (dying, choosing a point, and teleporting). However, you can use the power of the Listener System to track this lifecycle efficiently.
The following script, RespawnListener.java, demonstrates a robust approach. Instead of complex dynamic listeners, it uses a shared state (Set) to track dead players and processes them when they next teleport (which signifies a respawn clicking "To Village").
public class RespawnListener extends AbstractNpcAI {
private static final int ELPY_ID = 20432;
private final Set<Integer> _pendingRespawn = ConcurrentHashMap.newKeySet();
@RegisterEvent(CREATURE_KILL)
@RegisterType(GLOBAL_PLAYERS)
public void onPlayerDeath(CreatureKill event) {
final var target = event.target();
if (target.isPlayer()) {
_pendingRespawn.add(target.getObjectId());
}
}
@RegisterEvent(CREATURE_TELEPORTED)
@RegisterType(GLOBAL_PLAYERS)
public void onPlayerTeleport(CreatureTeleported event) {
final var creature = event.creature();
if (creature.isPlayer() && _pendingRespawn.remove(creature.getObjectId())) {
// Spawn the NPC at the player's new location.
// Parameters: npcId, x, y, z, heading, randomOffset, despawnTime
addSpawn(ELPY_ID, creature.getX(), creature.getY(), creature.getZ(), creature.getHeading(), false, 60000);
}
}
@RegisterEvent(PLAYER_LOGOUT)
@RegisterType(GLOBAL_PLAYERS)
public void onPlayerLogout(PlayerLogout event) {
_pendingRespawn.remove(event.player().getObjectId());
}
}
This pattern is highly efficient because it uses permanent listeners and keeps the state management self-cleaning. It is perfect for implementing systems like "Resurrection Buffs", "Spawn Protection", or complex quest triggers after death.
Best Practices and Tips
- Use Annotations: They are easier to read and automatically managed by
AbstractScript(e.g., they unregister automatically when a script is reloaded). - Set Thoughtful Priorities:
Integer.MAX_VALUE: For "Must-run-first" services like logging or security.- Positive values: For logic that needs to happen before standard game mechanics.
- Negative values: For logic that should only happen if nothing else cancelled the event.
- Check for Nulls: Always verify
event.attacker()orevent.target()aren't null, as environmental deaths or complex skills can produce unexpected event states. - Avoid Heavy Logic: Since many events (like
ATTACK) fire frequently, keep the logic inside the listener fast. UseThreadPoolManagerif you need to perform heavy calculations or database operations.