The game.* API
View SourceEverything a game script can call. Lua scripting is the walkthrough; this is the reference.
The game table is installed into match, world and zone VMs. Bot scripts load
into a sandboxed VM with no game table at all - see
Lua bots.
How to read this page
There are two return conventions and you have to know which one you are looking at.
The persistence-style calls - economy.*, storage.*,
leaderboard.top/rank/around, terrain.get_chunk, notify, notify_many -
return a wrapped table. Index into .ok:
local result = game.economy.balance(player_id)
if result.error then
game.log("warning", "balance lookup failed", { reason = result.error })
return
end
for _, wallet in ipairs(result.ok) do
game.log("info", wallet.currency .. " = " .. wallet.balance)
endThe plain calls - broadcast, send, chat.send, zone.spawn,
zone.despawn, terrain.preload, leaderboard.submit, spatial.* - return
their value directly:
local hits = game.spatial.query_radius(state.entities, 100, 100, 50)
for _, hit in ipairs(hits) do
game.log("info", hit.id .. " at " .. hit.distance)
endOne exception cuts across both: calling anything with the wrong number or type
of arguments returns { error = "..." } naming the shape it wanted, whichever
convention that call normally uses. So a plain call that suddenly returns a
table is an argument mistake, not a failure of the operation.
Identity and logging
game.id() -- a UUIDv7 string
game.log(level, message)
game.log(level, message, meta)level is one of "debug", "info", "warn", "warning" or "error".
"warn" and "warning" are the same level. Anything else returns
{ error = "..." }.
The line goes through the host logger, so it lands in the node's structured
JSON stream rather than breaking it. print and eprint do not exist for that
reason.
message is capped at 500 characters and is truncated past that. meta is
capped at 2 KB of encoded JSON and is not truncated: over the cap the whole
table is replaced by {"_truncated": true}, and a table that will not encode
becomes {"_unencodable": true}. Keep meta small and structured.
Logging is rate limited: 30 lines a second per match or per zone, and 300 a
second across the node. Over budget game.log returns false and the line is
dropped, so a script failing on every tick cannot drown its neighbours.
Messaging
game.broadcast(event, payload) -- to every player in the match
game.send(player_id, message) -- to one playerevent must be 1-64 characters of [A-Za-z0-9_-] and must not be one of the
names asobi emits itself: state, tick, terrain, list, left, joined,
finished, phase_changed, matched, matchmaker_failed,
matchmaker_expired, vote_start, vote_tally, vote_result,
vote_vetoed. A rejected name returns { error = "..." } at the call site
rather than being dropped silently downstream - the client would otherwise be
unable to tell a forged match.finished from a real one.
broadcast works from a match, a world and a zone. A match script's event
reaches clients as match.<event>; a world or zone script's reaches them as
world.<event>, because the world server is bound as the broadcast target in
both.
The encoded payload is capped at 64 KB. Past that the event is dropped and the
node logs game_broadcast_rejected - broadcast has already returned true
by then, because the fan-out is asynchronous. Do not put a whole world snapshot
in one event.
send reaches the client as a module.message frame carrying
{"module": "lua", "message": ...}, which is why it takes any Lua value rather
than a table. See WebSocket protocol.
Economy
game.economy.grant(player_id, currency, amount)
game.economy.grant(player_id, currency, amount, reason)
game.economy.debit(player_id, currency, amount)
game.economy.debit(player_id, currency, amount, reason)
game.economy.balance(player_id)
game.economy.purchase(player_id, listing_id)amount is truncated to an integer. reason is a free-form string that lands
on the transaction row.
balance returns { ok = { { currency = "...", balance = N }, ... } } - every
wallet the player holds, not one figure.
purchase takes a store-listing id, not an item slug. That is the id
field of an entry in GET /api/v1/store, a UUID. Passing a slug fails.
There is no inventory namespace. Inventory is REST (GET /api/v1/inventory,
POST /api/v1/inventory/consume) and Erlang only.
Leaderboards
game.leaderboard.submit(board_id, player_id, score) -- true | false
game.leaderboard.top(board_id, count) -- { ok = entries }
game.leaderboard.rank(board_id, player_id) -- { ok = rank }
game.leaderboard.around(board_id, player_id, count) -- { ok = entries }score is truncated to an integer. submit is the odd one out: it returns a
plain boolean, not a wrapped result. rank returns
{ error = "not_found" } for a player with no entry on the board.
Notifications
game.notify(player_id, type, subject)
game.notify(player_id, type, subject, data)
game.notify_many(player_ids, type, subject)
game.notify_many(player_ids, type, subject, data)notify_many returns { ok = ids } listing the players it reached. A partial
fan-out is a shorter list, not an error, so compare the length if that matters
to you.
Storage
game.storage.get(collection, key)
game.storage.set(collection, key, value)
game.storage.player_get(player_id, collection, key)
game.storage.player_set(player_id, collection, key, value)Two distinct namespaces, not one with an optional owner. get/set write
global rows; player_get/player_set write rows owned by a player. A global
key and a player key of the same name are different rows.
The global namespace is reachable only from Lua. The REST storage routes
are hard-scoped to per-player rows, so nothing a client sends can read or
overwrite what game.storage.set wrote. That makes it the right place for
server-authoritative configuration and the wrong place for anything a client
needs to fetch directly.
Chat
game.chat.send(channel_id, sender_id, content) -- truesender_id is whoever the message should appear to be from; nothing here
checks that the sender is in the channel. The channel process is started if it
is not already running, with channel_type "room". Delivery and persistence
are asynchronous, so true means the message was handed off, not that it is on
disk.
Spatial
Three shapes, and mixing them up is the usual mistake.
Entity-list and zone forms. query_radius takes either:
game.spatial.query_radius(entities, x, y, radius)
game.spatial.query_radius(entities, x, y, radius, opts)
game.spatial.query_radius(x, y, radius) -- zone context requiredquery_rect is zone-only:
game.spatial.query_rect(x1, y1, x2, y2) -- zone context requiredThe entity-list forms return { id = ..., entity = ..., distance = ... } per
hit. The zone forms return { id = ..., x = ..., y = ... } per hit - no
entity and no distance. Without zone context the zone forms return
{ error = "... requires zone context" }.
Entity-list only.
game.spatial.nearest(entities, x, y, n)
game.spatial.nearest(entities, x, y, n, opts)Two entities.
game.spatial.in_range(entity_a, entity_b, range) -- boolean
game.spatial.distance(entity_a, entity_b) -- numberopts on query_radius and nearest accepts:
| Key | Value |
|---|---|
type | A type string, or a list of them, to include |
exclude | An entity id, or a list of them, to drop |
max_results | Cap on hits returned |
sort | "nearest" or "farthest" |
Anything else in opts is ignored.
World mode only
game.zone.spawn(template_id, x, y) -- true | false
game.zone.spawn(template_id, x, y, overrides) -- true | false
game.zone.despawn(entity_id) -- true
game.terrain.get_chunk(cx, cy) -- { ok = data }
game.terrain.preload(coords_list) -- truespawn returns false for a template the zone does not declare - the spawn
itself is asynchronous, so the return says "the template resolved", not
"something exists now". It reads the zone's live template set, so a hot reload
that adds a template takes effect without a restart.
terrain.preload takes a list of tables carrying cx/cy (or x/y).
Entries it cannot read are skipped rather than raising.
zone.* needs zone context and terrain.* needs a terrain store, so all of
these are world-mode only. Called anywhere else they return
{ error = "... not available ..." }. See World server.
Extension namespaces
An installed extension declares its own game.<namespace>,
installed in the same window as core's, so it reads like a core call:
local result = game.quests.progress(player_id, "first_blood")
if result.ok then
game.log("info", "quest advanced")
endExtension bindings use the wrapped { ok = ... } / { error = "..." }
envelope, whoever wrote them. An extension may bind into match, world and zone
VMs; bot is refused at rebar3 asobi check rather than installing nothing.
Standard library
The Lua standard library is present apart from what the sandbox clears, with two functions replaced:
math.random()returns a float in[0, 1).math.random(n)returns an integer in[1, n].math.random(a, b)returns an integer in[a, b]; an empty interval (a > b) raises, as in standard Lua. Non-integer bounds are truncated towards zero, where standard Lua raises.math.sqrt(n)returns0.0for negative input rather than NaN.
Both are backed by the BEAM's rand and math. math.randomseed still
exists, but it seeds Luerl's own generator, which the replaced math.random
never reads - so seeded determinism is not available.
os keeps os.clock, os.date, os.difftime and os.time.
require("name") and require("dir.name") load <dir of the loaded script>/name.lua and <dir of the loaded script>/dir/name.lua. Results are
cached, and the cache is cleared on hot reload so an edited module is re-read.
A symlinked module file is refused.
What is not there
- No
packagetable and nopackage.path.requireis asobi's own and resolves relative to the directory of the script that was loaded. Dotted names work (require("bots.chaser")); parent traversal and absolute paths do not. - No
coroutine. Luerl 1.5.1 does not implement it. - No
io,load,loadstring,dofile,loadfile,printoreprint, andoskeeps only its harmless half -os.execute,os.exit,os.getenv,os.remove,os.renameandos.tmpnameare cleared tonil, soos.execute == nilis a predicate a script can check. - Scripts run on Luerl 1.5.1, with Lua 5.3 semantics, not the reference implementation. Upstream describes the 5.2-to-5.3 migration as in progress, so treat anything exotic as worth testing rather than assumed.
Limits
Every callback except handle_input runs in a child process under a
wall-clock budget, a 5,000,000-word per-eval heap cap and a reduction budget.
Exceeding any of them discards that callback's result and keeps the previous
Lua state; the match or zone survives.
handle_input is the exception, and it is deliberate: it runs inline, because
at high input rates the spawn cost dominates the Lua work. It has no budget of
any kind, so an infinite loop there hangs that one match or zone indefinitely,
with no supervisor restart to recover it. Bound your own loops.
Sandbox and limits has the numbers and the reasoning.
Next
- Lua scripting - callbacks, modules, the walkthrough.
- World server - zones, terrain, the world callbacks.
- Extensions - adding a
game.<namespace>of your own.