Skip to content

Calls

The server’s calls to the outside: HTTP requests, SQL databases (SQLite, Postgres, MySQL) and WebSockets, and json to read and write what they carry. Only the server makes them.

A call never holds up the game. It gives a task at once, and a handler that needs the answer awaits it, going on at the first tick after it comes. A failed call never raises an error: its answer’s ok is false and its error says what went wrong. A call sent always finishes, whatever becomes of the match.

Calls are made in handlers, timers, commands, on_tick and on_event, never at a module’s top as it loads; only a pool may be made there. Each call waits 30 seconds for its answer unless its timeout says otherwise.

Keep passwords and keys out of the mod: declare them as secret settings, which the server owner gives, and pass them as a URL or a header’s value. See settings.

On the server await(...tasks) → ...Answer

Pauses the handler until each task is answered, and gives their answers in order.

It goes on at the first tick after the last answer came, when what it held may be gone: check a player’s or an entity’s exists. A task already answered, as its done says, gives its answer at once. A handler, a timer or a command awaits, never a module’s top, on_tick or on_event. A paused handler is undone, resumed and dropped as one paused in wait is.

Every answer has ok, and error once it is false: no answer came, the call took longer than its timeout, or the answers on their way together passed the memory the mod has left. A failed call never raises an error; one no handler awaits is said once as the mod’s error.

NameTypeWhat it is
...tasksTaskAny number of them.
Returns
...Answer
Fails
  • a value of the wrong type
local rank, kills = await(
http.request("GET", "https://stats.example/rank/" .. p.id),
http.request("GET", "https://stats.example/kills/" .. p.id)
)
if rank.ok and kills.ok and p.exists then
print(`{p.name}: rank {json.decode(rank.body).rank}, {json.decode(kills.body).kills} kills`)
end
On the server http.request(method, url, options?) → Task

Sends an HTTP or HTTPS request, and gives its task at once, which await answers.

Any status, 500 too, is an answer: ok, with status, headers and body. A header’s name is in lower case, and a name sent several times has its values joined by , . No answer, as a refused connection or a timeout passed, fails the call, its error saying why. Redirects are followed, never telling the next host where they came from; a request carrying a secret, in its URL or a header, follows them only to the same scheme, host and port, answering one elsewhere as it is. A call made at a module’s top, or in a group, fails.

NameTypeWhat it is
methodstringNeeded.
urlstring | SecretNeeded.
optionstable?A table of the options below. It, and each option, may be left out.
headers({[string]: string | Secret})?Headers to send, by name; a value may be a secret.
body(string | buffer)?The body, a string's or a buffer's bytes as they are. Not with json.
jsonany?A value sent as the body, as json.encode writes it, with its content type unless headers names one. Not with body.
timeoutnumberdefault 30How long it may take, in seconds, before it fails.
Returns
Task
Fails
  • a value of the wrong type
  • an option it does not have
  • body and json both
local answer = await(http.request("GET", "https://stats.example/rank/" .. p.id))
if answer.ok and answer.status == 200 then
local rank = json.decode(answer.body).rank
print(`{p.name} is ranked {rank}`)
end
local answer = await(http.request("POST", "https://stats.example/kills", {
headers = { ["x-server"] = "eu-1" },
json = { player = p.name, weapon = "weapons/ak47" },
timeout = 5,
}))
if not answer.ok then
print("the stats site is down: " .. answer.error)
end
On the server json.encode(value) → string

Writes a value as JSON text.

A table of keys 1 to n is a list, an empty table too, and any other table an object of string keys, in sorted order; nil is null. A whole number is written without a decimal point. A table holding itself fails, as does a function, a key neither a string nor 1 to n, or text larger than the memory the mod has left.

NameTypeWhat it is
valueanyNeeded.
Returns
string
Fails
  • a value of the wrong type
local text = json.encode({ name = "ann", kills = 4, weapons = { "weapons/ak47", "weapons/usp" } })
On the server json.decode(text) → (any, string?)

Reads JSON text: gives its value, or nil and why it is not JSON.

An array is a table of keys 1 to n and an object a table of string keys; null is nil, leaving a hole in a list.

NameTypeWhat it is
textstringNeeded.
Returns
(any, string?)
Fails
  • a value of the wrong type
local stats, why = json.decode('{"rank": 3, "best": null}')
if stats then
print(`rank {stats.rank}`)
else
print("not JSON: " .. why)
end
On the server sql.pool(url, options?) → Pool

Makes a pool of connections to a SQLite, Postgres or MySQL database, which connects on its first call.

The URL starts sqlite:, postgres:// or mysql://, and may be a secret. A SQLite file is in the server’s directory, made if missing, and its SQL opens no other file. The pool is made at once, so a module’s top may make one: keep it in a global, as a reload in place runs a module’s top again. It closes once the mod no longer holds it and its calls have finished.

NameTypeWhat it is
urlstring | SecretNeeded.
optionstable?A table of the options below. It, and each option, may be left out.
max_connectionsnumber?1 to 4294967295The most connections it opens at once, 10 without.
min_connectionsnumberdefault 0The connections it keeps open, even idle; kept within max_connections, refused above it when both are given.
acquire_timeoutnumberdefault 30How long a call waits for a free connection, in seconds, before it fails.
idle_timeoutnumberdefault 600How long a connection with no call on it stays open, in seconds.
max_lifetimenumberdefault 1800How long a connection stays open at most, in seconds, before a new one takes its place.
Returns
Pool
Fails
  • a value of the wrong type
  • an option it does not have
  • a value outside its range
db = db or sql.pool("sqlite:stats.db")

To share a Postgres database between servers

Section titled “To share a Postgres database between servers”
db = db or sql.pool("postgres://game@db.example/stats", { max_connections = 4 })
On the server ws.connect(url, options?) → Task

Connects a WebSocket, and gives its task at once, which answers socket once it is open.

The URL starts ws:// or wss://, and it and a header’s value may be secrets. message and closed run as handlers of the module that connected, in the order the socket heard, never a player’s; what they hear has secrets hidden. The messages on their way, either way, count against the memory the mod has left, the socket closing past it. A socket closes once the mod no longer holds it, unless one of its own callbacks holds it, and with its match: reconnecting is the mod’s. socket.open is false once either side closes it. A call made at a module’s top, or in a group, fails.

NameTypeWhat it is
urlstring | SecretNeeded.
optionstable?A table of the options below. It, and each option, may be left out.
headers({[string]: string | Secret})?Headers to send with the handshake, by name; a value may be a secret.
protocols{string}?The subprotocols offered, of which the host picks one, socket.protocol.
timeoutnumberdefault 30How long it may take to connect, in seconds, before it fails.
message((data: string | buffer) -> ())?Runs for each message the socket hears: a string for text, a buffer for binary.
closed((code: number, reason: string) -> ())?Runs once as the socket closes, whichever side closed it, with the close's code and reason.
Returns
Task
Fails
  • a value of the wrong type
  • an option it does not have
local answer = await(ws.connect("wss://chat.example/relay", {
message = function(data)
say(`[relay] {data}`)
end,
closed = function(code, reason)
print(`the relay closed: {code} {reason}`)
end,
}))
if answer.ok then
relay = answer.socket
end
On the server Pool:query(sql, ...params) → Task

Runs a query on one of the pool's connections, and gives its task at once, which answers rows.

The SQL is one statement in the database’s own SQL, each ? ($1 and on in Postgres) a parameter after it, the options last. Each row is a table of its values by column name, a database’s null as nil and its bytes as a buffer. A call made at a module’s top, or in a group, fails.

NameTypeWhat it is
sqlstringNeeded.
...paramsSqlParamAny number of them.
Returns
Task
Fails
  • a value of the wrong type
db = db or sql.pool("sqlite:stats.db")
local answer = await(db:query("SELECT name, kills FROM players WHERE kills > ? ORDER BY kills DESC", 5))
if answer.ok then
for i, row in answer.rows do
print(`{i}. {row.name}, {row.kills} kills`)
end
end
On the server Pool:execute(sql, ...params) → Task

Runs a statement that changes the database on one of the pool's connections, and gives its task at once, which answers the rows it changed.

Its SQL and parameters are as Pool:query’s. A call made at a module’s top, or in a group, fails.

NameTypeWhat it is
sqlstringNeeded.
...paramsSqlParamAny number of them.
Returns
Task
Fails
  • a value of the wrong type
db = db or sql.pool("sqlite:stats.db")
local answer = await(db:execute("UPDATE players SET kills = kills + 1 WHERE name = ?", p.name, { timeout = 5 }))
if not answer.ok then
print("not saved: " .. answer.error)
end
On the server Pool:transaction(fn, { timeout? }?) → Task

Runs a function's calls on one connection as a transaction, and gives its task at once, which answers once it is committed or rolled back.

The function runs at once, as a handler of its own given the transaction, tx, so it may await. Its tx:query and tx:execute go to the database in order. It commits as the function returns; it is rolled back if the function returns false or fails, a call on tx fails, or timeout passes. A call made at a module’s top, or in a group, fails.

NameTypeWhat it is
fn(tx: Tx) -> ...anyNeeded.
optionstable?A table of the options below. It, and each option, may be left out.
timeoutnumberdefault 30How long it may take, in seconds, from its start to its commit, before it is rolled back.
Returns
Task
Fails
  • a value of the wrong type
  • an option it does not have

To move points from one wallet to another, or not at all

Section titled “To move points from one wallet to another, or not at all”
db = db or sql.pool("sqlite:stats.db")
local moved = await(db:transaction(function(tx)
local from = await(tx:query("SELECT points FROM wallets WHERE name = ?", "ann")).rows[1]
if not from or from.points < 100 then
return false
end
tx:execute("UPDATE wallets SET points = points - 100 WHERE name = ?", "ann")
tx:execute("UPDATE wallets SET points = points + 100 WHERE name = ?", "bob")
end, { timeout = 10 }))
print(moved.ok and "moved" or "not moved: " .. moved.error)
On the server Socket:send(data, { timeout? }?) → Task

Sends a message, after those sent before it, and gives its task at once, which answers once it is sent.

A string is sent as a text message and a buffer as a binary one. A message to a socket no longer open fails.

NameTypeWhat it is
datastring | bufferNeeded.
optionstable?A table of the options below. It, and each option, may be left out.
timeoutnumberdefault 30How long it may take, in seconds, before it fails.
Returns
Task
Fails
  • a value of the wrong type
  • an option it does not have
local answer = await(ws.connect("wss://chat.example/relay"))
if answer.ok then
answer.socket:send(json.encode({ joined = p.name }), { timeout = 5 })
end
On the server Socket:close(code?, reason?) → Task

Closes the socket, after the messages sent before, and gives its task at once, which answers once the close is sent.

The code is 1000 without one, or 3000 to 4999 for the mod’s own, and the reason up to 123 bytes. The socket is no longer open at once, and its closed runs once the host closes too.

NameTypeWhat it is
codenumber?May be left out.
reasonstring?May be left out.
Returns
Task
Fails
  • a value of the wrong type
local answer = await(ws.connect("wss://chat.example/relay"))
if answer.ok then
answer.socket:close(4000, "the match ended")
end
On the server Tx:query(sql, ...params) → Task

Runs a query in the transaction, after the calls on it before, and gives its task at once, which answers rows.

Its SQL, parameters and rows are as Pool:query’s. One that fails rolls the transaction back, and those after it fail. It fails once the transaction’s function has ended.

NameTypeWhat it is
sqlstringNeeded.
...paramsSqlParamAny number of them.
Returns
Task
Fails
  • a value of the wrong type
db = db or sql.pool("sqlite:stats.db")
await(db:transaction(function(tx)
local wallet = await(tx:query("SELECT points FROM wallets WHERE name = ?", "ann")).rows[1]
print(`ann has {wallet and wallet.points or 0} points`)
end))
On the server Tx:execute(sql, ...params) → Task

Runs a statement that changes the database in the transaction, after the calls on it before, and gives its task at once, which answers the rows it changed.

Its SQL and parameters are as Pool:query’s. One that fails rolls the transaction back, and those after it fail. It fails once the transaction’s function has ended.

NameTypeWhat it is
sqlstringNeeded.
...paramsSqlParamAny number of them.
Returns
Task
Fails
  • a value of the wrong type
db = db or sql.pool("sqlite:stats.db")
await(db:transaction(function(tx)
for _, p in players do
tx:execute("UPDATE players SET kills = kills + ? WHERE name = ?", 1, p.name)
end
end))