Docs / Erlang / API

Erlang API reference

Asobi is a plain Erlang/OTP library. You can use it directly from Erlang (or any BEAM language) without touching Lua — Lua is just a convenience layer that dispatches to these same modules.

When to use Erlang over Lua: you want behaviour-level control (supervision trees, custom match state machines, direct gen_statem handling), or you're embedding Asobi in an existing Erlang application.

asobi_match — the game behaviour

Every game mode is an Erlang module that implements the asobi_match behaviour. This is the canonical contract — the Lua adapter implements it on your behalf.

-module(my_card_game).
-behaviour(asobi_match).

-export([init/1, join/2, leave/2, handle_input/3, tick/1, get_state/2]).

init(Config) ->
    {ok, #{deck => shuffle(Config), players => #{}, pot => 0}}.

join(PlayerId, #{players := Players} = State) ->
    {ok, State#{players := Players#{PlayerId => starting_hand()}}}.

leave(PlayerId, #{players := Players} = State) ->
    {ok, State#{players := maps:remove(PlayerId, Players)}}.

handle_input(PlayerId, #{action := bet, amount := N}, State) ->
    {ok, place_bet(PlayerId, N, State)}.

tick(State) ->
    case round_complete(State) of
        true  -> {finished, summary(State), State};
        false -> {ok, State}
    end.

get_state(PlayerId, State) ->
    redact_for(PlayerId, State).

init/1

-callback init(Config :: map()) -> {ok, GameState :: term()}.

Called once when the match starts. Receives the config passed to `asobi_match_server:start_link/1`. Returns the initial game state.

join/2, leave/2

-callback join(PlayerId :: binary(), GameState :: term()) ->
    {ok, GameState1 :: term()} | {error, Reason :: term()}.

-callback leave(PlayerId :: binary(), GameState :: term()) ->
    {ok, GameState1 :: term()}.

Player joined or left. `join` may reject with `{error, Reason}` (e.g. match full, banned); `leave` is best-effort and cannot fail.

handle_input/3

-callback handle_input(PlayerId :: binary(), Input :: map(), GameState :: term()) ->
    {ok, GameState1 :: term()} | {error, Reason :: term()}.

Player action (click, move, ability). Inputs arrive asynchronously — the match server serialises them onto the match process, so you never race on state.

tick/1

-callback tick(GameState :: term()) ->
    {ok, GameState1 :: term()} | {finished, Result :: map(), GameState1 :: term()}.

Called on a fixed interval (default 10 Hz). Return `{finished, Result, State}` to end the match — Asobi persists the result, broadcasts to clients, and tears down the process.

get_state/2

-callback get_state(PlayerId :: binary(), GameState :: term()) ->
    StateForPlayer :: map().

Project the full match state into what ONE player sees. Use this to hide opponent hands, enemy positions outside of sight, etc.

Optional: phases, voting

-callback phases(Config :: map()) -> [asobi_phase:phase_def()].
-callback on_phase_started(PhaseName :: binary(), GameState :: term()) ->
    {ok, GameState1 :: term()}.
-callback on_phase_ended(PhaseName :: binary(), GameState :: term()) ->
    {ok, GameState1 :: term()}.
-callback vote_requested(GameState :: term()) ->
    {ok, VoteConfig :: map()} | none.
-callback vote_resolved(Template :: binary(), Result :: map(), GameState :: term()) ->
    {ok, GameState1 :: term()}.

All optional. Implement `phases/1` to drive a phase state machine; implement the vote callbacks to react to in-match voting.

asobi_match_server — match lifecycle

Your behaviour module is hosted by asobi_match_server, a gen_statem that drives the tick loop and routes player input. You rarely start one directly — the matchmaker does that — but you can for tests or custom lobbies.

asobi_match_server:start_link(Config)

Start a new match. Config must include `callback_module` and may include `tick_rate_ms`, `min_players`, `max_players`, `mode`.

{ok, Pid} = asobi_match_server:start_link(#{
    callback_module => my_card_game,
    tick_rate_ms => 100,
    min_players => 2,
    max_players => 4,
    mode => ~"ranked"
}).

asobi_match_server:join/2, :leave/2, :handle_input/3

Attach a player, detach, forward input. These are the hot-path calls — input is a cast (fire-and-forget), join is a call (awaits accept/reject).

ok = asobi_match_server:join(Pid, <<"p_alice">>),
asobi_match_server:handle_input(Pid, <<"p_alice">>, #{action => bet, amount => 50}),
asobi_match_server:leave(Pid, <<"p_alice">>).

asobi_match_server:pause/1, :resume/1, :cancel/1

Administrative control. `pause` stops the tick and queues input; `resume` continues; `cancel` ends the match without a winner.

asobi_match_server:whereis(MatchId)

Locate the Pid for a given match ID. Uses the `pg` scope so this works across cluster nodes.

case asobi_match_server:whereis(MatchId) of
    {ok, Pid} -> asobi_match_server:handle_input(Pid, PlayerId, Input);
    {error, not_found} -> {error, match_gone}
end.

asobi_match_server:start_vote/2, :cast_vote/4, :use_veto/3

In-match voting. `start_vote` opens a ballot; players cast with `cast_vote`; holders of a veto token can cancel with `use_veto`.

asobi_matchmaker — queues

Pluggable matchmaking. Add a ticket, the matchmaker groups compatible players via a strategy module, and spawns a match when a valid group forms.

asobi_matchmaker:add(PlayerId, Params)

Add a player to the queue. Params include the mode, skill, region, etc.; the strategy module decides how to use them.

{ok, TicketId} = asobi_matchmaker:add(<<"p_alice">>, #{
    mode => ~"ranked",
    skill => 1250,
    region => eu_west
}).

asobi_matchmaker:remove(PlayerId, TicketId)

Cancel a ticket. Safe to call even if already matched — it's a cast.

asobi_matchmaker:get_ticket(TicketId)

Poll a ticket's status. Returns the full ticket map including `status` (`waiting` / `matched` / `expired`).

case asobi_matchmaker:get_ticket(TicketId) of
    {ok, #{status := matched, match_id := Id}} -> join_match(Id);
    {ok, #{status := waiting}}                 -> wait();
    {error, not_found}                         -> expired
end.

asobi_world_server — persistent worlds

For games with a shared, persistent space (MMO, open-world, sandbox). A world process orchestrates zones (spatial partitions) and routes player I/O.

asobi_world_server:join(Pid, PlayerId)

Attach a player to the world. The world picks their initial zone based on spawn rules or a saved position.

asobi_world_server:move_player(Pid, PlayerId, {X, Y})

Move a player. The world recomputes zone membership and updates subscriptions.

asobi_world_server:move_player(WorldPid, PlayerId, {1024, 768}).

asobi_world_server:spawn_at/3, :spawn_at/4

Spawn an entity from a template at a position. The optional 4th arg overrides template fields.

{ok, EntityId} = asobi_world_server:spawn_at(WorldPid,
    <<"goblin_warrior">>,
    {512, 512},
    #{hp => 150}).

asobi_zone — spatial partitions

A zone is one Erlang process managing a rectangular region of the world. Zones are lazy-spawned, reaped when empty, and crash-isolated.

asobi_zone:subscribe(ZonePid, PlayerId)

Subscribe a player to a zone's event stream. From now on they receive entity updates and chat from this zone.

asobi_zone:query_radius(ZonePid, {X, Y}, Radius)

Return all entities within `Radius` of a point. Uses the zone's internal spatial grid — O(k) where k is matches, not total entities.

{ok, Entities} = asobi_zone:query_radius(ZonePid, {100, 200}, 50).

asobi_zone:spawn_entity/3, :spawn_entity/4

Spawn into a specific zone. Usually called via `asobi_world_server:spawn_at/3` which routes to the correct zone for you.

asobi_spatial — in-memory spatial queries

Stateless helpers for querying a list of entities by position. Use these inside match state when you don't need the zone infrastructure.

asobi_spatial:query_radius(Entities, {X, Y}, Radius)

Entities is a list of maps with `x` and `y` keys. Returns the subset within radius.

Nearby = asobi_spatial:query_radius(Entities, {0, 0}, 100),
lists:foreach(fun(E) -> notify(E) end, Nearby).

asobi_spatial:nearest(Entities, {X, Y}, N)

Return the N nearest entities sorted by distance.

asobi_spatial:in_range(A, B, Range) / :distance(A, B)

Point-to-point helpers. `distance` is Euclidean; `in_range` avoids a sqrt.

Under the hood

The Lua layer (asobi_lua) is a thin adapter: each game.* function marshals arguments into BEAM terms and calls the Erlang APIs above. Everything you can do from Lua you can do from Erlang — usually with less marshalling.

Where next?