The Asobi tanuki, cloaked and holding a controller
Your matches are in these paws.
Preview — v0.1

§ 01  Open-source game backend on the BEAM

Your game
never goes down.

A multiplayer game backend built on Erlang/OTP. Fault-tolerant by design. Zero-downtime deploys. 100K+ concurrent connections per node.

Asobi is early but fully open-source and ready to play with. Spin it up, prototype your next game, and help shape the future of game backends on the BEAM.

02 / Demo

Edit. Save. Live.

Edit your match logic in Lua. The running match picks up the change on the next tick. No restart, no reconnect, no kicked players.

Run it locally:

git clone https://github.com/widgrensit/asobi
cd asobi/examples/hotreload-demo
docker compose up

About 90 seconds. Open http://localhost:3000, drive a cube with WASD, then edit lua/match.lua and save.

03 / Client

Same server, any engine

Connect and receive match state in 15 lines — pick your engine.

UAsobiClient* Client = NewObject();
Client->SetBaseUrl(TEXT("http://localhost:8080"));

UAsobiAuth* Auth = NewObject();
Auth->Init(Client);

FOnAsobiAuthResponse OnLogin;
OnLogin.BindDynamic(this, &AMyPawn::OnLoggedIn);
Auth->Login(TEXT("player1"), TEXT("secret"), OnLogin);

// After login:
WebSocket->Connect(TEXT("ws://localhost:8080/ws"));
WebSocket->Authenticate(Client->GetAuthToken());
Matchmaker->Add(TEXT("arena"), {}, OnQueued);
WebSocket->OnMatchState.AddDynamic(this, &AMyPawn::OnMatchState);
var asobi = new AsobiClient("localhost", port: 8080);
await asobi.Auth.LoginAsync("player1", "secret");

asobi.Realtime.OnMatchState += state =>
    Debug.Log($"tick {state.tick}");

await asobi.Realtime.ConnectAsync();
await asobi.Matchmaker.AddAsync("arena");
// When matched, server sends match.joined → match.state at tick rate.
@onready var asobi: AsobiClient = $AsobiClient

func _ready():
    await asobi.auth.login("player1", "secret")
    asobi.realtime.match_state.connect(_on_state)
    asobi.realtime.connect_to_server()
    asobi.realtime.add_to_matchmaker("arena")

func _on_state(payload: Dictionary):
    print("tick ", payload.get("tick"))
local asobi = require("asobi.client")
local rt = require("asobi.realtime")

asobi.auth.login("player1", "secret", function(res)
    rt.init(asobi)
    rt.on("match_state", function(payload)
        print("tick " .. payload.tick)
    end)
    rt.connect(function()
        rt.matchmaker_add("arena")
    end)
end)
import { Asobi } from "@asobi/client";

const asobi = new Asobi({ baseUrl: "http://localhost:8080" });
const { access_token } = await asobi.auth.login({
    username: "player1", password: "secret",
});
asobi.client.setToken(access_token);

const ws = asobi.websocket();
ws.on("match.state", (s) => console.log("tick", s.tick));
await ws.connect();
await asobi.matchmaker.add({ mode: "arena" });
final asobi = AsobiClient(host: 'localhost', port: 8080);
await asobi.auth.login('player1', 'secret');

asobi.realtime.onMatchState.listen((state) {
    print('tick ${state.tick}');
});

await asobi.realtime.connect();
await asobi.matchmaker.add(mode: 'arena');
-- Server-side game mode (runs inside asobi_lua)
function on_player_joined(match, player)
    game.broadcast(match, "welcome", { player_id = player.id })
end

function on_match_input(match, player, input)
    player.x = player.x + (input.move_x or 0)
    player.y = player.y + (input.move_y or 0)
    -- match.state is diffed and broadcast automatically each tick.
end

04 / Models

Sessions or worlds — pick the right shape

Your game decides, not the backend. Both models share the same auth, social, economy, and storage.

Sessions

Matches

Small, ephemeral game sessions assembled by the matchmaker. Players queue, the server forms a match, a game mode runs, results are recorded. Like FTL, Brotato-with-friends, arena shooters, card games.

  • Matchmaker with skill + party rules
  • Server-authoritative tick (30–60 Hz)
  • Auto-cleanup when the match ends
  • In-match voting, DMs, chat
Matchmaking docs →
Persistent

Worlds

Long-running worlds with lazy-loaded zones and streamed terrain. Find-or-create joins a world that isn’t full; players drop in and out. Like EVE, Albion, MMOs, shared sandboxes.

  • Lazy zones scale on demand
  • Binary terrain chunks on join
  • ETS-backed reconnection state
  • 500+ concurrent players per world
World server docs →

05 / Runtime

Built on the BEAM

The same virtual machine that powers WhatsApp, Discord, and RabbitMQ. Designed for millions of concurrent connections with predictable latency.

■ 58 processes up · ■ 2 supervised restart · zero impact to neighbours

Per-Process GC

Each match runs in its own process with isolated garbage collection. No stop-the-world pauses affecting other players.

Preemptive Scheduling

The BEAM scheduler ensures fair CPU time for every match. One expensive operation cannot starve others.

🛡

OTP Supervision

If a match process crashes, it restarts automatically. Players reconnect to a fresh state. The server is unaffected.

Cloud Native

Graceful shutdown, health endpoints, and rolling deploys out of the box. Built for Kubernetes, Fly.io, and any container orchestrator.

📈

100K+ Connections

Lightweight processes and efficient I/O multiplexing. Handle half a million concurrent WebSocket connections per node.

📊

Built-in Observability

OpenTelemetry integration, structured logging, and Telemetry events. Monitor every match, queue, and connection.

06 / Kit

Everything you need

A complete backend for multiplayer games. One release, no external dependencies.

Authentication

Player registration, login, sessions, and OAuth. Built on nova_auth.

Matchmaking

Automatic player pairing with configurable rules. Lobby and queue support.

Real-Time Sync

WebSocket-based state synchronization at configurable tick rates.

Leaderboards

Ranked scoring with ETS-backed storage. Per-game, per-season, global.

Virtual Economy

Wallets, transactions, inventory, and in-game store.

Social

Friends, groups, chat, and notifications.

Cloud Saves

Persistent player data storage with versioning.

Admin Dashboard

Web-based management UI for players, matches, and economy.

08 / Contract

Define your game logic

Implement the asobi_match behaviour. Asobi handles the rest.

-module(arena_match).
-behaviour(asobi_match).

-export([init/1, handle_join/3, handle_input/3,
         handle_tick/2, handle_leave/3]).

init(Opts) ->
    #{max_players => maps:get(max_players, Opts, 8),
      tick_rate   => 10,
      players     => #{},
      projectiles => []}.

handle_join(PlayerId, _Metadata, State) ->
    Spawn = random_spawn_point(),
    Player = #{pos => Spawn, hp => 100, score => 0},
    {ok, State#{players := maps:put(PlayerId, Player,
                           maps:get(players, State))}}.

handle_input(PlayerId, #{<<"action">> := <<"fire">>} = Input, State) ->
    Projectile = spawn_projectile(PlayerId, Input),
    {ok, State#{projectiles := [Projectile |
                    maps:get(projectiles, State)]}}.

handle_tick(_DeltaMs, State) ->
    S1 = move_projectiles(State),
    S2 = detect_collisions(S1),
    {broadcast, S2}.

09 / Position

How Asobi compares

Honest accounting against the engines you’d otherwise pick.

AsobiNakamaColyseus
RuntimeBEAM (Erlang/OTP)GoNode.js
Garbage CollectionPer-process, isolatedStop-the-worldStop-the-world
Fault ToleranceOTP supervision treesManual recoveryManual recovery
Cloud/K8sGraceful shutdown, health checksBasic supportManual setup
Pub/SubBuilt-in (pg module)Requires RedisBuilt-in
Connections/Node100K+~50K~10K
ObservabilityOpenTelemetry + TelemetryPrometheusCustom metrics
LicenseApache 2.0Apache 2.0MIT

10 / Start

Get started in minutes

Four commands between you and a running multiplayer game.

1

Create a new project

rebar3 nova new my_game fullstack

2

Add asobi as a dependency

{asobi, "~> 0.1"}

3

Implement your match logic

Define a module with the asobi_match behaviour

4

Run it

rebar3 nova serve

11 / People

Join the community

Ask questions, share what you're building, and help shape Asobi.

Join us on Discord