Docs / Clustering

Clustering

Run multiple Asobi nodes as one cluster: horizontal scale for connections and matches, plus automatic failover. Presence, chat, and cross-match messaging are cluster-safe out of the box via pg.

Asobi is single-node by design for gameplay. A match lives on one node; the world server's zones live on one node. Clustering is for connection termination, cross-node messaging, and failover — not for live cross-node zone migration. Shard at the app level (e.g. route players by region).

What's cluster-safe

  • pg-scoped process groups — presence, chat channels, world/match whereis lookups work cross-node.
  • Player sessions: a session on node A can send to a match on node B (proxied via pg lookup).
  • Storage (Postgres) is shared; everything persistent is consistent across nodes.
  • Matchmaker is replicated (one gen_server per node, tickets are in PG; any node can match).

What isn't

  • A match/world process does not migrate between nodes. If the owning node dies, active matches on it are lost (though state persists for post-mortem).
  • ETS caches (zone entity snapshots, rate limits) are per-node. Hot paths assume local access.
  • Luerl VMs are per-process and per-node — no shared script state across nodes.

Forming a cluster

Asobi uses the BEAM's distribution protocol. Give each node a long name, share a cookie, and let the asobi_cluster discovery loop (configured below) connect them. Out of the box the image only reads ASOBI_PORT, ASOBI_DB_*, and ASOBI_CORS_ORIGINS — set node name and cookie with the standard -name/-setcookie VM flags.

Or from a running shell:

net_adm:ping('asobi@10.0.0.1').
nodes().          %% ['asobi@10.0.0.1']

Service discovery

Asobi ships a tiny discovery loop (asobi_cluster) with two strategies — DNS (for Kubernetes headless services) and EPMD (for a static list of hosts). It resolves peer addresses, derives node names by reusing the current node's base name, and pings them periodically.

%% DNS (Kubernetes headless service):
{asobi, [
    {cluster, #{
        strategy      => dns,
        dns_name      => ~"asobi-headless",
        poll_interval => 10000
    }}
]}

%% EPMD (static host list):
{asobi, [
    {cluster, #{
        strategy      => epmd,
        hosts         => ['asobi-1.example.internal', 'asobi-2.example.internal'],
        poll_interval => 10000
    }}
]}

Routing players to nodes

Put a load balancer in front of the cluster with a sticky WebSocket cookie, or hash on player_id at the LB. This keeps a player's session on one node; cross-node calls happen only for matches/worlds the player joins on a different node.

Deployment

Rolling restarts are safe: drain a node (stop accepting new matches, wait for existing ones to finish), upgrade, rejoin. Sessions on the drained node reconnect to another node when the LB routes them.

Observability

Cluster-wide metrics surface via telemetry events under [asobi, match, *], [asobi, zone, *], and [asobi, matchmaker, *]. Wire them into Prometheus via telemetry_metrics_prometheus or ship them to any OpenTelemetry collector.

Where next?