Architecture
View SourceWhat a node is
An asobi node is a single Erlang/OTP release holding the game backend, the Lua
runtime and the operator console. There is no separate scripting service and no
separate admin service. The image is ghcr.io/widgrensit/asobi and the release
binary inside it is bin/asobi (Dockerfile:75).
Two front doors onto the same node:
- Run the image and write Lua.
match.luaor aconfig.luamanifest is loaded at boot and drives matches, worlds and bots. - Depend on the Hex package and write Erlang. Implement the
asobi_matchbehaviour and register the module undergame_modes.
Both reach the same processes, the same database and the same console.
Supervision tree
asobi_sup is one_for_one, 10 restarts in 60 seconds
(src/asobi_sup.erl:16-20). Nineteen children, in this order
(src/asobi_sup.erl:21-41):
asobi_sup (one_for_one)
├── asobi_rate_limits temporary; registers the seki limiter buckets
├── asobi_oidc_providers temporary; starts OIDC workers only if providers are configured
├── asobi_auth_cache per-node bearer-token cache
├── asobi_cluster node discovery; returns `ignore` unless `cluster` is set
├── asobi_player_session_sup simple_one_for_one
│ └── asobi_player_session one per connected socket
├── asobi_match_sup simple_one_for_one; owns the asobi_match_state table
│ └── asobi_match_server one per match (gen_statem)
├── asobi_world_sup owns asobi_world_state and asobi_player_worlds
│ ├── asobi_zone_snapshotter
│ ├── asobi_world_registry
│ └── asobi_world_instance_sup simple_one_for_one
│ └── asobi_world_instance one_for_all: zone sup, zone manager, ticker, world server
├── asobi_world_lobby_server serialises asobi_world_lobby:find_or_create/1
├── asobi_vote_sup simple_one_for_one
│ └── asobi_vote_server one per open vote
├── asobi_matchmaker the queue; ticks itself
├── asobi_leaderboard_sup simple_one_for_one
│ └── asobi_leaderboard_server one per board
├── asobi_chat_sup simple_one_for_one; owns the channel registry table
│ └── asobi_chat_channel one per live channel
├── asobi_tournament_sup simple_one_for_one
│ └── asobi_tournament_server registered under `global`
├── asobi_presence online set and delivery targets, over pg
├── asobi_guest_reaper opt-in sweep of unclaimed guest accounts
├── asobi_console_session console session table; started whether or not the console is on
├── asobi_lua_config temporary; loads the game config
├── asobi_lua_sup
│ ├── asobi_lua_rate_limits temporary; `game.log` budgets
│ ├── asobi_bot_sup
│ ├── asobi_bot_spawner
│ └── asobi_lua_config_watcher
└── asobi_extension_sup one child group per installed extensionThe last three entries are ordered deliberately and the order is load-bearing:
core children, then the Lua config load, then the Lua children, then extensions
(src/asobi_sup.erl:44-52). The Lua runtime used to be its own OTP
application, which started after asobi and therefore loaded the game config
after every asobi_sup child was already up. Moving the load earlier changes
what a core child that reads configuration at start_link/0 sees at boot, so
it stays here and the old order is preserved exactly.
A game config that fails to load aborts the boot. load_lua_game_config/0
raises, which arrives as a supervisor failed_to_start_child and takes the
already-started core children down with it (src/asobi_sup.erl:60-72). A node
with an unloadable game config has nothing useful to serve.
asobi_extension_sup is last so an extension's processes start after every
core service they might call. With no extensions installed it is one idle
supervisor with no children (src/asobi_sup.erl:81-89).
asobi_rate_limits is a temporary registration child, not a server: it calls
seki:new_limiter/2 once per bucket and returns ignore
(src/asobi_sup.erl:217-305). The buckets themselves live in seki, in memory,
per node - ten of them, covering auth, register, iap, api, ws_connect, join,
guest_global, script_log, rehome and rehome_global.
Session lifecycle
One asobi_player_session process per connection, started when the socket
sends session.connect and stopped when the socket closes
(src/ws/asobi_ws_handler.erl:356-370). A session does not survive the
connection. A client that reconnects presents the same token and gets a new
session process.
Client WS handler Session pg
│ │ │ │
│─ WS connect ───────►│ │ │
│─ session.connect ──►│ │ │
│ │ resolve_token │ │
│ │─ start_session ──►│ │
│ │ │─ join {player,Id}►│
│◄ session.connected ─│ │ │
│ ... play ... │ │ │
│─ disconnect ───────►│ │ │
│ │─ stop ───────────►│ │
│ │ │─ leave ─────────►│The token is resolved once, at session.connect. After that the player_id
lives in process state and no further lookup happens on the hot path. The
session monitors the WebSocket process, and terminate/3 calls
asobi_player_session:stop/1 for the other direction.
The auth cache
Bearer tokens are opaque 32 random bytes issued by nova_auth and stored in
player_tokens (src/asobi_auth.erl:6-18,
src/migrations/m20260329095708_update_schema.erl:185). They are not JWTs, so
resolving one is a database read, and both the HTTP plugin and the WebSocket
handler go through a per-node ETS cache to avoid doing it per request
(src/plugins/asobi_auth_plugin.erl:11, src/ws/asobi_ws_handler.erl:914).
Entries expire after auth_cache_ttl_ms, default 60000 ms; negative results
expire after auth_cache_negative_ttl_ms, default 5000 ms
(src/auth/asobi_auth_cache.erl:94-95). That TTL is the bound on revocation
latency: a token revoked through asobi_auth_tokens or
asobi_auth_cache:revoke_player/1 is invalidated immediately on the node that
did it, and everywhere else within the TTL. Access tokens are valid for 60
minutes and refresh tokens for 30 days, both nova_auth defaults
(src/asobi_auth.erl:15).
The cache is per node. Nothing replicates it.
Session revocation
asobi_presence:revoke_session(PlayerId, ~"banned").revoke_session/2 enqueues a job on the broadcast fanout queue
(src/social/asobi_presence.erl:121-123). Every node consumes the fanout
queue, and asobi_broadcast_worker:perform/1 calls
asobi_presence:disconnect/2 locally, which looks the player up in the local
pg group and sends {session_revoked, Reason} to each session process
(src/workers/asobi_broadcast_worker.erl:24-25).
asobi_broadcast_worker is the only worker asobi ships, and it handles exactly
three job types: session_revoked, notification and chat. Anything else
logs unknown_broadcast_type and returns ok.
Fanout jobs are ephemeral - a 120 second window in the dev config
(config/dev_sys.config.src), auto-pruned. The database is the source of
truth; the fanout is a best-effort push.
Match lifecycle
asobi_match_server is a gen_statem under asobi_match_sup
(src/matches/asobi_match_server.erl:60-62,
src/matches/asobi_match_sup.erl:22-33). States are waiting, running,
paused and finished; a cancellation is a transition to finished with a
cancelled result, not a state of its own.
Matchmaker Match sup Match server Players (pg)
│ │ │ │
│─ start_match(Cfg)──►│ │ │
│ │─ start_link ─────►│ waiting │
│─ join(Pid, P1) ────────────────────────►│ │
│─ join(Pid, P2) ────────────────────────►│ running │
│ │ │◄─ {input, ...} ───│
│ │ │── tick ───────────│
│ │ │── broadcast ─────►│
│ │ │ finished │
│ │ │── persist ───────►DBServer-authoritative: the match process owns all game state, clients send
inputs, the server applies them on the tick and broadcasts the result. The tick
is a state_timeout, default 100 ms
(src/matches/asobi_match_server.erl:47,200).
The game module implements the asobi_match behaviour. Only init/1 and
exactly one of get_state/2 (per player) or get_state/1 (shared,
broadcast-once) are required; everything else is optional, including
join/2, join/3, leave/2, handle_input/3, tick/1, vote_requested/1,
vote_resolved/3, phases/1, on_phase_started/2 and on_phase_ended/2
(src/matches/asobi_match.erl:30-128). See
Performance tuning for when the shared form pays.
Finding a match
A match joins the pg group {asobi_match_server, MatchId} in nova_scope
when it starts, and asobi_match_server:whereis/1 resolves through that group
(src/matches/asobi_match_server.erl:152-156,167). Because pg replicates
within a scope across connected nodes, a match pid is resolvable from any node
in the cluster. The process itself does not move, and its 10 Hz broadcast stays
where it is. Nothing registers matches under global. Tournament
servers do: asobi_tournament_server starts as
{global, {asobi_tournament_server, TournamentId}}
(src/tournaments/asobi_tournament_server.erl:10).
asobi_match_state is a separate thing and is often misread as the registry.
It is a node-local public ETS table owned by asobi_match_sup
(src/matches/asobi_match_sup.erl:12) into which a match writes a
pid-stripped snapshot of its state, so a crashed match restarted by its
supervisor on the same node resumes rather than starting empty
(src/matches/asobi_match_server.erl:948-968). It is a crash-recovery buffer,
it is per node, and it does not survive the node.
Matchmaker
One asobi_matchmaker gen_server per node. It ticks itself with
erlang:send_after/3 - once in init/1 and once at the end of every tick
handler (src/matches/asobi_matchmaker.erl:217,342). The default interval is
1000 ms and the default wait before a ticket expires is 60 seconds, both under
the matchmaker key as tick_interval and max_wait_seconds.
Tickets are a plain map in the gen_server's own state
(src/matches/asobi_matchmaker.erl:229,236-270). There is no ticket table, no
ticket schema and nothing persisted: the queue dies with the node, and a
player queued against one node can never be matched with a player queued
against another. Plan for that before you run more than one node - see
Clustering.
One live ticket per player per mode. Re-adding while already queued for the
same mode returns the existing ticket rather than minting a second, so a
double-tapped "find match" cannot fill one player into a self-match
(src/matches/asobi_matchmaker.erl:246-252). A player may hold tickets in two
different modes at once.
Grouping is a strategy module - fill (first-come, first-served) and
skill_based (expanding window) ship. There is no query language and no region
concept; filtering beyond mode happens inside your strategy against the
ticket's properties map. When a group fills, the matchmaker starts the match
through asobi_match_sup on its own node.
Lua subsystem
The Lua runtime is fifteen modules under src/lua/ plus the bot modules. It is
not a wrapper around asobi; it is part of it.
Registration. asobi_app:start/2 calls
asobi_lua_sup:register_game_modes/0 before the supervision tree comes up,
which registers asobi_lua_match, asobi_lua_match_shared and
asobi_lua_world as the providers for the three Lua mode kinds
(src/asobi_app.erl:14,41-42, src/lua/asobi_lua_sup.erl:16-20). Without that
registration every {lua, Script} mode resolves to
{error, lua_runtime_unavailable} (src/asobi_game_modes.erl:92-100). This is
what makes a Lua mode resolvable at all, and it happens ahead of every
asobi_sup child including the matchmaker.
Config load. asobi_lua_config:maybe_load_game_config/0 reads either a
single match.lua or a config.lua manifest mapping mode names to script
paths (src/lua/asobi_lua_config.erl:5-20). If neither file exists it is a
no-op, so an Erlang project that configures modes in sys.config is
unaffected. If a file exists and is broken, the boot fails - fail-closed, as
described under the supervision tree above.
Loader and sandbox. asobi_lua_loader builds the Luerl state and clears
every dangerous standard-library entry point: os.execute, os.exit,
os.getenv, os.remove, os.rename, os.tmpname, the whole of io,
dofile, loadfile, load, loadstring, and the whole of package
(src/lua/asobi_lua_loader.erl:1-31). package is replaced by a controlled
require/1 that resolves names relative to the loaded script's directory and
rejects parent traversal and absolute paths. init_sandboxed/0 gives a
hardened state with no script attached, used for evaluating a config.lua
manifest; new/1 loads a script and pins its base directory. The API surface a
script sees is asobi_lua_api and asobi_lua_surface; see
Lua API.
Configuration keys. The runtime's own keys go through
asobi_lua_env:get_env/2 (src/lua/asobi_lua_env.erl:22-32), which reads
asobi_lua before asobi so a sys.config written against the old separate
application did not become dead config at the merge. See
Which application key.
World and zone subsystem
Twenty-two modules under src/world/. A world is long-lived, partitioned into
zones on a grid, and ticked by its own process.
asobi_world_instance is a one_for_all supervisor per world holding a zone
supervisor, a zone manager, a ticker and the world server, started in that
order because the world server discovers the others through the supervisor
(src/world/asobi_world_instance.erl:30-78). One world therefore lives entirely
on one node: its server, its ticker and every one of its zone processes are
children of one supervisor tree on one BEAM. Worlds do not migrate.
The world server joins the pg group {asobi_world_server, WorldId} so a
world is discoverable from any connected node
(src/world/asobi_world_server.erl:174,187). Two node-local public ETS tables
back it: asobi_world_state, holding zone entity snapshots, and
asobi_player_worlds, mapping a player to the world they are in
(src/world/asobi_world_sup.erl:26-36). Both are node-local, and both are
public, which is an explicit trust boundary - anything running in the same
BEAM can read and write them, and a sandboxed runtime layered on top must keep
its sandbox out of them.
See World server and Large worlds.
Leaderboards
One asobi_leaderboard_server per board, hydrating its ETS tables from
leaderboard_entries before it accepts reads
(src/leaderboards/asobi_leaderboard_server.erl:120-122).
The ETS table is an ordered_set keyed on {-Score, PlayerId}, so iteration
order is rank order (src/leaderboards/asobi_leaderboard_server.erl:138-168).
sub_score is written to the database as 0 and takes no part in ordering
(src/leaderboards/asobi_leaderboard_server.erl:362); it exists as a column,
not as a tiebreak.
A 30 second timer inside the board process flushes dirty players to Postgres
(src/leaderboards/asobi_leaderboard_server.erl:123,177,180). Each flush
upserts only the players that changed since the last one; a player whose write
fails stays pending and is retried. terminate/2 flushes, so a clean restart
does not lose scores.
The exported API is submit/3, top/2, rank/2, around/3 and
live_boards/0 (src/leaderboards/asobi_leaderboard_server.erl:6). There is
no reset/0, no archive table and no scheduled reset. Time-scoped boards are
something you build with separate board ids.
Chat
One asobi_chat_channel process per live channel, members tracked in the pg
group {chat, ChannelId}. A send fans out to the group's members and then
inserts the row inline, in the same handle_cast
(src/social/asobi_chat_channel.erl:131-137,204-219). There is no batching job
and no async persist queue. Channel types and who may reach them are in
asobi_chat_acl.
Presence
asobi_presence records two separate things: the delivery target for a player
and membership of the online set. track/2 records both; track_bot/2 records
only the delivery target, so bots receive broadcasts without counting toward
online_count/0 (src/social/asobi_presence.erl:15). Status changes go out
over nova_pubsub, not the fanout queue.
Everything asobi_presence:send/2 delivers is one of the shapes in
asobi_presence:message/0.
Extensions and the RPC seam
An extension is an OTP application listed in the release that declares a
manifest. asobi_extension_sup starts one child group per installed extension,
last in the tree. Extensions contribute no routes.
Clients reach an extension over the WebSocket, through one frame type:
{"type": "rpc.call", "cid": "c-1",
"payload": {"protocol": 1, "method": "shop.buy", "params": {"sku": "hat"}}}The reply is rpc.ok with a result, or rpc.error with the standard error
object:
{"type": "rpc.ok", "cid": "c-1", "payload": {"result": {"reward": 100}}}
{"type": "rpc.error", "cid": "c-1", "payload": {"error": {"code": "...", "message": "...", "details": {}}}}cid is required on rpc.call and validated, unlike the rest of the socket
where it is optional - it is the only way a client pairs a reply with the call
it made, and it is bounded and checked rather than reflected unchanged. A
rejected cid is not echoed back (src/extensions/asobi_rpc.erl:12-25,118-128).
Every client SDK speaks this. See Extensions.
Ops plane and console
The node serves an operator console at /console and an ops HTTP API at
/api/v1/ops/*, on the same listener as the game
(src/asobi_router.erl:25-26,214,282).
The two are gated separately. /console needs console to be true; the ops
routes are always mounted and reject everything until an ops_secret is
configured, so a stock node serves neither. Operator console owns
the detail.
Every ops route carries a capability class - read, player_data, config
or erasure (src/ops/asobi_ops_caps.erl) - and the class is the only thing
that authorises the call. Core's routes are reads apart from two:
GET /api/v1/ops/players/:id/export (player_data) and
POST /api/v1/ops/players/:id/erase (erasure). The third mutating route is
/api/v1/ops/ext/:extension/:action, whose behaviour comes from an installed
extension.
erasure is its own class because it is the only irreversible one, and a
console session is granted every class but that one by default
(src/console/asobi_console_session.erl).
The console holds no privileged path into the database. It reads the ops plane
over HTTP like any other client. It was previously a separate project,
asobi_admin, which read the same database directly; that is archived, because
a second deployment to secure and keep in step was the wrong shape for
something an operator opens during an incident.
Database and migrations
Each node runs its own pgo pool through Kura. Migrations are Erlang modules in
src/migrations/ and run at application start, before the supervision tree,
via kura_migrator:migrate(asobi_repo) (src/asobi_app.erl:16-22).
- All operations in one migration run in a single PostgreSQL transaction under an advisory lock, so only one node migrates at a time and the rest wait. Safe for a rolling deploy.
- Kura topologically sorts
create_tableoperations by foreign-key dependency, so ordering within a migration file does not matter. - Never edit or delete an applied migration. Add an
alter_tablemigration. - A failed migration logs
migration_failedand the node carries on starting, butasobi_readiness:mark_ready/0is not called - the node comes up and reports itself unready rather than dying.
Running more than one node
Everything above describes one node, which is the shape asobi is designed around: a match lives on one node, a world lives on one node, and neither migrates.
Clustering is the single source of truth for what is and is not cluster-safe, and holds the complete list of per-node state. Do not infer it from this page. Before you go further, four facts about a node that shape every clustering decision:
- The matchmaker queue and its tickets are in one gen_server's state, per node, never persisted.
- The console session store and its CSRF secret are per node and per boot.
- Rate-limit buckets and the auth cache are per node.
- Matches and worlds are pinned to the node that started them.
Postgres is shared, so everything persistent is consistent across nodes.
pg-scoped lookups - presence, chat, match and world whereis - resolve
across connected nodes.
Sticky routing, where you need it, is a load-balancer configuration you own.
asobi does not implement placement, does not assign nodes, and sets no cookie
for it; the only cookies in the codebase are the console's
(src/console/asobi_console_session.erl).