Skip to main content

Architecture

You do not need this page to write a plugin. It helps when a plugin behaves in a way that is surprising, and you want to understand why.

The layers

The sandbox, the module registry, the plugin loader, and the tick loop know nothing about Wizard101 specifically — they are a general-purpose Lua plugin host. All the game knowledge sits in the bindings layer, which is the only layer that actually talks to the game client.

One VM per plugin

Every plugin file gets its own Lua state. Two plugins cannot see each other's globals, cannot share a table, and cannot collide over a variable name. There is no inter-plugin communication channel: if two plugins need to coordinate, they do it through the game (or through automation's ownership field), not through Lua.

That also means the cost of a plugin is not free — each one carries its own copy of the globals and its own metatables — but it is small, and the isolation is worth it.

The sandbox

Each state is opened with a deliberately narrow standard library:

LibraryAvailable?
base, table, string, mathYes
osNo
ioNo
debugNo
coroutineNo

dofile, loadfile, load, and loadstring are additionally set to nil, so a plugin cannot compile a string into a function at runtime.

package is replaced with a sandboxed version whose only searcher looks in package.preload. This is why require("inventory") works and require("socket") does not — there is no filesystem searcher, and package.path is empty. Requiring an unknown name gives you module 'X' is not available; require() it only for known modules.

Practical consequences for plugin authors:

  • No os.time(), no os.date(), no os.clock(), no io.open. If you need the clock, you are usually counting ticks instead, which is the right answer anyway.
  • No require of your own files. A plugin is one file. If it needs to be smaller, it needs to do less.
  • No coroutines. The "yield until later" shape you might reach for has to be a state machine across ticks instead — see Ticks and timing.
  • print exists but writes to the host process's stdout, which in the console's full-screen terminal UI corrupts the display. Use utils.Log.

The per-call timeout

Every entry into Lua — on_load, each on_tick, each event callback, execute — runs under a deadline. libscript's own default is 30 seconds, but both shipped hosts lower it to 10 seconds. A callback still running at the deadline is cancelled and the plugin's status flips to error.

The deadline is a context, and that context is threaded into the game bindings as well, so a utils.Sleep or a blocking WaitFor* inside the callback aborts at the deadline rather than running past it. It is not a hard kill of a tight while true do end loop, though — a Lua loop that never calls back into Go can still wedge its VM.

Practical upshot: never ask for a wait longer than about 10 seconds inside a single callback. utils.WaitUntil's own default timeout is 30 seconds, which is longer than the call budget — pass an explicit timeout_ms under 10000 if you use it at all.

Always-on versus opt-in globals

Every global follows the same idea: it is registered once, and the registry decides how it becomes visible.

Always-on globals are installed as real globals the moment a plugin's VM is built: clients, utils, events, and paths.

Host-supplied globals are registered only if the program embedding libscript hands it the matching capability. automation appears when the host provides a combat controller (both the desktop trainer and the console do). forge appears when the host provides an action executor (neither shipped host does, so forge is nil today).

Opt-in modules are only preloaded, so they cost nothing until a script asks for them:

local inventory = require('inventory')

For the opt-in modules, the require does double duty: it returns the module table and flips on the matching client sub-object. require('inventory') is what enables c.inventory; before that call, reading c.inventory raises inventory module not required - call require("inventory") first. The seven are inventory, pet, fishing, spell, equipment, garden, and drops.

require is per-VM, so each plugin does its own. Put the calls at file scope, next to your constants, and they run once when the file loads.

The paths.* constants are a third pattern again: after the registry has installed the paths table, a finalizer stamps every known window path onto it as a plain value. That is why paths.MessageBoxCenter is a table lookup and not a function call.

The plugin loader

Loading a plugin file happens in a fixed sequence:

  1. A sandboxed Lua state is created for it.
  2. The module registry installs the always-on globals and preloads the opt-in ones.
  3. The file executes, and is expected to assign a global plugin table.
  4. name, version, author, description, and hooks are read out of that table.
  5. The metadata is validated — a plugin with no name is rejected, as is a second plugin claiming a name already taken.
  6. Every plugin.hotkeys entry is copied to an on_hotkey_<key> field.
  7. The table is scanned for keys starting with on_ whose value is a function; each becomes a subscription. The hooks list is merged in on top.

Note step 7: the subscription set is a snapshot, not a live lookup. It is taken at load and refreshed once more immediately after on_load returns, so a callback you assign inside on_load does get picked up. A callback you assign later — from inside on_tick, say — does not, and will silently never fire.

The escape hatch is events.On, which sets the field and registers the subscription in the same call, so it works at any time:

plugin.on_tick = function()
if not state.watching then
state.watching = true
events.On('zone_change', function(data)
utils.Log('now in ' .. tostring(data.new_zone))
end)
end
end

Because events.On("zone_change", fn) assigns plugin.on_zone_change = fn, it replaces any handler already in that field rather than adding to it. One handler per event, per plugin.

Hook dispatch is by name: when an event fires, the loader walks the running plugins, skips any that are not subscribed to that hook, and calls plugin.on_<event> with the payload converted to a Lua table. A plugin that is loaded but not running receives nothing.

Subscribing to the combined name combat — via hooks = { 'combat' } — expands to on_combat, on_combat_enter, and on_combat_exit. Names in hooks are normalised, so 'combat' and 'on_combat' mean the same thing.

Stopping a plugin cancels its run context, which unblocks anything the plugin has waiting inside utils.WaitUntil, utils.Sleep, or a long teleport.

The tick loop and client tracking

Both hosts run a 250ms ticker that calls into the engine, and the engine then walks every running plugin in name order and calls its on_tick — sequentially, on one goroutine. That is the detail behind "never block inside a tick": a plugin that spends two seconds in its callback delays every other plugin's tick by two seconds.

That same interval is what utils.TickMS() and utils.Ticks() report, so timing derived from those functions always matches the loop actually driving your plugin.

Client tracking is where the two hosts genuinely differ:

  • Desktop app. The memory inspector rescans for game processes every two seconds. Clients appear in clients and disappear from it on their own, and client_connected / client_disconnected fire as that happens.
  • Console. There is no background scan. The hook command builds the client set; running it again detaches everything (one client_disconnected per client) and rebuilds from scratch (one client_connected per client).

Per-client state changes are bridged into Lua events. A zone change becomes a zone_change dispatch with a client_id in the payload — which is why every event payload carries one, and why a plugin driving more than one client needs to check it. Each host chooses which of libwiz's state events it forwards, and the two lists are not the same; see the per-host table in Plugin lifecycle.

Client identity

A Client handle in Lua is a thin table holding one string: the client id. Every method call re-resolves that id against the host's live client table. This is what makes a stale handle mostly harmless — it does not point at freed memory, it just stops resolving and the calls start returning errors.

The id itself is host-specific, and this catches people out:

Hostc:GetID() returns
ConsoleThe alias assigned by hookp1, p2, …
Desktop appThe process id as a string — "48213"

So clients:Get('p2') is a console-only idiom. Portable code takes ids from c:GetID() or from an event payload's client_id and never writes one as a literal.

The game bindings

This is the API surface you actually call from a plugin: the Client object (stats, movement, windows, waits, predicates, survey), its sub-objects (combat, quest, dialog, entity, inventory, pet, fishing, spell, equipment, garden, drops), and the globals.

Underneath, every optional capability follows the same pattern: try the live client first, fall back to a secondary source if the client cannot answer, and return a specific error if neither can. That is why some calls fail with a distinct message like "not connected" instead of just returning nothing — the error tells you which side of that fallback gave up.

That layering is also how the whole Lua API is tested with no game running: the test harness supplies the fallback source and the real client is simply absent.

Where a call goes

Tracing a call like c:GetHealth() conceptually, end to end:

  1. Your Lua code calls the method on the client object.
  2. The string id on that table is resolved to a live client handle.
  3. That resolves to a live read against the hooked game client's memory.
  4. The result comes back and is converted into a Lua value.

Every step can fail. The read in step 3 is the one that actually fails in practice — a client that is mid-zone-transition, or one whose hooks were just invalidated by a game update, will return errors for a few seconds and then recover.

Where the Lua signature has no error slot (GetHealth, GetMana, GetGold, GetLevel, PotionCount), a failed read comes back as a zero, and the failure is only visible in the host's log. That is a deliberate trade-off and a sharp one: treat a suspicious zero as possibly-a-read-failure, and never build an irreversible decision on one.