Configuration
asobi is one node with two surfaces. Run the image and configure it from the
environment plus your Lua scripts, or depend on the Hex package and configure it
in sys.config. This page is the reference for both.
Version floors, supported Postgres and the image's architecture live in Self-hosting.
Lua (Docker)
For Lua game developers using the image, configuration lives in your Lua scripts. No Erlang syntax needed.
Game mode config
Declare settings as globals at the top of your match script:
-- match.lua
match_size = 4
max_players = 10
min_players = 4 -- defaults to match_size; higher makes the match wait for backfill
quick_play = true -- defaults to FALSE for matches: without it match.find_or_create refuses
strategy = "fill"
bots = { script = "bots/arena_bot.lua" }
| Global | Required | Default | Description |
|---|---|---|---|
match_size |
yes | none | Minimum players to start a match |
max_players |
no | match_size |
Maximum players per match |
min_players |
no | match_size |
Players needed before the loop starts. Higher than match_size spawns a match that waits for backfill, and gives up after 60s |
strategy |
no | "fill" |
"fill", "skill_based", or a custom module |
bots |
no | none | { script = "path/to/bot.lua" } - see Bots |
game_type |
no | "match" |
"match" or "world" |
listed |
no | false for matches, true for worlds |
Whether instances appear in discovery (match.list / world.list). Never gates joining |
quick_play |
no | false for matches, true for worlds |
Whether match.find_or_create / world.find_or_create may place a player into an existing instance of this mode. A match mode that does not set it is refused with quick_play_disabled. Independent of listed |
state_strategy |
no | none | "shared" selects the encode-once broadcast path |
guest_auth |
no | false |
Declares that this game offers anonymous play. The operator still has to supply a pepper |
registration |
no | none | "open", "oauth_only" or "closed". The operator's sys.config wins when it sets one |
World-mode games (game_type = "world") read a further set of globals -
tick_rate, grid_size, zone_size, view_radius, persistent,
lazy_zones, zone_idle_timeout, max_active_zones,
spatial_grid_cell_size, cold_tick_divisor, empty_grace_ms,
player_ttl_ms. World server documents those.
Where you put guest_auth and registration matters. They are read from
match.lua in single-mode and from config.lua in multi-mode. A game with a
config.lua manifest that declares guest_auth = true in match.lua instead
gets nothing, silently: the config loader reads config.lua when it exists and
never looks at match.lua.
Multiple game modes
Add a config.lua manifest mapping mode names to scripts:
-- config.lua
return {
arena = "arena/match.lua",
ctf = "ctf/match.lua"
}
Infrastructure config
Infrastructure settings come from environment variables. Every default below is
the image's own ENV; consuming asobi as a dependency, these do not exist and
you write sys.config instead.
| Variable | Default | Description |
|---|---|---|
ASOBI_PORT |
8084 |
HTTP and WebSocket port |
ASOBI_DB_HOST |
db |
PostgreSQL host |
ASOBI_DB_NAME |
asobi |
Database name |
ASOBI_DB_USER |
postgres |
Database user |
ASOBI_DB_PASSWORD |
postgres |
Database password |
ASOBI_DB_SOCKET_OPTS |
inet |
Erlang term fragment spliced into kura's socket_options list. inet, inet6, inet, {nodelay, true}. Set inet6 for IPv6-only Postgres networks |
ASOBI_CORS_ORIGINS |
none | Allowed CORS origin. Effectively required for any browser client: unset renders an empty Access-Control-Allow-Origin, which no browser accepts |
ASOBI_NODE_HOST |
127.0.0.1 |
Erlang node hostname, in -name asobi@.... Not a bind address |
ERLANG_COOKIE |
asobi |
Erlang distribution cookie. The default is the literal string asobi |
The database port is not a variable. It is fixed at 5432 in the image's
sys.config, so a Postgres on another port means supplying your own.
Erlang (sys.config)
For Erlang OTP projects that add asobi as a dependency, configuration lives in
sys.config under the {asobi, [...]} key.
Which application key
Everything below goes under {asobi, [...]}.
The Lua runtime used to be its own OTP application, so the keys it owns -
max_heap_words, max_reductions_per_ms, reload_mode,
config_watch_interval, dev_errors, terrain_providers, lua_gc and
rate_limits -
are still read from asobi_lua first and asobi second
(asobi_lua_env:get_env/2). An existing {asobi_lua, [...]} block keeps
working and there is nothing to migrate. Put new configuration under {asobi, [...]}.
Everything else, game_dir and game_modes included, is an asobi key only
and always was.
The module names have not moved either: asobi_lua_config, asobi_lua_api,
asobi_lua_loader and friends are current, and so is ASOBI_LUA_RELOAD. Only
the image name changed - see Glossary.
Game modes
{game_modes, #{
~"arena" => #{
module => my_arena_game,
match_size => 4,
max_players => 8,
strategy => fill
}
}}
Lua scripts work too, in the same release:
{game_modes, #{
~"arena" => #{
module => {lua, "game/match.lua"},
match_size => 4,
max_players => 8,
strategy => fill
}
}}
Luerl is a hard dependency of asobi and asobi_app:start/2 registers the Lua
providers itself, so {lua, _} modes work in a stock release with no extra
application. {error, lua_runtime_unavailable} survives only as the answer for
a mode kind that has no registered provider, which a stock release does not
have.
Shorthand (Erlang module only):
{game_modes, #{
~"arena" => my_arena_game
}}
Mode options
| Option | Default | Description |
|---|---|---|
module |
required | Erlang module or {lua, "path.lua"} |
match_size |
2 |
Players needed to start a match |
max_players |
match_size for matches, 500 for worlds |
Maximum players per instance |
strategy |
fill |
Matchmaking strategy: fill, skill_based, or a custom module |
skill_window |
200 |
Initial skill difference allowed (skill_based only) |
skill_expand_rate |
50 |
Window expansion per 5 seconds (skill_based only) |
bots |
#{} |
Bot configuration - see Bots |
listed |
false for matches, true for worlds |
Whether instances appear in discovery (match.list / world.list). Matches are unlisted by default: a matchmaker-spawned match is already assigned to its players, so opt in explicitly |
quick_play |
true for worlds, false for matches |
Whether world.find_or_create / match.find_or_create may place a player into an existing instance of this mode. Match modes default closed so a mode written before match.find_or_create existed is not exposed on upgrade. Independent of listed - see World server |
Operator modes and game-declared modes
Modes come from two independent places and asobi keeps them apart (ADR 0006):
- Operator modes are the ones above, in your
sys.configgame_modes. asobi never rewrites that key. - Game-declared modes are what a Lua game declares in
match.luaor aconfig.luamanifest. Loading a game replaces that set wholesale, so a mode you delete fromconfig.luais gone the next time the config loads instead of lingering until a restart.
The effective registry is the game-declared set with the operator set on top:
an operator mode wins a name clash and a game bundle can never drop or redefine
it. Read it with asobi_game_config:modes/0. The raw game_modes app-env key
is only the operator half.
The override is whole-entry, not per-key. The merge happens at the mode name, so an operator entry replaces the game's entire map for that mode rather than layering onto it. Writing the minimal-looking
{game_modes, #{~"arena" => #{listed => true}}}
does not force listed on top of the game's config - it replaces the mode with
one that declares no module, and the mode then fails to resolve. To override
one key you must restate the whole shape, including
module => {lua, "..."}.
Game directory
{game_dir, "/app/game"}
Where the Lua loader looks for config.lua, match.lua and every script a
mode names. /app/game is the image's default and the mount point it declares.
There is no environment variable for it.
Matchmaker
{matchmaker, #{
tick_interval => 1000, %% ms between matchmaker ticks (default 1000)
max_wait_seconds => 60 %% ticket expiry (default 60)
}}
The queue and its tickets live in this node's own process. Players queuing against different nodes never match each other - see Clustering.
Sessions
Nothing to configure. Access tokens last 60 minutes and refresh tokens 30 days,
from nova_auth's defaults, and asobi_auth:config/0 does not override them.
Changing either means editing that function.
Rate limiting
Per-route-group sliding windows via Seki. Buckets are per node, so a 5/s limit is 5 x N across a cluster; size them for one node and read Clustering before you rely on a number.
{rate_limits, #{
auth => #{limit => 5, window => 1000}, %% 5 req/sec for login/refresh
iap => #{limit => 10, window => 1000}, %% 10 req/sec for IAP
api => #{limit => 300, window => 1000} %% 300 req/sec for API
}}
| Group | Default | Keyed on |
|---|---|---|
auth |
5 / 1000 ms | IP |
register |
3 / 1000 ms | IP |
iap |
10 / 1000 ms | IP |
api |
300 / 1000 ms | IP |
ws_connect |
60 / 1000 ms | IP |
join |
10 / 60000 ms | player |
rehome |
5 / 1000 ms | player |
guest_global |
100 / 1000 ms | a constant (global) |
rehome_global |
200 / 1000 ms | a constant (global) |
script_log |
3 / 10000 ms | the failing call site |
register has its own bucket because /auth/register runs the password KDF as
its only cost gate. script_log bounds log lines from a script that fails on
every tick, not the telemetry counter behind them. rehome_global is a
placeholder default: size it from your real concurrent-player target.
Override any group; unset groups keep their default.
Request body cap
asobi_body_cap_plugin runs before Nova buffers a request body, so an
oversized POST is rejected before it reaches the heap.
{nova, [
{plugins, [
{pre_request, asobi_body_cap_plugin, #{
max_body => 1048576,
require_content_length => true
}}
]}
]}
| Option | Default | Description |
|---|---|---|
max_body |
1048576 (1 MiB) |
Bodies larger than this get 413 payload_too_large |
require_content_length |
true |
A body with no content-length gets 411 length_required rather than being streamed |
Per-route checks (cloud save, storage) still apply on top of this floor. The image configures both values already.
Pre-auth client gate
An optional gate in front of the anonymous auth-create routes, for a CAPTCHA or an attestation check. Unset, it is a no-op.
{client_gate, my_captcha_gate},
{client_gate_timeout, 5000},
{client_gate_on_error, deny}
| Key | Default | Description |
|---|---|---|
client_gate |
unset | Module implementing asobi_client_gate. Unset disables the gate entirely |
client_gate_timeout |
5000 |
Milliseconds to wait for the gate's verdict |
client_gate_on_error |
deny |
What a crashed or timed-out gate means. Anything but skip rejects; skip trades the check for availability |
A rejected request gets 403 client_gate_denied, with the gate's own reason in
details.reason (client_gate_unavailable when the gate itself failed). It
runs after the rate limiter, so a flood is shed by the cheap in-memory check
before it reaches an external verification service.
The datagram gateway role
One image, two roles. role defaults to engine and gives you exactly what you
have today; dgram_gw starts the datagram gateway and nothing else.
{role, dgram_gw},
{dgram, #{port => 7777, shards => 4}}
Run them as two containers from the same image. That separation is the point
rather than a deployment convenience: the gateway binds a UDP port and parses
packets from anyone on the internet, and it must not share a process tree with
the Lua sandbox or your database credentials. In the dgram_gw role no zone, no
world, no match, no Lua VM and no database pool is ever started.
shards is the number of SO_REUSEPORT receiver sockets and defaults to the
scheduler count capped at 8. It is fixed at boot. Adding or removing a socket
reshuffles the kernel's hash and breaks every flow already running through the
gateway, so there is no reload path and changing it is a restart with a
reconnect for every player on the plane.
The engine side
The engine dials the gateway; the gateway never dials the engine. So the engine needs to know where it is, and both ends need the same secret:
%% On the engine
{dgram_gateway, #{host => {127, 0, 0, 1}, port => 7778}},
{dgram_link_secret, <<"...">>},
{dgram_endpoint, ~"udp.example.com:7777"},
%% On the gateway
{role, dgram_gw},
{dgram, #{port => 7777, link_port => 7778, shards => 4}},
{dgram_link_secret, <<"...">>}
dgram_gateway is the opt-in. Without it the engine dials nothing, mints nothing
and answers datagram_unavailable to any client that asks - which is a normal
answer, not an error.
dgram_endpoint is what a client is told to send to, handed over in the mint
response. Putting it there rather than having the client resolve it is what makes
the plane independent of DNS and of SNI, and why a non-standard port costs the
client nothing.
The link is loopback-only and is not encrypted. It carries mint secrets, so
it binds 127.0.0.1 and refuses to be told otherwise. Two containers sharing a
network namespace is the shape it is built for; separate hosts need a tunnel, and
that is an operator decision rather than something to default.
Deliberately not distributed Erlang, which would have been the obvious answer and is the wrong one: dist is all-or-nothing, so a node that can reach another can call any function in it. Handing that to the process parsing packets from the internet gives back most of what the two-role split is for.
Describing your transform fields
Nothing is sent on the plane until you say what a position is. There is no
default and that is deliberate: guessing x and y at some scale would silently
pick a precision for a world that might be a thousand times larger.
{dgram_pose, #{
period_ticks => 20,
fields => [
#{name => ~"x", scale => 100},
#{name => ~"y", scale => 100},
#{name => ~"vx", scale => 100},
#{name => ~"vy", scale => 100}
]
}}
The list is the canonical order, so a client decodes a fixed layout and the wire carries no field names at all. At most eight fields - the per-record bitmask is one byte - and a ninth disables the plane rather than dropping a field silently.
scale converts to the int16 the wire carries: 100 gives two decimal places
and a range of about +/-327 world units. A bigger world needs a smaller scale and
coarser steps, which is a trade only your game can make. A value outside the
range saturates and is counted on asobi.dgram.pose_saturated, never wrapped -
wrapping would teleport an entity across the world, which looks like a game bug,
where saturation looks like what it is.
period_ticks is the axial refresh. An entity that stops moving stops being
mentioned, so a client that missed its last update would keep it wrong forever;
each tick additionally re-sends every entity whose slot falls in that tick's
slice, so at 20 ticks nothing is stale for more than a second. It costs no acks,
no per-client state and no extra encode.
Only these fields travel on the plane. Everything else about an entity -
including its creation and removal - rides world.tick on the WebSocket, where
it is ordered and cannot be lost.
Clients ask for it over the WebSocket
A client mints with rpc.call on the method asobi.datagram.open, which is a
frame every SDK already implements, so the datagram plane adds zero frame
types to the JSON wire. The reply carries conn_id, kup, epoch, endpoint
and expires_in.
The plane is optional in every state: the WebSocket carries everything throughout, and a client that never reaches the gateway is degraded rather than broken. The datagram plane is the whole story end to end
- what it carries, the compose file, the client side, and what happens when it does not work.
Binary world.tick
Off by default. Turning it on lets a client ask for world.tick as a binary
frame at session.connect, about a quarter of the bytes and several times
cheaper to decode - the numbers and the encoding are in
the protocol guide.
{binary_wire, true}
A zone reads this once when it starts, so an already-running world keeps the setting it started with.
What it costs while on: a zone can have subscribers on both wires, so it builds two buffers per broadcast instead of one. That is two encodes per zone per tick rather than one per subscriber, and it is paid whether or not anyone has negotiated binary. Measured at roughly 50 us per zone per broadcast tick against a 50 ms budget.
Clients that never ask see exactly what they saw before, so turning it on is safe for a live deployment. Leave it off if no client in your game asks for it.
WebSocket origin allowlist
By default the /ws upgrade accepts any Origin: web builds are served from
arbitrary studio and hosting domains, so a strict default would break them.
To harden a deployment against cross-site WebSocket hijacking, set an allowlist:
{ws_allowed_origins, [
~"https://play.yourgame.com",
~"https://yourstudio.itch.io"
]}
When set, a browser upgrade whose Origin is not listed is closed with 1008 origin_rejected and emits [asobi, ws, origin_rejected]. Leaving it unset or
empty keeps the open default.
Match is exact against the value the browser sends, so copy that verbatim:
scheme, host and non-default port only. No trailing slash, no path, all
lowercase, punycode (xn--...) for internationalised domains, and each entry a
binary rather than a string. A trailing slash, an explicit :443 or an
uppercase host silently matches nothing and locks out real users. A value that
is not a list of binaries is treated as a misconfiguration and fails closed,
rejecting everything, with a logged error.
This is independent of CORS: CORS governs XHR and fetch, not the WebSocket handshake.
Native clients (Defold, Unity, Unreal) send no Origin header and are never
affected. An absent Origin always passes, since a non-browser client cannot
be a CSWSH vector. The socket also does nothing until it presents a valid token
in the first session.connect frame, so this is defence in depth, not the
primary auth gate.
Deprecated game.* extension frames
Extension-produced pushes go out as module.message and module.error. The
pre-rename names game.message and game.error are emitted alongside them,
with identical payloads, so SDK builds from before the rename keep working.
They are removed at the 1.0 wire break.
{ws_legacy_game_frames, false}
Set this once every client on the deployment dispatches on module.*, and each
extension message drops from two frames to one. game.message carries
game.send/2, which a script may call per player per tick, so on a chatty game
the compatibility frame doubles that path. Any client still listening for
game.* goes silent the moment you set it. Default true. See
WebSocket protocol.
CORS
CORS is handled by nova_cors_plugin in the Nova plugin chain:
{nova, [
{plugins, [
{pre_request, nova_cors_plugin, #{allow_origins => ~"https://mygame.com"}}
]}
]}
In the image this is ASOBI_CORS_ORIGINS, and it has no default.
Clustering
Optional multi-node clustering via Erlang distribution. Both forms below match Clustering, which is the guide for this.
DNS strategy (Fly.io, Kubernetes)
{cluster, #{
strategy => dns,
dns_name => ~"asobi-headless.default.svc.cluster.local",
poll_interval => 10000
}}
dns_name must be a binary. A string crashes the discovery server on every
poll.
EPMD strategy (static hosts)
{cluster, #{
strategy => epmd,
hosts => ['host-a', 'host-b']
}}
hosts are bare hostnames, not node names. asobi derives each peer's node name
by reusing this node's basename, so 'node@host' in that list produces
asobi@node@host, which resolves to nothing.
Authentication providers
OAuth and OIDC
{oidc_providers, #{
google => #{
issuer => ~"https://accounts.google.com",
client_id => ~"...",
client_secret => ~"..."
},
apple => #{
issuer => ~"https://appleid.apple.com",
client_id => ~"...",
client_secret => ~"..."
}
}}
Every provider needs issuer, client_id and client_secret. asobi discovers
the rest (authorize, token and JWKS endpoints) from the issuer's
.well-known/openid-configuration document. A provider entry with no issuer,
or an issuer that is not https://, is logged and disabled on its own; the node
still boots and the other providers are unaffected - see
Authentication for the full supported-provider table and
per-provider notes.
base_url is the public origin asobi uses to build redirect URIs (default
~"http://localhost:8082"). Set it to your deployed URL so the redirect
providers call back to matches what you registered:
{base_url, ~"https://mygame.com"}
Steam
{steam_api_key, ~"your-steam-web-api-key"},
{steam_app_id, ~"480"}
Apple and Google IAP
{apple_bundle_id, ~"com.example.mygame"},
{apple_root_cert_path, ~"/path/to/AppleRootCA-G3.pem"},
{google_package_name, ~"com.example.mygame"},
{google_service_account_key, ~"/path/to/service-account.json"}
apple_root_cert_path points at the Apple Root CA (PEM or DER) that
asobi_iap:verify_apple/1 validates the StoreKit 2 receipt chain against.
Without it Apple receipt verification is refused.
Guest (anonymous) auth
Guest auth lets a device create a throwaway player without credentials and
upgrade it to a real account later. It is opt-in and fails closed: the guest
endpoints return 403 guest.disabled until the game declares guest_auth = true in its Lua config and the operator sets a guest_verifier_pepper
(ADR 0004). The game half is a Lua global, not a sys.config key - see
Authentication. This page covers the
operator half.
%% Required. A key-id -> pepper map (>= 32 bytes each). Keep old key ids for the
%% guest retention window so existing guests can still resume after rotation.
{guest_verifier_pepper, #{~"v1" => ~"a-32-byte-or-longer-secret......"}},
{guest_verifier_key_id, ~"v1"},
%% Optional abuse control: max unclaimed guests, or `infinity`.
{guest_unlinked_cap, 100000},
%% Optional retention. Unset = permanent guests (never reaped). Seconds of
%% inactivity after which an unclaimed guest is deleted by the reaper. The
%% clock restarts every time the device resumes, so this never expires a
%% player who is still playing.
{guest_reap_after, 2592000}
| Key | Default | Description |
|---|---|---|
guest_verifier_pepper |
none | Key-id -> pepper map, or a single binary. Each pepper must be at least 32 bytes; a shorter one is treated as absent. Presence is the operator's on switch |
guest_verifier_key_id |
~"v1" |
Which pepper key id to use when minting new verifiers |
guest_unlinked_cap |
100000 |
Soft ceiling on unclaimed guests, or infinity. Anything else falls back to the default and logs invalid_guest_unlinked_cap |
guest_reap_after |
unset | Seconds of inactivity since the device last resumed; unset disables the reaper, so guests are permanent. Also reads ASOBI_GUEST_REAP_AFTER. On cloud this is the Guests picker on the environment row, not a key you write |
The cap is a soft ceiling, not an exact one: the count comes from a short-TTL
cache rather than a COUNT per create, so it can overshoot by roughly (TTL x
create rate). Reaching it answers 503 guest.capacity_reached. If the node
cannot run the count at all it refuses too, but under 503 guest.unavailable -
a different problem with a different fix, and a database fault rather than a
full deployment. Both log guest_create_denied with a reason; the cap denial
also logs the count and cap it compared, which is what tells you whether
the ceiling is anywhere near.
Clients can also shed guests themselves with POST /api/v1/players/me/erase
(see REST API). Reach for it when a
player asks to be deleted, not as a way to do housekeeping: a client-side
erasure is one HTTP request per account, issued by a process that may be on its
way out. Several engines bind a response callback to the object that made the
call, so the natural place to put it - a quit, a teardown, a screen closing -
is exactly where the reply is dropped and the request may never land. Retention
is the server's job; use this setting for it.
Measured from the last resume, not from account creation. Under device auth a guest stays unclaimed for life - there is no password to set - so account age would say nothing about whether anyone is still playing, and a returning player would be deleted on schedule.
A reaped guest is erased in full - wallet, ledger, saves, storage, chat,
friendships, identities and any installed extension's rows - through the same
asobi_player_erase an operator-initiated erasure uses. This is permanent and
irreversible, it takes up to 500 accounts per sweep, and the sweep writes no
audit rows; it logs a count. Set it deliberately. See
Erasing and exporting a player.
In the image today this needs a sys.config. The Dockerfile declares
ASOBI_GUEST_VERIFIER_PEPPER, but nothing substitutes it into sys.config, so
setting the variable configures nothing and guest auth stays closed. Mount a
sys.config with the pepper until that is fixed.
The pepper is a server-side secret kept outside the database: keep it in a
secret manager, never in source. To rotate, add a new key id and point
guest_verifier_key_id at it, keeping the old ids for at least the retention
window so existing guests can still resume. Guest creation is bounded by the
per-IP auth limiter plus the global guest_global limit.
Ops plane
The /api/v1/ops routes are for a game-operations console, not a game client,
and they carry their own credential. Fails closed: unset the key and every ops
request is rejected, so a deployment that never reads this page is closed
rather than open. There is no default credential.
%% Required to use /api/v1/ops at all. Random, >= 32 bytes.
{ops_secret, ~"a-32-byte-or-longer-random-secret"}
| Key | Default | Description |
|---|---|---|
ops_secret |
none | Operator bearer token for /api/v1/ops. Unset rejects every ops request |
32 bytes is a recommendation here, not a rule: asobi_ops_auth accepts any
non-empty binary. ops_token_secret below and guest_verifier_pepper above
are length-checked and silently treat a short value as unset, so the three do
not behave alike.
Send it as Authorization: Bearer <ops_secret>. It is compared in constant time
and never leaves the server. Player and guest tokens are rejected here: the ops
plane never consults the player token store.
One secret is one privilege level: whoever holds it holds every capability
class, including config and erasure. Restrict who can reach the plane with
a reverse proxy, and set x-asobi-operator per person for attribution in the
audit trail - it is a label, never authority. A console session opened with
this secret is the one exception: it gets every class but erasure unless
console_erasure is set. See
REST API for the per-route reference and
Operator console for the operator narrative and for what the plane
can and cannot do.
Minted tokens (managed environments)
A managed environment takes a second kind of ops credential: a short-lived, env-scoped token minted by a control plane after it has authenticated the tenant and checked they own this environment. Self-hosting needs none of this, and Cloud walks the handoff end to end.
{ops_token_secret, ~"${ASOBI_OPS_TOKEN_SECRET}"},
{env_id, ~"${GAME_ID}"}
| Key | Default | Description |
|---|---|---|
ops_token_secret |
none | A per-environment secret that signs ops tokens and nothing else. At least 32 bytes; shorter is treated as unset |
env_id |
none | This environment's id. A token minted for another one is refused |
It is deliberately not the credential the engine authenticates with. A value that both proves who the engine is and signs the operator credentials it accepts is one leak away from doing both for an attacker, and deriving one from the other prevents confusion but not shared compromise.
Rotating it revokes every ops token outstanding for the environment at once, which is the only revocation there is.
Both or neither: a node that knows the secret but not which environment it is
cannot check a token's env claim, so it refuses every minted token rather
than accepting one issued for somebody else's environment.
Unlike ops_secret, a minted token carries only the capability classes it was
minted with, so a tenant whose role maps to read and player_data cannot
reach a config route with it. The role name never arrives here; the control
plane maps it to classes at mint time.
The lifetime is capped at 15 minutes by this node, not by the minter. A token signed with a longer one is refused, because there is no revocation list to fall back on if the minting side ever issues a bad one.
Operator console
A browser console for the ops plane, served by this node at /console. Off by
default: Nova starts one listener, so the console shares the game port, and an
operator surface on a public port has to be asked for.
{console, true},
{ops_secret, ~"a-32-byte-or-longer-random-secret"}
| Key | Default | Description |
|---|---|---|
console |
false |
Serve the console at /console. Anything but true is off, and every console route answers 404 |
console_session_ttl |
43200 |
Session lifetime in seconds, clamped to 60-86400. Absolute: it is not extended by use |
console_secure_cookie |
false |
Force Secure on the session cookies. Set it behind a TLS terminator that does not send x-forwarded-proto |
console_api_base |
none | Absolute https://host[:port] origin the console should call instead of this one. Also widens connect-src. Anything that is not a bare origin is ignored |
console_label |
none | Names this deployment in the tab title and the console header |
console_production |
false |
Marks a deployment to be careful in. The console colours its label |
console_erasure |
false |
Let a console session erase players. Off because a browser can be clickjacked and an erasure cannot be undone; a bearer secret holds the class regardless |
console_bundle_app |
asobi |
Which application's priv/console is served. Point it at the application rebar3 asobi console wrote a composed bundle into. An application that is not in the release makes /console answer 503 and logs bundle_app_unavailable; it never falls back to asobi's own bundle |
console, console_label and console_production also read
ASOBI_CONSOLE, ASOBI_CONSOLE_LABEL and ASOBI_CONSOLE_PRODUCTION, and
ops_secret reads ASOBI_OPS_SECRET_FILE or ASOBI_OPS_SECRET. The other
five - console_session_ttl, console_secure_cookie, console_api_base,
console_erasure and console_bundle_app - have no environment variable and
need a sys.config. A variable overrides sys.config only when it is set, so
the two coexist.
guest_reap_after reads ASOBI_GUEST_REAP_AFTER, in seconds, on the same
terms. Anything that is not a positive integer leaves it unset, which means
guests are kept for ever: a node that cannot parse its own retention setting
must not fall back to deleting accounts on a schedule nobody chose. 0 is the
explicit "off".
console_bundle_app is only for a host whose extensions ship their own operator
screens; see Extending the operator console. It has no
environment variable on purpose: it names an application in the release, so it
is decided when the release is built, not when the container starts.
There is no ASOBI_DB_PASSWORD_FILE. The database password is substituted into
sys.config before any Erlang runs, so it cannot be read from a file the way
the ops secret can.
Sessions live in memory. The session store and the CSRF secret are per node, so the console needs a sticky route behind a load balancer and a restart signs everyone out - see Clustering and Operator console, which owns turning it on, signing in, what the screens show and the troubleshooting.
Storage
Cloud saves and the generic key-value store, served at /api/v1/saves* and
/api/v1/storage* and exposed to Lua as game.storage.*. On by default; set
storage to false to switch the whole subsystem off - the opposite default
to the console, which is off until asked for.
{storage, false}
| Key | Default | Description |
|---|---|---|
storage |
true |
Serve the storage subsystem. When false the seven /saves and /storage routes answer 404 and the game.storage.* Lua namespace is withheld at VM install |
It has no environment variable; set it in sys.config.
Vote templates
Reusable vote configurations, merged with the per-vote config from your game module:
{vote_templates, #{
~"map_vote" => #{
method => ~"plurality",
window_ms => 15000,
visibility => ~"live"
}
}}
Instance capacity
Bounds on persistent world creation, enforced as a DoS backstop:
{world_max_per_player, 5}, %% default 5
{world_max, 1000} %% default 1000
A player at the per-player cap gets 429 world.player_limit_reached; once the
global cap is reached further creates get 503 world.capacity_reached. The
global cap is checked first.
Matches have a node-wide cap of their own:
{match_max, 1000} %% default 1000
It bounds match.find_or_create, and answers match_capacity_reached. The
matchmaker used to bound match creation implicitly - forming one took
match_size queued tickets - and that bound disappears once a single player can
create a match. There is no per-player match cap: matches carry no owner, unlike
worlds.
Join rate
Joins are bounded per player, not per IP:
{rate_limits, #{
join => #{algorithm => sliding_window, limit => 10, window => 60000}
}}
Joining is how a client reaches a world's roster and leaving is free, so an
unbounded join rate lets one account enumerate every live world by joining,
reading world.joined and leaving. The default (10 per minute) is generous for
real play and turns a sweep of a full deployment from seconds into hours per
identity. Exceeding it returns join_rate_limited and emits [asobi, join, rate_limited].
This bounds the cost of a sweep; it does not make worlds private. For that,
implement join/3 in your game module and reject unauthorised joins - see
WebSocket protocol.
Zone crossing rate
For world-mode games, re-homing a player across a zone boundary is bounded
per player and, separately, globally:
{rate_limits, #{
rehome => #{algorithm => sliding_window, limit => 5, window => 1000},
rehome_global => #{algorithm => sliding_window, limit => 200, window => 1000}
}}
Each crossing updates part of the player's interest ring and resends a full
zone snapshot to any newly-subscribed zone, so an unbounded rate lets one
client force that work every tick by parking on a zone boundary. The per-player
default (5/sec) bounds the worst case on top of the crossing's own hysteresis
margin (see World server); it caps sustained crossing speed
at limit * zone_size units/sec, so a fast-moving game on a small zone_size
may need to raise it. The global bucket bounds the aggregate load N concurrent
attackers can push into the world's single terrain store.
Denied crossings are not dropped input: the player's position still updates
within their current zone, they just do not re-home that tick. Exceeding the
limit emits [asobi, rehome, rate_limited].
Terrain provider allowlist
For Lua large-world games, only allowlisted terrain generators can be named from Lua:
{asobi, [
{terrain_providers, [asobi_terrain_flat, asobi_terrain_perlin]}
]}
The default allows asobi_terrain_flat and asobi_terrain_perlin.
Per-call upper bounds
These runtime limits bound the cost of a single request. They are not configurable; they are here so you can size clients accordingly.
| Limit | Value |
|---|---|
| Cloud save body | 256 KB |
| Save slots per player | 10 |
| Inventory consume quantity | 1 .. 1000000 |
Leaderboard top ?limit |
1 .. 100 |
Leaderboard around ?range |
1 .. 50 |
Chat history ?limit |
1 .. 200 |
| DM content | 2000 bytes |
| WS chat channels per connection | 32 |
| Idle channel timeout | 60s |
| Lua table decode depth | 64 |
Database (Kura)
Database configuration is under the kura application key:
{kura, [
{backend, kura_backend_postgres},
{repo, asobi_repo},
{host, "localhost"},
{port, 5432},
{database, "my_game_dev"},
{user, "postgres"},
{password, "postgres"},
{pool_size, 10}
]}
Background jobs (Shigoto)
{shigoto, [
{pool, asobi_repo}
]}
Full example (Erlang sys.config)
[
{kura, [
{backend, kura_backend_postgres},
{repo, asobi_repo},
{host, "localhost"},
{database, "my_game_dev"},
{user, "postgres"},
{password, "postgres"},
{pool_size, 20}
]},
{shigoto, [
{pool, asobi_repo}
]},
{asobi, [
{rate_limits, #{
auth => #{limit => 10, window => 60000},
api => #{limit => 300, window => 1000}
}},
{matchmaker, #{
tick_interval => 1000,
max_wait_seconds => 60
}},
{game_modes, #{
~"arena" => #{
module => {lua, "game/match.lua"},
match_size => 4,
max_players => 8,
strategy => fill,
bots => #{
enabled => true,
min_players => 4,
script => ~"game/bots/chaser.lua"
}
}
}}
]}
].
Full example (Lua and Docker)
# docker-compose.yml
services:
postgres:
image: postgres:17
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: my_game_dev
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
asobi:
image: ghcr.io/widgrensit/asobi:latest
depends_on:
postgres: { condition: service_healthy }
ports:
- "8084:8084"
volumes:
- ./lua:/app/game:ro
environment:
ASOBI_DB_HOST: postgres
ASOBI_DB_NAME: my_game_dev
ASOBI_CORS_ORIGINS: https://play.yourgame.com
-- lua/match.lua
match_size = 4
max_players = 8
strategy = "fill"
bots = { script = "bots/chaser.lua" }
function init(config)
return { players = {} }
end
-- ... rest of callbacks
-- lua/bots/chaser.lua
names = {"Spark", "Blitz", "Volt", "Neon"}
function think(bot_id, state)
-- AI logic
end
Next steps
- Self-hosting - requirements, the production compose, and what to check before you go live.
- Clustering - multi-node config and what is per node.
- Operator console - turning the console on and using it.
- Performance tuning - the tick and BEAM knobs.