Writing a mod in Lua
A mod is a folder of Lua that the client loads when it starts. It can answer chat, add commands you type with a slash, put figures in the world, look at what is around you, talk to a web API, and log a bot into the server that everyone else can see.
You need the modified client and a text editor. No compiler, no SDK, no build step. Edit a file, press Reload mods, see what happened.
If you have never written Lua, start at part 0. It is short, and it covers everything the rest of this page uses.
0. Lua in ten minutes
Lua is small on purpose. There are eight types, one data structure, and almost no syntax. If you have written any other language you will be productive in an afternoon; if you have not, it is a genuinely good first one.
Variables
local name = 'Aiko' -- a string
local count = 3 -- a number. There is no separate integer type
local ok = true -- a boolean
local nothing = nil -- the absence of a value
x = 5 -- WITHOUT local, this is global. Almost always a mistake.
Write local unless you have a reason not to. A
global in one mod is visible to that mod everywhere, lives until reload, and is
the usual cause of "it worked until I added the second command".
Comments are -- to the end of the line.
Strings
local a = 'single quotes'
local b = "double quotes" -- identical, pick one and stick to it
local c = a .. ' and ' .. b -- .. joins strings. NOT +
#a -- length: 15
a:upper() -- 'SINGLE QUOTES'
a:lower()
a:sub(1, 6) -- 'single' — indexes start at 1, not 0
a:find('quo') -- 8, 10 — where it is, or nil if absent
Conditions
if count > 2 then
worlds.tell('more than two')
elseif count == 2 then -- == to compare, = to assign
worlds.tell('exactly two')
else
worlds.tell('fewer')
end
if name ~= 'Aiko' then ... end -- ~= is "not equal". Not !=
Only false and nil are false. Zero is true.
An empty string is true. That surprises people from other languages.
Loops
for i = 1, 10 do -- 1 to 10 inclusive
print(i)
end
for i, person in ipairs(worlds.people()) do -- a list, in order
print(i, person.name)
end
for key, value in pairs(settings) do -- a table, any order
print(key, value)
end
while connected do ... end
Tables — the only data structure
A table is a list and a dictionary at the same time. That is the whole language's data model.
local list = { 'a', 'b', 'c' }
list[1] -- 'a'
#list -- 3
table.insert(list, 'd')
local person = { name = 'Aiko', x = 100, y = 200 }
person.name -- 'Aiko'
person['name'] -- the same thing
person.height = 170 -- add a field by assigning to it
Everything this interface hands you is a table. worlds.me()
returns one; worlds.people() returns a list of them.
Functions
local function greet(who)
return 'hello ' .. who
end
greet('world') -- 'hello world'
Functions are values, so you can pass one straight into something else. That is what almost every call on this page does:
worlds.command('greet', function(rest) -- an unnamed function, written in place
worlds.say('hello ' .. rest)
end, 'greet someone')
Read that as: "register a command called greet; when someone types it, run this". The function is not called now — it is handed over to be called later. Every event handler, timer and callback here works that way.
The five mistakes everyone makes first
| You wrote | Lua wants |
|---|---|
"a" + "b" | 'a' .. 'b' |
i != 3 | i ~= 3 |
list[0] | list[1] |
if x { } | if x then ... end |
x = 5 at the top of a file | local x = 5 |
That is enough Lua for everything below. Errors name the mod and the line, so when you get one, the message is usually the whole answer.
1. The shortest possible mod
Two files in a folder beside the client:
mods/mymod/
mod.ini
main.lua
mod.ini:
id = mymod
name = My Mod
version = 1.0
author = you
description = What it does, in one line.
main = main.lua
Only id and main matter to the client. The rest is
what the Mods tab shows.
main.lua:
worlds.command('greet', function(rest)
worlds.say('hello ' .. rest)
end, 'greet someone')
Open the settings window, go to Mods, tick your mod, type
/greet world. That is the whole loop.
Two buttons in the Mods tab write working examples to disk: one uses every part of the interface once, the other is a bot that logs in and answers people. Copy whichever is closer to what you want.
2. Talking
worlds.tell(text) a line only you see
worlds.say(text) a line everyone sees, as if you typed it
worlds.whisper(who, text) a whisper
worlds.log(text) a line in worldsmod.log
print(...) the same as worlds.log
worlds.say sends what you give it. A mod saying
/something means to say it — it is not read back as a
command, so mods cannot drive each other by accident.
3. Commands
worlds.command(name, handler, help)
handler is called as handler(rest, who), where
rest is everything after the command word. The line never reaches
the server. /mods lists every command every running mod has
registered, which is how you find out what you have.
4. Events
worlds.on(event, handler)
| Event | Handler gets |
|---|---|
chat | (who, text) — someone said something |
appear | (who, x, y, z) — someone arrived |
disappear | (who) — someone left |
tick | (time) — about twenty times a second |
extension | (from, verb, payload) — a message nobody sees on screen |
server | (name, version, extended) — the server said what it is |
A greeter, whole:
worlds.on('appear', function(who)
worlds.say('hey ' .. who)
end)
5. Timers
worlds.after(milliseconds, handler) once
worlds.every(milliseconds, handler) repeatedly
Both are driven by the client's own frames. Nothing runs on a thread of its own, so a mod can never be halfway through changing something while the client is drawing it.
6. Looking around
worlds.me() { name, x, y, z, yaw, world, room, url }
worlds.people() a list of { name, x, y, z, yaw }
worlds.world() { name, url, room, objects }
worlds.objects(n) a list of { name, kind, hasClump, x, y, z }
hasClump says whether the client actually has geometry for that
object. An object without it is in the world but drawing nothing, which is
worth knowing when something is missing.
7. Settings that survive a restart
worlds.config.get(key, default)
worlds.config.set(key, value)
Kept in worldsmod.ini under mod.<id>.<key>.
8. Talking to the web
worlds.fetch(url, function(body, err) end)
worlds.fetch({ url =, method =, body =, type = }, function(body, err) end)
The answer arrives through the callback, never as a return
value. That is not a style choice. Waiting for a web server on the
thread that draws the world would freeze the client for as long as the far end
took to answer, and nothing in the sandbox could interrupt it — the
instruction budget counts Lua instructions, and a Java call blocked on a socket
is not executing any. The request runs on its own thread and the callback is
delivered on the client's thread with everything else. One of
body and err is nil.
worlds.json.decode(text) -> table, or nil and a message
worlds.json.encode(value) -> text
Asking an API something, complete:
worlds.command('ip', function()
worlds.fetch('https://api.ipify.org?format=json', function(body, err)
if err then worlds.tell('could not ask: ' .. err); return end
worlds.tell('this machine looks like ' .. worlds.json.decode(body).ip)
end)
end, 'ask a web API something')
Network access is off unless you turn it on in the Mods tab, and every request is written to the log with the mod that made it. Four requests at a time, a megabyte each.
9. Knowing which server you are on
worlds.server() { extended, name, version, describe, caps }
worlds.serverHas(cap) true when the server offered that capability
worlds.server() at load time
always says legacy — the announcement has not arrived yet. The
server event is it arriving.
worlds.on('server', function(name, version, extended)
if worlds.serverHas('anim2') then ... else ... end
end)
10. Messages between clients
worlds.ext.send(verb, payload) to everyone in the room
worlds.ext.sendTo(who, verb, payload) to one person
worlds.ext.room(verb) characters left for the payload
They travel as chat with a prefix no released client displays, so they cost
bystanders on an original client nothing and they pass through
any server, including one that knows nothing about them. The
whole line is capped at 200 characters and a longer one is refused rather than
truncated — half an extension message is worse than none. They arrive as
the extension event, never as chat.
11. Menus
worlds.menu(label, function() end)
Puts an entry on the client's own menu bar, under WorldsClient. This is what turns a mod into something you use rather than something you type at. Entries asked for at load time are held and added when the bar appears, and they go away when the mod is reloaded.
12. Bots
A bot is a client of its own. It opens its own connection, logs in under its own name, walks into a room, and everybody sees it — people on ordinary, unmodified clients can look at it, talk to it and get an answer. Your client is not involved: the bot has its own session and keeps it whether your window is in focus or not.
What makes that affordable is that a bot needs almost none of what a real client needs. No world to draw, no geometry, no sound — a socket and about two hundred lines of protocol are enough to be a person as far as the server and everyone in the room is concerned.
worlds.bots.connect{ name = 'Helper',
room = 'GroundZero#Reception',
avatar = 'avatar:pengo.mov',
x = 1228, y = 2465, z = 0, dir = 157 } -> id
worlds.bots.say(id, text)
worlds.bots.whisper(id, who, text)
worlds.bots.moveTo(id, x, y, z, dir)
worlds.bots.teleport(id, room, x, y, z, dir)
worlds.bots.who(id) -- the names it can currently see
worlds.bots.info(id) -- name, room, host, port, connected, position
worlds.bots.disconnect(id)
host and port default to the server your own client is
pointed at, so a bot usually needs only a name.
Events, all of them (botId, a, b):
| Event | Means | a, b |
|---|---|---|
botready | it is in the room | name, room |
botchat | somebody spoke | who, text |
botwhisper | somebody whispered to it | who, text |
botappear | somebody arrived | who |
botdisappear | somebody left | who |
boterror | something went wrong | reason |
botroom | it finished moving rooms | room |
botgone | its session ended |
A working bot:
local helper = worlds.bots.connect({ name = 'Helper' })
worlds.on('botready', function(id, name, room)
worlds.bots.say(id, 'hello, I am ' .. name)
end)
worlds.on('botchat', function(id, who, text)
if who == 'Helper' then return end -- do not answer yourself
if text:lower():find('time') then
worlds.bots.say(id, who .. ': I am not wearing a watch')
end
end)
The limits, and why they are there:
- Off unless you tick it — Let mods log bots into the server. Logging into somebody's server under another name should not follow from unzipping a file.
- Four at a time, each its own session with its own name.
- One line every 400 ms per bot. Faster lines are queued, not dropped — a bot answering two people has two things worth saying — up to sixteen waiting.
- A bot belongs to the mod that made it: reloading or stopping the mod logs it out. It never outlives what is driving it.
- A bot's events go only to the mod that logged it in. Two mods each running a bot never hear each other's.
A bot identifies itself the same way the client does and under the same rule: only to a server that announced itself first, and only for what it can really do — talking, because it draws nothing at all.
Local avatars, which are not bots
A different tool for a different job: an avatar loaded into your own view only, seen by nobody else. For trying an avatar or checking how something looks in place without bothering anyone.
worlds.bots.spawnLocal({ avatar = 'avatar:willy.mov', name = 'bob',
x = 0, y = 0, z = 0, yaw = 0 }) -> id
worlds.bots.moveLocal(id, x, y, z)
worlds.bots.removeLocal(id)
13. Driving the Shaper
The world editor can be operated from a mod, which is how you turn a sequence you keep repeating into one menu entry. See the Shaper guide for what each call corresponds to on screen.
worlds.menu('Build: fine grid', function()
worlds.shaper.show(true)
worlds.shaper.snap(10, 10, 1)
worlds.shaper.snap(true)
worlds.tell(worlds.shaper.describe())
end)
14. What a mod cannot do
The interpreter is built from a short list of libraries: base, package, table, string, math, coroutine. What is missing is the point.
| Missing | Would have allowed |
|---|---|
io | opening any file on your disk |
os | running any program, reading the environment |
luajava | instantiating any Java class, the client's own included |
debug | reaching around all of the above |
require and dofile work, but resolve inside the mod's
own folder only, and an attempt to read outside it is logged. A mod that will
not stop is cut off: the interpreter is asked every twenty thousand instructions
whether the call has run longer than 250 ms. A mod that throws is reported
and stopped — it does not carry on half broken and it
cannot take the client down.
15. Things that will save you time
- The interpreter is LuaJ 3.0.1 — Lua 5.2 with a few gaps. It was chosen because every class in it is Java 5 bytecode, so it loads on the Java 6 this client runs on.
- Its
string.formatignores width and precision on%f:string.format('%.3f', math.pi)returns every digit andstring.format('[%5.2f]', 1.5)returns[1.5].%dand%sbehave. Round the number yourself if the shape of the output matters. - Errors name the mod and the line, in the Mods tab and in
worldsmod.log. - Reloading throws away every running mod and starts the enabled ones again. Bots and timers go with them.
- The Tools tab has a line of Lua you can run against the
live client with the same interface a mod gets. Fastest way to try one call:
return worlds.me().name
To share a mod, zip the contents of the folder —
mod.ini at the top of the zip, not inside another folder.