Events API
Event system for inter-script communication and modular script architecture.
The Events API allows scripts to subscribe to game events and custom events, emit events with data, and build modular, event-driven script architectures. This enables multiple scripts to communicate without tight coupling.
Using variables (global state) with events: Event handlers run in a separate Lua engine from your main script. If you need to use variables or shared state between your main script and event handlers (e.g. counters, flags, or data that both sides read or write), you must use the shared API. See Events and Shared State for the architecture and how to use shared:int(), shared:string(), shared:bool(), etc.
Event Types
The events.EventType enum provides constants for built-in game events:
| Event Type | Value | Description |
|---|---|---|
TICK | "tick" | Fired every game tick (~600ms) |
MESSAGE_RECEIVED | "message_received" | Fired when a chat message is received |
GAME_ACTION | "game_action" | Fired when a game action is performed |
PROJECTILE_DESTINATION_CHANGED | "projectile_destination_changed" | Fired when a projectile's destination changes |
Functions
on
events:on(event_name: string, handler: function)
Subscribe to an event with a handler function.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
event_name | string | Yes | Name of the event to subscribe to (use events.EventType for built-in events) |
handler | function | Yes | Function to call when the event is emitted |
Example:
-- Listen for game tick events
events:on(events.EventType.TICK, function()
logger:info('Game tick!')
end)
-- Listen for chat messages
events:on(events.EventType.MESSAGE_RECEIVED, function(data)
logger:info('Message type: ' .. data.msg_type)
logger:info('Sender: ' .. data.sender)
logger:info('Content: ' .. data.content)
end)
-- Listen for game actions
events:on(events.EventType.GAME_ACTION, function(data)
logger:info('Action: ' .. data.interaction)
logger:info('Scene position: ' .. data.scene_x .. ', ' .. data.scene_y)
logger:info('Opcode: ' .. data.opcode)
logger:info('Entity index: ' .. data.entity_idx)
end)
-- Listen for projectile events
events:on(events.EventType.PROJECTILE_DESTINATION_CHANGED, function(data)
logger:info('Projectile ' .. data.projectile_id .. ' changed destination')
logger:info('Target index: ' .. data.target_index)
logger:info('Start: ' .. data.start_x .. ', ' .. data.start_y)
logger:info('Dest: ' .. data.dest_x .. ', ' .. data.dest_y)
logger:info('Current: ' .. data.current_x .. ', ' .. data.current_y)
end)
-- Custom events
events:on('player_level_up', function(skill, level)
logger:info(skill .. ' leveled up to ' .. level)
end)
off
events:off(event_name: string)
Unsubscribe all handlers for a specific event.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
event_name | string | Yes | Name of the event to unsubscribe from |
Example:
-- Remove all handlers for tick events
events:off(events.EventType.TICK)
-- Remove custom event handlers
events:off('player_level_up')
clear
events:clear()
Clear all event handlers for all events.
Example:
-- Remove all event handlers
events:clear()
list
events:list() -> table
List all registered events and their handler counts.
Returns:
table- Map of event names to handler counts
Example:
local handlers = events:list()
for event_name, count in pairs(handlers) do
logger:info(event_name .. ' has ' .. count .. ' handlers')
end
emit
events:emit(event_name: string, ...)
Emit a custom event with optional arguments.
Note: Built-in game events (TICK, MESSAGE_RECEIVED, etc.) are emitted automatically by the engine. Use this to emit your own custom events.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
event_name | string | Yes | Name of the event to emit |
... | any | Yes | Optional arguments to pass to handlers |
Example:
-- Emit custom event without arguments
events:emit('combat_started')
-- Emit custom event with arguments
events:emit('player_level_up', 'Strength', 99)
-- Emit event with table data
events:emit('loot_received', {
item = 'Dragon bones',
quantity = 15,
value = 3000
})
Event Data Structures
MessageReceived Event
{
msg_type = number, -- Message type ID
sender = string, -- Sender name
content = string -- Message content
}
GameAction Event
{
scene_x = number, -- Scene X coordinate
scene_y = number, -- Scene Y coordinate
opcode = number, -- Action opcode
entity_idx = number, -- Entity index
subject = number, -- Subject ID
interaction = string, -- Interaction text
mouse_x = number, -- Mouse X position
mouse_y = number -- Mouse Y position
}
ProjectileDestinationChanged Event
{
projectile_id = number, -- Projectile ID
target_index = number, -- Target entity index
start_x = number, -- Start X coordinate
start_y = number, -- Start Y coordinate
dest_x = number, -- Destination X coordinate
dest_y = number, -- Destination Y coordinate
tile_x = number, -- Tile X position
tile_y = number, -- Tile Y position
current_x = number, -- Current X position (decimal)
current_y = number, -- Current Y position (decimal)
cycle_start = number, -- Start cycle
cycle_end = number -- End cycle
}
Common Patterns
Patterns below that keep state (counters, flags, tables) used by event handlers must use the shared API, since handlers run in a separate Lua engine.
Tick-Based Logic
Event handlers run in a separate engine, so use the shared API for counters or state that the handler updates:
local tick_count = shared:int("tick_count")
events:on(events.EventType.TICK, function()
tick_count:add(1)
-- Run every 5 ticks (~3 seconds)
if tick_count:get() % 5 == 0 then
logger:info('5 ticks elapsed')
end
end)
Chat Message Filtering
events:on(events.EventType.MESSAGE_RECEIVED, function(data)
-- Filter for specific message types or senders
if data.sender == 'Friend' then
logger:info('Message from friend: ' .. data.content)
end
-- Check for specific keywords
if string.find(data.content:lower(), 'trade') then
logger:info('Trade request detected')
end
end)
Combat Detection via Projectiles
local function is_targeting_player(target_index)
local player = players:local_player()
if not player then return false end
-- Check if target index matches player index
return target_index == player:index()
end
events:on(events.EventType.PROJECTILE_DESTINATION_CHANGED, function(data)
if is_targeting_player(data.target_index) then
logger:warn('Incoming projectile! ID: ' .. data.projectile_id)
-- Take defensive action
end
end)
Action Logging and Analytics
Use a shared table for counts so the handler and main script can both access it:
local action_counts = shared:table("action_counts")
events:on(events.EventType.GAME_ACTION, function(data)
local action = data.interaction
local count = tonumber(action_counts:get(action)) or 0
action_counts:set(action, tostring(count + 1))
logger:info('Action performed: ' .. action)
end)
-- Print statistics after script stops (read from shared:table in handler)
events:on('script_stopping', function()
logger:info('=== Action Statistics ===')
-- Read action_counts:get(key) for each key you care about
end)
Modular Script Architecture
-- combat_module.lua
local function init_combat_events()
events:on(events.EventType.TICK, function()
local player = players:local_player()
if player and not player:is_in_combat() then
local enemy = npcs:find_nearest({name = 'Goblin'})
if enemy then
events:emit('enemy_found', enemy)
end
end
end)
events:on('enemy_found', function(npc)
logger:info('Engaging ' .. npc:name())
npc:smart_interact({action = 'Attack'})
end)
end
-- health_module.lua
local function init_health_events()
local last_health = 100
events:on(events.EventType.TICK, function()
local current = stats:current_hitpoints()
if current < last_health then
events:emit('health_decreased', current, last_health - current)
end
if current < 20 then
events:emit('health_low', current)
end
last_health = current
end)
events:on('health_low', function(hp)
logger:warn('Low health: ' .. hp)
-- Eat food or teleport
end)
end
-- main.lua
init_combat_events()
init_health_events()
State Machine with Events
Use shared state for the current state so both the tick handler and the state_change handler can read/write it:
local state = shared:string("state")
state:set('IDLE')
events:on(events.EventType.TICK, function()
local current = state:get()
if current == 'IDLE' then
local resource = game_objects:find_nearest({name = 'Tree'})
if resource then
events:emit('state_change', 'GATHERING')
end
elseif current == 'GATHERING' then
local player = players:local_player()
if player and not player:is_animating() then
events:emit('state_change', 'BANKING')
end
elseif current == 'BANKING' then
if inventory:is_empty() then
events:emit('state_change', 'IDLE')
end
end
end)
events:on('state_change', function(new_state)
logger:info('State: ' .. state:get() .. ' -> ' .. new_state)
state:set(new_state)
end)
Conditional Event Subscriptions
Use a shared boolean so the handler (separate engine) can read the flag:
local monitoring_enabled = shared:bool("monitoring_enabled")
monitoring_enabled:set(true)
-- Start monitoring
local function start_monitoring()
events:on(events.EventType.GAME_ACTION, function(data)
if not monitoring_enabled:get() then return end
logger:info('Action logged: ' .. data.interaction)
end)
end
-- Stop monitoring without removing handler
local function stop_monitoring()
monitoring_enabled:set(false)
end
-- Resume monitoring
local function resume_monitoring()
monitoring_enabled:set(true)
end
Best Practices
-
Use EventType constants: Always use
events.EventType.TICKinstead of string literals like"tick"for built-in events -
Shared state for variables: Event handlers run in a separate engine and cannot see your script's local variables. Use the shared API for any state that must be read or updated from both the main script and event handlers (counters, flags, status strings, etc.)
-
Document custom events: Clearly document what arguments your custom events pass to handlers
-
Clean up handlers: Use
events:off()to remove handlers when they're no longer needed to prevent memory leaks -
Error handling: Event handlers are called safely - if one handler errors, other handlers will still execute
-
Performance: Game events (TICK, MESSAGE_RECEIVED, etc.) fire frequently. Keep handlers lightweight
-
Modularity: Use events to decouple different parts of your script for better organization
-
Subscription order: Handlers are called in the order they were registered
-
Custom events: Use custom events (
events:emit()) for your own application logic; built-in events are emitted by the engine
Related APIs
- Events and Shared State - Using variables and shared state with event handlers (required when main script and handlers need to share state)
- Logger API - Logging functionality
- Players API - Player information
- Stats API - Player stats
- Chat API - Chat interaction
- Game API - Game state