Skip to main content

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:

GlobalPurpose
clientsThe list of hooked game clients. Everything starts here.
utilsSleeping, logging, retries, timing helpers
eventsSubscribe to zone changes, combat, dialogs, hotkeys
pathsNamed game-window paths, mirroring libwiz's registry

Two more are supplied by the host rather than by libscript itself:

GlobalAvailability
automationPresent in both the desktop trainer and the console. Starts and stops the libstrat combat runner.
forgeNot 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 nil

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