Skip to main content

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:

  1. EventType: An enumeration (constant) representing a specific occurrence in the game (e.g., CREATURE_KILL, PLAYER_LOGIN, NPC_TALK).
  2. BaseEvent: The data carrier. Each event type has a corresponding implementation of BaseEvent (e.g., CreatureKill event) that contains all relevant information about that specific occurrence.
  3. ListenersContainer: A specialized storage container that holds listeners for various events. Many objects in L2J (Players, NPCs, Castles, Clans) implement or own a ListenersContainer.
  4. 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):

  1. Event Instantiation: The core engine creates a new AttackableAttack event object, populating it with the attacker, target, damage, and skill used.
  2. Dispatching: The core calls EventDispatcher.getInstance().notifyEvent(event, targetContainer).
  3. Listener Retrieval: The dispatcher fetches all registered listeners for that EventType from:
    • Local Container: The specific target involved (e.g., the hit Monster).
    • Global Containers: Centralized containers like Containers.Global() or Containers.Players().
  4. Priority Sorting: Listeners are sorted by their priority value (higher values execute first).
  5. Execution: The dispatcher iterates through the listeners and executes them sequentially.
  6. Return/Abort: If a listener returns a TerminateReturn or uses the abort() 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.

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:

TypeDescription
GLOBALListens to the event everywhere in the world.
NPCAttached to NPC Templates. Triggers for all NPCs of that ID.
ITEMAttached to Item Templates.
PLAYERAttached to a specific player instance.
GLOBAL_NPCSListens to events for ALL NPCs globally.
GLOBAL_MONSTERSListens to events for ALL monsters globally.
GLOBAL_PLAYERSListens to events for ALL players globally.
CASTLE / FORTAttached to a specific residence.
ZONEAttached 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:

  1. Abort: Stop notifying other listeners.
  2. Override: Replace any previous return value.
  3. 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

  1. Use Annotations: They are easier to read and automatically managed by AbstractScript (e.g., they unregister automatically when a script is reloaded).
  2. 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.
  3. Check for Nulls: Always verify event.attacker() or event.target() aren't null, as environmental deaths or complex skills can produce unexpected event states.
  4. Avoid Heavy Logic: Since many events (like ATTACK) fire frequently, keep the logic inside the listener fast. Use ThreadPoolManager if you need to perform heavy calculations or database operations.