Scripting (Lua)
A mod has up to two scripts, both Lua 5.4:
server.lua— the rules. The game server runs it, once for each lobby. It decides: it watches the players, changes HP, rings, speed, the clock, sends messages, ends rounds.client.lua— the screen. Each player's game runs it. It draws your HUD and plays sounds, from what the server tells it.
The two talk through game.sync (server → every player), game.event (server → players) and
game.send (player → server).
Scripts define functions with known names (hooks) that the game calls. Everything else
(game.*, hud.*, mod) is given to them. The standard table, string, math, utf8 and
coroutine libraries are there; files, load, os, io and debug are not.
A whole mode in 20 lines
-- server.lua: survivors get a ring every 5 seconds; the one with most rings at the end wins
local timer
function on_round_start(map)
game.say("`RING RUSH~: most rings at the end wins!")
timer = game.every(5, function()
for _, p in ipairs(game.players()) do
if p.alive and not p.exe then game.give_rings(p, 1) end
end
end)
end
function on_round_end(winner)
game.cancel(timer)
local best
for _, p in ipairs(game.players()) do
if not p.exe and (not best or p.rings > best.rings) then best = p end
end
if best then game.say(best.name .. " had the most rings: " .. best.rings) end
end
server.lua
The mod table
mod.id, mod.version, mod.name, and mod.config: the host's settings (the manifest's
settings, with the host's values), for example mod.config.fuse.
Players
game.players() returns a list of players; game.player(id) one player (or nil). A player is a
table:
| Field | |
|---|---|
id |
Their number on this lobby (use it to find them again) |
name |
Their nickname |
bot |
A bot of the server |
exe |
They are the EXE this round |
character |
tails, knuckles, eggman, amy, cream, sally, or exe, chaos, exetior, exeller |
hp, rings |
Now |
x, y |
Where they are (pixels) |
dead, escaped, demon |
|
alive |
In the round, not dead, not escaped |
playing |
In the round |
ready |
Their game has loaded the mod (web players only) |
Every function that takes a player takes the table or its id.
game.*
| Function | |
|---|---|
game.state() |
"lobby", "vote", "charselect", "game" or "results" |
game.map() |
Three values: the map's number, its name (for a mod map, its file: "hill.json"), and whether it is a mod map |
game.time() |
Seconds left in the round |
game.set_time(seconds) |
Sets the clock (1-1090) |
game.elapsed() |
Seconds since the round started |
game.exe() |
The EXE's id, or nil |
game.say(text) |
A line in everyone's chat (120 characters) |
game.tell(player, text) |
A line in one player's chat |
game.message(player, text, seconds) |
A big message in the middle of the screen; player 0 or nil: everyone (default 3 seconds) |
game.sound(player, name) |
Plays a sound (a game sound like snd_ring, or the mod's sounds/<name>.ogg); player 0: everyone |
game.end_round(winner) |
Ends the round: "exe", "survivors" or "time" (at the next tick) |
game.kill(player) |
Downs a survivor (the EXE and demons can't be killed) |
game.set_hp(player, hp) |
0-127 |
game.set_rings(player, n), game.give_rings(player, n) |
0-9999 |
game.teleport(player, x, y) |
Moves them |
game.freeze(player, seconds) |
They can't move for a while: they stop at once (in the air, they drop straight down) |
game.launch(player, vx, vy) |
Throws them (pixels per frame; negative vy is up) |
game.set_stat(player, stat, k) |
Multiplies a stat (0-10, 1 is normal): "speed", "acceleration", "jump", "gravity", "damage_taken" |
game.reset_stats(player) |
Back to normal |
game.sync(key, value) |
Shares a value (nil, number, text up to 120, boolean) with every player's client.lua as mod.synced[key], now and for players who join later. Up to 128 keys |
game.event(player, name, data) |
Calls on_event(name, data) in one player's client.lua (0: everyone) |
game.timer(seconds, fn) |
Calls fn once, later. Returns a handle |
game.every(seconds, fn) |
Calls fn again and again. Returns a handle |
game.cancel(handle) |
Stops a timer |
game.random(a, b) |
A whole number from a to b (game.random(n): 1 to n) |
game.distance(a, b) |
Pixels between two players |
game.choose_exe(player) |
This player is the EXE in the next round |
game.log(...), print(...) |
A line in the server's console |
require(name) |
Runs lua/<name>.lua of the mod once and returns what it returns |
What changes a player (kill, set_hp, set_rings, teleport, freeze, launch, set_stat,
message, sound) is done by their own game: it works on web and phone players; the server's bots
ignore it.
Hooks
Define any of these in server.lua:
| Hook | Called |
|---|---|
on_load() |
Once, when the lobby starts |
on_join(player), on_leave(player) |
Someone came in or left |
on_ready(player) |
Their game has downloaded and loaded the mod |
on_lobby() |
Back in the lobby after a round |
on_round_start(map) |
A round starts (map: the mod map's file, or the game map's number) |
on_tick(frame) |
60 times a second during a round (frame counts from 0) |
on_round_end(winner) |
"exe", "survivors" or "time" |
on_death(player), on_escape(player), on_demonize(player) |
|
on_ring(player, rings) |
They picked up a ring |
on_hurt(attacker, target, damage, stun) |
A hit, as the one hit reports it: damage (the hit's strength, before damage_taken), or stun seconds (a survivor stunning the EXE) |
on_hp(player, old, new) |
Their HP changed |
on_chat(player, text) |
A chat line; return false to hide it |
on_choose_exe(id) |
The server picked the next EXE; return another player's id to change it |
on_client_event(player, name, data) |
Their client.lua called game.send(name, data) |
Objects (api 2)
The mod's objects (objects/<name>.json, made in the Mod Studio) are placed in its maps or made by
a script; the server moves them and runs their events, and every player sees them.
| Function | |
|---|---|
game.spawn(name, x, y, fields) |
Makes one (its create runs). fields (optional) are set on it. Returns it |
game.destroy(obj) |
Removes it (its destroy runs) |
game.objects(name) |
A list of them (all, or only that kind) |
game.object(id) |
One (or nil) |
An object is a table (self in its events): x, y, vx, vy (added to x, y every frame),
sprite (a sprite's name), frame (nil: animated), visible (false: not drawn, and nothing
touches it), solid (false: players go through a solid one), xscale, yscale (-1 mirrors it),
alpha (0-1), angle (degrees), id, type,
and its own fields. Change them and every player sees it (20 times a second).
Its events, in objects/<name>.lua:
-- objects/coin.lua
function create(self)
self.vy = 0
end
function touch(self, player) -- a player came onto its box
game.give_rings(player, self.value)
game.sound(player, "snd_ring")
game.destroy(self)
end
create(self), step(self) (60 a second), touch(self, player), leave(self, player),
destroy(self). They see the game rules' globals. At most 256 objects at a time; they are all
removed when the round ends. The Studio's Run plays them in your game alone (no server).
A character's own moves (api 2)
A mod character can have characters/<name>.lua beside its .json (the Studio: Give it moves).
It runs in the game of whoever plays that character, every frame, after the game's own moves:
function step(me, input)
if me.ground then
me.dashes = 1
elseif input.pressed.c and me.dashes > 0 then -- a dash in the air
me.dashes = 0
me.vx = 9 * me.facing
me.vy = -1
me.anim = "spr_tails_fly" -- shown to everyone
end
end
me: x, y, vx, vy, ground, facing (1 / -1), anim (a sprite's name shown instead of the
game's animation, nil: the game's), anim_speed, and to read state, character, hp, rings,
dead; its own fields stay from frame to frame. input: left, right, up, down, a, b, c
(held) and input.pressed.<key> (this frame). It has client.lua's functions: game.send asks the
game rules for what the server decides (damage, objects...).
Limits
Each lobby's script has 48 MB of memory and about three million instructions per hook call (and
six million for all the calls of one tick): a script stuck in a loop is stopped, not the server,
even inside pcall, xpcall or a coroutine. Errors go to the server's console; after 25 errors in
a round its hooks rest until the next round.
Also: at most 1000 timers at a time; game.sync keys up to 40 characters (not starting with
config.); 200 game.log lines and 20 game.say / game.tell a second; 240 mod messages a second
to each player (more are dropped; a synced value always arrives, a moment later); game.send
from each player: 30 a second (the web game queues them). setmetatable refuses a __gc field, and patterns
(string.find, gsub...) that would take too long stop with "pattern too complex".
client.lua
The mod table
mod.id, mod.version, mod.name, mod.config (the host's settings, as on the server) and
mod.synced (what the server shared with game.sync).
hud.* (drawing, in on_draw)
The screen is 480 by 270. Colours are "#rrggbb" or a number 0xRRGGBB; texts take the
colour codes.
| Function | |
|---|---|
hud.text(x, y, text, colour, size) |
Text from x (size default 0.5). Returns where it ended |
hud.center(x, y, text, colour, size) |
Text centred on x |
hud.width(text, size) |
How wide a text is |
hud.rect(x, y, w, h, colour, alpha) |
A filled box (alpha 0-1) |
hud.sprite(name, frame, x, y, xscale, yscale, alpha) |
A sprite: the game's or the mod's |
game.*
| Function | |
|---|---|
game.me() |
You: id, exe, and in a round hp, rings, x, y, dead, character (a name, as on the server) |
game.players() |
Everyone: id, name, exe, and (when seen) x, y, hp |
game.send(name, data) |
Calls on_client_event on the server. 30 go out a second: more wait their turn (300 at most) |
game.objects(name) |
The mod's objects you see (api 2): id, type, x, y, sprite, frame, visible |
game.sound(name) |
Plays a sound for you only |
game.log(...), print(...) |
The browser's console |
require(name) |
As on the server |
Hooks
| Hook | Called |
|---|---|
on_load() |
Once, when the mod is loaded |
on_round_start(map), on_round_end(winner) |
|
on_tick(frame) |
60 times a second during a round |
on_draw() |
Every frame, over the game: draw your HUD here |
on_sync(key, value) |
The server changed mod.synced[key] |
on_event(name, data) |
The server called game.event |
Limits
32 MB of memory and about two million instructions per hook call, as on the server (pcall can't
catch running out of time). After 50 errors in a round the script rests until the next round; a
mistake in on_draw runs every frame, so test it with the Studio's Run first.
An example with both: Hot Potato
-- server.lua
local holder, left
local function give(p)
holder, left = p.id, mod.config.fuse
game.sync("holder", p.id)
game.sync("holder_name", p.name)
game.sync("left", left)
game.message(0, p.name .. " has the potato!", 2)
end
function on_round_start(map)
holder = nil
game.timer(3, function()
local s = {}
for _, p in ipairs(game.players()) do
if p.alive and not p.exe then s[#s + 1] = p end
end
if #s > 0 then give(s[game.random(#s)]) end
end)
end
function on_tick(frame)
if not holder then return end
if frame % 60 == 0 then
left = left - 1
game.sync("left", left)
if left <= 0 then
game.kill(holder)
game.sound(0, "snd_boom")
holder = nil
game.sync("holder", 0)
return
end
end
for _, p in ipairs(game.players()) do
if p.alive and not p.exe and p.id ~= holder and game.distance(holder, p) < 32 then
give(p)
return
end
end
end
-- client.lua
function on_draw()
local h = mod.synced.holder
if not h or h == 0 then return end
if game.me().id == h then
hud.rect(140, 22, 200, 22, "#000000", 0.6)
hud.center(240, 26, "`YOU HAVE THE POTATO!~ " .. mod.synced.left, "#ffffff", 0.6)
else
hud.center(240, 26, "potato: " .. mod.synced.holder_name, "#ffd040")
end
end
The full mod (with a passing cooldown and a winner) is on the site as Hot Potato: download it and open it in the Mod Studio (Open a .dmod file) to read it.