Events
Subscribe to lifecycle + packet events via client:on(eventName, handler). The dispatcher task is spawned lazily on the first registration for a bot and shares one registry across every getClient() handle.
⚠️ Handlers run on the runtime's event loop — keep them lightweight and async-safe. Heavy work (network, multi-second sleeps) blocks every other event for that bot. Push slow work to a
runThreadinstead.
API
client:on(event, callback)
| Field | Type |
|---|---|
| Signature | (self: Client, event: string, callback: (any) -> ()) → () |
| Returns | nothing — the runtime keeps the callback alive in the per-bot registry until the script ends |
| Async | no — registration is sync; the dispatcher fires callbacks asynchronously |
local client = getClient()
client:on(eventName, function(...)
-- handler body
end)
Anything outside the table below is silently registered but never fires.
Lifecycle events
Edge-detected from the bot snapshot at 50 ms cadence. "connect" fires on the first tick where status reaches MENU_READY or IN_WORLD; "disconnect" fires on the first tick that leaves that connected set.
| Event | When | Handler args |
|---|---|---|
"connect" | Status reaches MENU_READY or IN_WORLD (TCP up + auth complete) | none |
"disconnect" | Status leaves the connected set (any reason) | none |
--!strict
local client = getClient()
if not client then return end
client:on("connect", function()
print("auth done, sitting at menu or in world")
end)
client:on("disconnect", function()
print("dropped")
end)
Packet events
Hook raw incoming BSON packets. Two forms — both can be active at the same time on the same packet.
| Pattern | Matches | Handler args |
|---|---|---|
"p" | every inbound packet | doc: table |
"p:<ID>" | only packets whose ID field matches (e.g. "p:WCM", "p:OoIP") | doc: table |
doc is the BSON envelope decoded as a Lua table — keys mirror the wire field names. The packet id lives at doc.ID (catch-all listeners read it from there).
--!strict
local client = getClient()
if not client then return end
-- Watch every chat broadcast (BGM = "broadcast game message")
client:on("p:BGM", function(doc)
local msg = doc.CmB and doc.CmB.message or "<no message>"
local from = doc.CmB and doc.CmB.nick or "<unknown>"
print(("[chat] %s: %s"):format(from, msg))
end)
-- Catch every server redirect
client:on("p:OoIP", function(doc)
print(("server redirected → %s (ER=%s)"):format(doc.IP or "?", doc.ER or ""))
end)
-- Inspect the catch-all stream (id sits in doc.ID)
client:on("p", function(doc)
print("packet:", doc.ID)
end)
BSON
Binaryfields (e.g. AI spawn /WClSDblobs) come through as hex strings, not raw byte arrays — decode withbit32or your own hex parser when needed.
Outbound hook — "presend"
Fires synchronously inside the scheduler tick right before the outbound batch flushes. Unlike every other event (which dispatches post-hoc on a separate task), presend is a direct call — packets you queue inside the callback ride the same TCP write as the trigger batch.
| Field | Type |
|---|---|
| Handler signature | (batch: {Packet}) → () |
| Argument | {Packet} — array of packets currently in the slot batch (heartbeat, movement, pending). Each entry is a table; batch[i].ID is the packet id. |
| Return | ignored |
| Sync | yes — scheduler awaits each callback before flushing |
Example: place + spam-hit a tile every tick
function breakBlocks(targetBlock, maxBlocks, minHits)
client:on("presend", function(batch)
local amount = client:inventory():count(targetBlock, InventoryItemType.block)
local clientPoint = client:point()
local tilePoint = Vector2i.new(clientPoint.x - 1, clientPoint.y)
local blocks = math.min(amount, maxBlocks)
for i = 1, blocks do
client:place(tilePoint, targetBlock, InventoryItemType.block)
for j = 1, minHits do
client:send("HB", { x = tilePoint.x, y = tilePoint.y })
end
end
end)
end
-- Fires every slot tick (~250ms). Heavy — server may disconnect on
-- aggressive flood values. Tune carefully.
breakBlocks(2735, 40, 4)
Heads up:
presendruns every tick, even mc=0 heartbeat ticks. Anything heavy you do here multiplies by ~4×/sec. Use a counter / time-gate inside the callback if you don't want it on every tick.
Cleanup
Handlers stay registered for the lifetime of the bot's script runtime. Short-lived scripts (run once, exit) get cleared automatically on shutdown — no manual cleanup needed. There is no client:off: the registry doesn't surface a per-handler detach API today, so re-running the same registration code accumulates duplicate subscribers and every event fires N times.
For long-running scripts that re-arm on each iteration, register handlers once at startup outside any loop:
local client = getClient()
-- Register once at the top of the script.
client:on("connect", function() print("connected") end)
client:on("p:BGM", function(doc) print("chat:", doc.CmB and doc.CmB.message) end)
-- Then loop.
while true do
-- main script work…
sleep(1000)
end
See also
Client → Generic packet send—client:send(name, params)is the inverse ofclient:on("p:<ID>", …)- Threading — push slow work off the event loop with
runThread