Skip to main content

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 runThread instead.


API

client:on(event, callback)

FieldType
Signature(self: Client, event: string, callback: (any) -> ()) → ()
Returnsnothing — the runtime keeps the callback alive in the per-bot registry until the script ends
Asyncno — 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.

EventWhenHandler 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.

PatternMatchesHandler args
"p"every inbound packetdoc: 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 Binary fields (e.g. AI spawn / WClSD blobs) come through as hex strings, not raw byte arrays — decode with bit32 or 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.

FieldType
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.
Returnignored
Syncyes — 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: presend runs 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