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.