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 TypeValueDescription
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:

ParameterTypeRequiredDescription
event_namestringYesName of the event to subscribe to (use events.EventType for built-in events)
handlerfunctionYesFunction 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:

ParameterTypeRequiredDescription
event_namestringYesName 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:

ParameterTypeRequiredDescription
event_namestringYesName of the event to emit
...anyYesOptional 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

  1. Use EventType constants: Always use events.EventType.TICK instead of string literals like "tick" for built-in events

  2. 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.)

  3. Document custom events: Clearly document what arguments your custom events pass to handlers

  4. Clean up handlers: Use events:off() to remove handlers when they're no longer needed to prevent memory leaks

  5. Error handling: Event handlers are called safely - if one handler errors, other handlers will still execute

  6. Performance: Game events (TICK, MESSAGE_RECEIVED, etc.) fire frequently. Keep handlers lightweight

  7. Modularity: Use events to decouple different parts of your script for better organization

  8. Subscription order: Handlers are called in the order they were registered

  9. Custom events: Use custom events (events:emit()) for your own application logic; built-in events are emitted by the engine


  • 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