Events and Shared State

Events and Shared State Guide

This guide explains the event system architecture and how to use shared state for cross-engine communication in your scripts.

Architecture Overview

The scripting engine uses a dual Lua engine architecture:

  • Main Lua Engine: Runs your script's main logic (on_start, poll, on_stop)
  • Event Lua Engine: Dedicated to event handlers, runs on the event thread

This architecture enables immediate event dispatch with near-zero latency. When a game event occurs, it dispatches directly to the Event Lua Engine without waiting for the Main Engine.

Game Thread                     Event Thread                  Script Thread
     |                               |                              |
  emit() ──────────────────> Event Lua Engine                       |
     |                         (immediate dispatch)                 |
     |                         handler runs:                        |
     |                           shared.x:increment()               |
     |                               |                              |
     |                               v                              |
     |                        SharedStore (Rust)  <────────>  Main Lua Engine
     |                         (lock-free atomics)            shared.x:get()
     |                                                        poll(), on_start()

The Shared State API

Since event handlers run in a separate Lua engine, they cannot access local variables from your main script. The shared API provides a Rust-backed store for cross-engine state synchronization using atomic operations.

Creating Shared Values

-- Create typed shared values
local counter = shared:int("counter")        -- Integer value
local ratio = shared:float("ratio")          -- Float value
local status = shared:string("status")       -- String value
local enabled = shared:bool("enabled")       -- Boolean value
local tbl = shared:table("my_table")        -- Table value (key-value store)

Integer Operations

local count = shared:int("ore_count")

count:set(0)           -- Set value
count:get()            -- Get current value (returns 0)
count:increment()      -- Add 1, returns new value
count:decrement()      -- Subtract 1, returns new value
count:add(5)           -- Add arbitrary amount, returns new value

Float Operations

local xp_rate = shared:float("xp_per_hour")

xp_rate:set(50000.5)   -- Set value
xp_rate:get()          -- Get current value
xp_rate:add(100.25)    -- Add amount, returns new value

String Operations

local last_action = shared:string("last_action")

last_action:set("Mining")       -- Set value
last_action:get()               -- Get current value
last_action:append(" Iron")     -- Append to string

Boolean Operations

local is_banking = shared:bool("is_banking")

is_banking:set(true)   -- Set value
is_banking:get()       -- Get current value
is_banking:toggle()    -- Toggle and return new value

Table Operations

Shared tables provide a key-value store that can hold integers, floats, strings, booleans, and even nested tables. Tables are useful for storing structured data that needs to be shared between engines.

local player_data = shared:table("player_data")

-- Set values with string keys
player_data:set("name", "Alice")
player_data:set("level", 99)
player_data:set("xp", 13034431.5)
player_data:set("is_member", true)

-- Get values (returns nil if key doesn't exist)
local name = player_data:get("name")           -- Returns "Alice"
local level = player_data:get("level")         -- Returns 99
local missing = player_data:get("missing")     -- Returns nil

-- Check if a key exists
if player_data:contains("name") then
    logger:info("Player name is set")
end

-- Remove a key
player_data:remove("xp")

-- Clear all entries
player_data:clear()

-- to_table() returns a snapshot as a plain Lua table (keys and values copied; independent of
-- the shared store). Use for iteration or when you need a mutable copy.
local lua_table = player_data:to_table()
for key, value in pairs(lua_table) do
    logger:info(key .. " = " .. tostring(value))
end

-- Get all keys as an array
local keys = player_data:keys()
for i, key in ipairs(keys) do
    logger:info("Key " .. i .. ": " .. key)
end

Nested Tables

Shared tables support nested tables for complex data structures:

local config = shared:table("config")

-- Create a nested table
local nested = {
    setting1 = 100,
    setting2 = "value",
    setting3 = true
}
config:set("settings", nested)

-- Access nested values
local settings = config:get("settings")
if type(settings) == "table" then
    logger:info("Setting 1: " .. tostring(settings.setting1))
end

Note: Table keys must be strings. When setting values, numeric keys are automatically converted to strings.

Utility Methods

shared:contains("counter")  -- Check if key exists (returns bool)
shared:remove("counter")    -- Remove a specific key
shared:clear()              -- Clear all shared values

Using Events with Shared State

Basic Pattern

-- Create shared state at the top of your script
local ores_mined = shared:int("ores_mined")
local last_ore = shared:string("last_ore")

-- Register event handler (runs in Event Lua Engine)
events:on(events.EventType.GAME_ACTION, function(data)
    if data.interaction:find("Mine") then
        ores_mined:increment()
        last_ore:set(data.interaction)
    end
end)

-- Main script logic (runs in Main Lua Engine)
function poll()
    logger:info("Mined " .. ores_mined:get() .. " ores")
    logger:info("Last ore: " .. last_ore:get())
end

Combat Tracking Example

local damage_dealt = shared:int("damage_dealt")
local kills = shared:int("kills")
local in_combat = shared:bool("in_combat")

events:on(events.EventType.GAME_ACTION, function(data)
    if data.interaction == "Attack" then
        in_combat:set(true)
    end
end)

function poll()
    local player = players:local_player()
    if player and not player:is_in_combat() then
        in_combat:set(false)
    end
    
    logger:info(string.format(
        "Combat stats - Damage: %d, Kills: %d, In combat: %s",
        damage_dealt:get(),
        kills:get(),
        tostring(in_combat:get())
    ))
end

Chat Monitor Example

local trade_requests = shared:int("trade_requests")
local last_whisper = shared:string("last_whisper")

events:on(events.EventType.MESSAGE_RECEIVED, function(data)
    -- Count trade requests
    if data.content:lower():find("trade") then
        trade_requests:increment()
    end
    
    -- Track whispers
    if data.msg_type == 7 then  -- Private message type
        last_whisper:set(data.sender .. ": " .. data.content)
    end
end)

function poll()
    local requests = trade_requests:get()
    if requests > 0 then
        logger:info("Trade requests received: " .. requests)
    end
    
    local whisper = last_whisper:get()
    if whisper ~= "" then
        logger:info("Last whisper: " .. whisper)
    end
end

Player Stats Tracking Example (Using Tables)

local player_stats = shared:table("player_stats")

events:on(events.EventType.GAME_ACTION, function(data)
    -- Track different action types
    if data.interaction:find("Attack") then
        local attacks = player_stats:get("attacks") or 0
        player_stats:set("attacks", attacks + 1)
    elseif data.interaction:find("Pick") then
        local picks = player_stats:get("picks") or 0
        player_stats:set("picks", picks + 1)
    end
end)

events:on(events.EventType.TICK, function()
    -- Update last action time periodically
    local current_time = os.time()
    player_stats:set("last_action_time", current_time)
end)

function poll()
    local stats = player_stats:to_table()
    
    -- Display formatted stats
    logger:info("Player Stats:")
    logger:info("  Attacks: " .. tostring(stats.attacks or 0))
    logger:info("  Picks: " .. tostring(stats.picks or 0))
    
    if stats.last_action_time then
        logger:info("  Last action: " .. tostring(stats.last_action_time))
    end
end

Important Constraints

Event Handlers Must Be Self-Contained

Event handlers run in the Event Lua Engine, which is separate from the Main Lua Engine. This means:

  1. No access to local script variables - Use shared instead
  2. No calling functions that modify local state - Keep handlers simple
  3. Game APIs work fine - They're Rust-backed and thread-safe

Wrong: Using Local Variables

-- BAD: This will NOT work as expected
local mining_active = false

events:on(events.EventType.GAME_ACTION, function(data)
    if mining_active then  -- Always false in Event Lua Engine!
        logger:info("Mining action detected")
    end
end)

function poll()
    mining_active = true  -- Only affects Main Lua Engine
end

Correct: Using Shared State

-- GOOD: Use shared state for cross-engine communication
local mining_active = shared:bool("mining_active")

events:on(events.EventType.GAME_ACTION, function(data)
    if mining_active:get() then  -- Reads from shared store
        logger:info("Mining action detected")
    end
end)

function poll()
    mining_active:set(true)  -- Both engines see this
end

Both Engines Load the Same Script

When your script starts:

  1. The Main Lua Engine loads and executes your script (calls on_start, runs poll loop)
  2. The Event Lua Engine loads and executes the same script (only registers event handlers)

This means:

  • options values are identical in both engines
  • Pure functions defined in your script work in both engines
  • Top-level code that registers events via events:on() runs in both, but only the Event Lua Engine's handlers are invoked

Error Handling

Errors in event handlers are logged but don't crash your script. This ensures that a bug in one handler doesn't break the entire event system.

events:on(events.EventType.TICK, function()
    -- If this errors, it will be logged and the script continues
    local result = some_function_that_might_fail()
end)

Performance Considerations

  1. Keep handlers lightweight - Event handlers should complete quickly to avoid blocking the event thread

  2. Use appropriate types - Integers and booleans use lock-free atomics (fastest). Floats, strings, and tables use locks (still fast, but slightly slower under high contention). Use simple types when possible, and reserve tables for structured data needs.

  3. Batch reads in poll() - If you need multiple shared values, read them all at the start of your poll function

function poll()
    -- Read all shared state at once
    local ore_count = ores_mined:get()
    local fish_count = fish_caught:get()
    local is_active = script_active:get()
    
    -- Then use the values
    if is_active then
        logger:info("Ores: " .. ore_count .. ", Fish: " .. fish_count)
    end
end

Complete Example: Mining Script with Event Tracking

-- Shared state for cross-engine communication
local ores_mined = shared:int("ores_mined")
local xp_gained = shared:float("xp_gained")
local current_action = shared:string("current_action")
local is_mining = shared:bool("is_mining")
local ore_stats = shared:table("ore_stats")  -- Track stats per ore type

-- Track mining actions
events:on(events.EventType.GAME_ACTION, function(data)
    if data.interaction:find("Mine") then
        ores_mined:increment()
        current_action:set(data.interaction)
        is_mining:set(true)
        
        -- Track stats per ore type (extract ore name from interaction)
        local ore_name = data.interaction:match("Mine (.+)")
        if ore_name then
            local current_count = ore_stats:get(ore_name) or 0
            ore_stats:set(ore_name, current_count + 1)
        end
    end
end)

-- Track XP drops (example - actual implementation depends on game data)
events:on(events.EventType.TICK, function()
    -- This runs every game tick in the Event Lua Engine
    -- Could track XP changes here if needed
end)

function on_start()
    logger:info("Mining script started")
    ores_mined:set(0)
    xp_gained:set(0)
    current_action:set("Starting")
    is_mining:set(false)
end

function poll()
    local player = players:local_player()
    if not player then return end
    
    -- Check if we stopped mining
    if not player:is_animating() then
        is_mining:set(false)
    end
    
    -- Display stats
    local stats = string.format(
        "Ores: %d | XP: %.0f | Action: %s | Mining: %s",
        ores_mined:get(),
        xp_gained:get(),
        current_action:get(),
        tostring(is_mining:get())
    )
    logger:info(stats)
    
    -- Display ore stats from table
    local stats_table = ore_stats:to_table()
    if next(stats_table) then
        local ore_list = {}
        for ore_name, count in pairs(stats_table) do
            table.insert(ore_list, ore_name .. ": " .. tostring(count))
        end
        logger:info("Ore breakdown: " .. table.concat(ore_list, ", "))
    end
    
    -- Find and mine rocks if not busy
    if not is_mining:get() then
        local rock = game_objects:find_nearest({name = "Rocks"})
        if rock then
            rock:interact({action = "Mine"})
        end
    end
end

function on_stop()
    logger:info("Final stats - Ores mined: " .. ores_mined:get())
end