React to events

Listen to chat, entity_add, entity_remove, damage, world_change, think and unload with xiao.signal.

This guide shows how to react to what happens in the game without polling: chat messages, entities appearing and leaving, damage, world changes, the frame tick and the unload of the VM.

How signals work

A signal callback is registered under a signal name and an identity: xiao.signal.add(name, identity, callback).

  • Registering the same identity again replaces its callback, so re-executing a script never stacks callbacks.
  • Callbacks run in the order their identity was first added.
  • Signals are shared by every execution in the VM.
  • A callback that raises an error while the client fires it is removed and the error is printed in the console.

The client signals below are fired on the render thread, in a fixed order (see Order of a frame), only while a VM is running. Events older than the VM are never delivered, and the first frame of a VM fires no world_change: it only records the current state.

Signal Arguments Limit per frame
world_change old, new, reason 1
entity_add entity 256 (shared with entity_remove)
entity_remove entity 256 (shared with entity_add)
chat message, source, type, meta 64
damage event 64 hits plus 1 local hurt
think dt 1
unload none -

Events past the limit wait for the next frame. The client keeps the last 128 chat messages and the last 512 entity changes, older ones are lost. The complete reference is in xiao.signal.

Chat New

chat fires for every incoming text packet. source is set for the chat, whisper and announcement types and nil otherwise. When meta.needs_translation is true, message is a translation key and meta.parameters holds its arguments. meta.xuid is the sender XUID, "" when the packet has none.

xiao.signal.add("chat", "chat_log", function(message, source, kind, meta)
    if kind == "chat" or kind == "whisper" then
        print(("<%s> %s"):format(source or "?", message))
    elseif meta.needs_translation then
        print(kind, message, table.concat(meta.parameters, ", "))
    else
        print(kind, message)
    end
end)

Texts keep their UTF-8 bytes and color codes, and are cut at 511 bytes.

Entities New

entity_add fires when an AddActor or AddPlayer packet creates a new network entity, entity_remove when a RemoveActor packet removes a known one. The entity_event table has id, unique_id, name, is_player, and when known type, x, y, z, position and distance.

local seen = {}

xiao.signal.add("entity_add", "tracker", function(entity)
    seen[entity.id] = entity
    if entity.is_player then
        print(("player %s entered view at %.0f blocks"):format(entity.name, entity.distance or -1))
    elseif entity.type == "minecraft:creeper" then
        print("creeper spawned")
    end
end)

xiao.signal.add("entity_remove", "tracker", function(entity)
    seen[entity.id] = nil
end)

Damage New

damage fires when the local player hits a mob or a player and its health drops, and when the local player loses health (local_hurt is true). The damage_event table always has lethal, local_hurt and target_is_player; target_id, target_name, source_id, source_name, amount, item, distance, health and max_health are present when known. On a hit, amount and health (health left on the target, 0 on a kill) need a health drop seen on the target: a server that never sends the health of a mob (training dummy, custom entity) gives a hit without amount nor health, never a 0.

xiao.signal.add("damage", "hits", function(event)
    if event.local_hurt then
        print(("-%.1f hp, %.1f left"):format(event.amount or 0, event.health or 0))
    elseif event.amount then
        local kill = event.lethal and " (kill)" or ""
        local target = event.target_name or tostring(event.target_id or "?")
        print(("hit %s for %.1f%s"):format(target, event.amount, kill))
    end
end)

A hit is reported once the health of the target dropped, or 800 ms after the attack packet. The client tracks attacks only while a damage callback exists.

World changes New

world_change(old, new, reason) fires when a field of the world state (in_world, session, dimension) changed since the last frame. old and new are world tables.

reason Meaning
"enter" in_world went from false to true
"leave" in_world went from true to false
"dimension" the dimension changed and in_world did not
"session" only session changed (outside a world, a dimension change that comes with a new session is also "session")
xiao.signal.add("world_change", "reset", function(old, new, reason)
    print("world", reason, new.dimension_name, new.session)
end)

Frame tick and unload

  • think(dt) runs every frame after on_frame and before the paint listeners, dt is the frame time in seconds. Use it for polling.

  • unload() runs on Clear VM and when the client unloads, after on_unload. Use it to save state or restore what you changed; xiao.workspace tables are written right after it.

local elapsed = 0

xiao.signal.add("think", "uptime", function(dt)
    elapsed = elapsed + dt
end)

xiao.signal.add("unload", "uptime", function()
    xiao.fs.write("elapsed.txt", tostring(elapsed))
end)

Timers New

For a delay, an interval or a sequence of steps, use xiao.timer instead of counting dt in think. Timers run on the same frame clock, after the client signals and before on_frame, so a timer has one frame of resolution.

Function Use
after(seconds, fn) call fn(id) once, returns the timer id
every(seconds, fn) call fn(id) every seconds until cancel(id)
cancel(id) stop a timer, true when it existed
spawn(fn, ...) run fn as a coroutine that can wait(seconds)

React to an event now, follow up later:

local announced = {}

xiao.signal.add("entity_add", "greeter", function(entity)
    if not entity.is_player or announced[entity.id] then return end
    announced[entity.id] = true
    xiao.timer.after(5, function()
        print(entity.name .. " has been in view for 5 seconds")
    end)
end)

local status = xiao.timer.every(10, function()
    local me = xiao.game.player()
    if me ~= nil then print(("hp %.0f"):format(me.health or 0)) end
end)

xiao.signal.add("world_change", "greeter", function(old, new, reason)
    announced = {}
    if reason == "leave" then xiao.timer.cancel(status) end
end)

A timer callback reads xiao.game like a signal callback. after, every and cancel also work in a packet hook or a cmd hook, the callback then runs later on the render thread; spawn raises xiao.timer.spawn is not available inside a packet hook or a cmd hook there. Clear VM drops every timer.

Your own signals

xiao.signal.call(name, ...) calls every callback of name and stops at the first one returning a non-nil value. Scripts of the same VM can use it to talk to each other:

xiao.signal.add("greet", "english", function(who) return "hello " .. who end)
print(xiao.signal.call("greet", "world"))

Pitfalls and limits

  • Callbacks and timers run inside the frame budget of 100 ms: keep them short, a timeout clears the VM.
  • Do not rely on events from before the script started, they are not delivered.
  • chat and damage may arrive several in the same frame, in order.
  • xiao.signal.add for damage is what turns attack tracking on: a script without it pays nothing.

Full example: session tracker

local players = {}
local hits = 0

local function reset()
    players = {}
    hits = 0
end

xiao.signal.add("world_change", "session", function(old, new, reason)
    reset()
    print("new world state:", reason, new.dimension_name or "?")
end)

xiao.signal.add("entity_add", "session", function(entity)
    if entity.is_player then players[entity.id] = entity.name end
end)

xiao.signal.add("entity_remove", "session", function(entity)
    players[entity.id] = nil
end)

xiao.signal.add("damage", "session", function(event)
    if not event.local_hurt and event.amount then hits = hits + 1 end
end)

xiao.signal.add("chat", "session", function(message, source, kind)
    if kind == "chat" and message:lower():find("gg", 1, true) then
        print("gg from " .. (source or "?") .. " after " .. hits .. " hits")
    end
end)

xiao.signal.add("unload", "session", function()
    local names = {}
    for _, name in pairs(players) do names[#names + 1] = name end
    print("players in view at unload: " .. table.concat(names, ", "))
end)

Related pages: xiao.signal, xiao.timer, Running scripts, Read the player and the world.