React to events
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.
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.
xiao.signal.add("world_change", "reset", function(old, new, reason)
print("world", reason, new.dimension_name, new.session)
end)
No entity_remove is fired for every entity when a session ends. Listen to world_change and forget your entities
there, as the full example below does.
Frame tick and unload
-
think(dt)runs every frame afteron_frameand before the paint listeners,dtis the frame time in seconds. Use it for polling. -
unload()runs on Clear VM and when the client unloads, afteron_unload. Use it to save state or restore what you changed;xiao.workspacetables 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.
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.
chatanddamagemay arrive several in the same frame, in order.xiao.signal.addfordamageis 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.