libscript
libscript is the Lua layer. It embeds gopher-lua in a
sandbox, injects a set of globals that wrap the live game client, and runs your .lua files
as long-lived plugins with a tick loop and event hooks.
Two programs can load a plugin: the desktop app's trainer and the console. They share the same engine and the same Lua API, so a plugin file does not care which one started it — but they do not offer identical features, and this wiki flags the differences where they bite.
What you get
Four globals are always there, in both hosts:
| Global | Purpose |
|---|---|
clients | The list of hooked game clients. Everything starts here. |
utils | Sleeping, logging, retries, timing helpers |
events | Subscribe to zone changes, combat, dialogs, hotkeys |
paths | Named game-window paths, mirroring libwiz's registry |
Two more are supplied by the host rather than by libscript itself:
| Global | Availability |
|---|---|
automation | Present in both the desktop trainer and the console. Starts and stops the libstrat combat runner. |
forge | Not wired up by either shipped host. See below. |
Plus plugin, which is the table you define. Seven capability modules — inventory, pet,
fishing, spell, equipment, garden, drops — are opt-in through require(), so a
plugin that never touches the backpack does not pay for reading it.
forge is currently nilforge only exists when the host passes libscript an action executor, and neither the desktop
trainer nor the console does today. Calling forge.Execute(...) from a plugin raises an
"attempt to index a non-table object" error, which aborts the callback and puts the plugin
into error status.
Guard it the way plugins guard automation if you want to be forward-compatible:
if forge ~= nil then
forge.Execute('teleport', { x = 100, y = 200 })
end
Everything forge would do has a direct equivalent on the client object — c:Teleport,
c:SendKey, c:Goto — so there is no reason to reach for it. The
forge reference documents the action vocabulary for when it returns.
The shape of a plugin
Every plugin is one file that assigns a global table called plugin:
---@type KebabPlugin
plugin = {
name = 'my_bot',
version = '1.0.0',
description = 'Does a thing',
}
plugin.on_load = function() end -- once, when the plugin starts
plugin.on_tick = function() end -- on every host tick while running
plugin.on_combat_enter = function(d) end
plugin.on_stop = function() end -- when explicitly stopped
The loader reads name, version, author, description, and hooks out of that table at
load time, then calls back into it. There is no registration call and no base class to
inherit — if the field exists, it gets called.
See Plugin lifecycle for the full list of callbacks, when each one fires, and which events each host actually delivers.
Design notes worth knowing up front
Never hardcode the tick interval. Both shipped hosts tick at 250ms today, but write
utils.Ticks(seconds) to convert a duration into a tick count and utils.TickMS() if you
need the raw interval. A plugin written against utils.Ticks follows the host if the number
ever changes; a plugin with local TICK_MS = 250 silently drifts.
Everything returns (value, err) or (ok, err). There are no Lua exceptions in this API.
A teleport that fails returns false, "some reason". A read that fails usually returns a zero
value plus an error string. Check both, because "no error" and "useful value" are not the same
thing — see the note on PotionCount.
The one systematic exception: reading an opt-in sub-object you never required (c.inventory
without require('inventory')) raises a real Lua error rather than returning nil.
Entities are identified three different ways and picking the wrong one is the single most
common source of "my bot can't find the mob". display_name is what the player reads,
template_name is the internal object name, and name is the raw debug name. The
entity guide explains which to match against and why
entity:Nearest searches all three.
Your plugin does not own combat. Once a duel starts, hand off to libstrat via
automation:EnsureCombat and stop moving. Issuing teleports during a fight is how you get a
client stuck between the duel circle and wherever you told it to go.
You cannot make the game run its own code, and should not want to. Client:ExecuteCommand
exists in the annotations but is hard-disabled at the libwiz layer and always fails with
remote code execution is disabled. Injecting calls into the client is a confirmed
account-ban vector; the supported ways to make the game do something are memory writes
(teleport, stat reads) and clicking its real UI. Everything on the
Windows and UI page goes through the second one.
Under the hood
The sandbox, the module registry, the plugin loader, and the tick loop are a general-purpose
Lua plugin host; the game bindings — everything under client, entity, combat, quest,
and the rest — are the only part that actually knows about Wizard101.
Read Architecture for how those fit together, or skip straight to Getting started and write something.