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
Indexes start at 1. Not 0. This catches everyone once and then never again.

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 wroteLua wants
"a" + "b"'a' .. 'b'
i != 3i ~= 3
list[0]list[1]
if x { }if x then ... end
x = 5 at the top of a filelocal 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.

A mod you have just installed is off. Copying a file onto your machine is not the same act as agreeing to run it, so you tick it yourself.

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.
The Mods tab of the WorldsClient settings window
The Mods tab. Install from a zip, then tick it yourself — a mod you have just installed is off, because copying a file onto your machine is not the same act as agreeing to run it.

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)
EventHandler 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
Ask on the event, not at the top of the file. Mods load before the client has finished connecting, so 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):

EventMeansa, b
botreadyit is in the roomname, room
botchatsomebody spokewho, text
botwhispersomebody whispered to itwho, text
botappearsomebody arrivedwho
botdisappearsomebody leftwho
boterrorsomething went wrongreason
botroomit finished moving roomsroom
botgoneits 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 guard against answering itself matters. Without it, two bots in one room will talk to each other until somebody stops them.

The limits, and why they are there:

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.

MissingWould have allowed
ioopening any file on your disk
osrunning any program, reading the environment
luajavainstantiating any Java class, the client's own included
debugreaching 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

To share a mod, zip the contents of the folder — mod.ini at the top of the zip, not inside another folder.