How to gather players before a game starts.

asobi has no Lobby object. A lobby is a state, not a type, and asobi already has two things that hold players before a game begins. This guide is about picking one and wiring it up.

Which one

Waiting matchPersistent world
Use forgather N players, play, donea hub people return to between games
Who can create onean Erlang caller in the release, or the matchmakerany client, over world.create or POST /api/v1/worlds
Processes16 (instance sup, zone sup, zone manager, one zone, ticker, world server)
Ticks while idlenoneyes, at tick_rate
Presenceyou broadcast itfree, from the tick loop
Lifetimestarts at min_players, gives up after 60ssurvives empty if persistent

A waiting match is the cheaper shape, but the row above that decides it is "who can create one". Read the next section before choosing it.

Waiting match

A match starts in the waiting state and transitions to running when min_players is reached. That waiting period is the lobby.

No client-facing call brings a waiting match into existence. asobi_match_sup:start_match/1 is the only thing that creates a match, and its only caller in the release is the matchmaker - which spawns a match with min_players already equal to the group it just formed, so the waiting state lasts as long as the join fan-out and no longer. There is no match.create frame and no POST /api/v1/matches.

So the waiting-match lobby is an Erlang-only route: it needs a module in your release that calls asobi_match_sup:start_match/1 with a min_players higher than the number of players it seeds, and listed => true so clients can find it.

{ok, Pid} = asobi_match_sup:start_match(#{
    mode         => ~"arena",
    game_module  => my_arena,
    game_config  => #{},
    min_players  => 4,
    max_players  => 4,
    listed       => true
}).

If you are writing Lua, use a world instead. A world is the only session a client can create, so it is the only lobby a Lua-only game can build. Skip to Persistent world as a hub.

Letting players find it

GET /api/v1/matches/live        REST
match.list                      WebSocket

Both filter on mode and has_capacity. Matches are unlisted by default - a matchmaker-spawned match is already assigned to its players and has no reason to be browsable - so a mode opts in with listed = true.

Do not use GET /api/v1/matches for this. It reads the match record table: finished matches, an audit trail, nothing joinable. See REST API.

The 60-second timeout

A match that does not reach min_players within 60 seconds stops itself. That value is fixed (?WAITING_TIMEOUT in asobi_match_server) and is not exposed per mode. Fine for quick play; too short if you want players assembling at their own pace.

Persistent world as a hub

For a town square people return to between games, use a world. This is the path a client can drive on its own.

-- hub.lua
game_type   = "world"
persistent  = true    -- stays alive when empty; without this it dies on the last leave
grid_size   = 1       -- one zone: no spatial partitioning needed to stand around
tick_rate   = 200     -- 5 Hz is plenty; the 50ms default is for action games
match_size  = 1

listed and quick_play are Lua globals, both defaulting to true for a world - which is what a hub wants: it is browsable and world.find_or_create drops everyone into the same one. Set either to false in the script to change it. An operator game_modes entry still wins, and it replaces the script's mode config rather than merging into it - so if you add one, declare module => {lua, "hub.lua"} and the rest of the shape in it too.

persistent is the flag that makes it a hub rather than a session. Without it a world finishes the moment the last player leaves, so the next player gets a fresh empty one.

Presence is free here: worlds tick and broadcast zone state, so players see each other without you broadcasting anything. world:<WorldId> chat works and is gated on world membership.

Nothing creates the hub at boot. The first world.find_or_create instantiates it and it stays up from then on; after a restart the first player recreates it.

Worlds are subject to world_max_per_player (5) and world_max (1000) - see World capacity.

Private lobbies

Because only a world can be created by a client, a code-gated private lobby is a world too. Share a code out of band and check it on the way in. The join context is whatever the client put in the join payload; asobi never reads it.

function join(player_id, state, ctx)
	if ctx.code ~= state.room_code then
		return state                    -- refuse: player is not added
	end
	state.players[player_id] = true
	game.broadcast("lobby_update", { players = state.players })
	return state
end

Hide it from the browser with listed = false in the script. That is discovery only - it never gates joining, so the join callback above is still the whole gate. listed and quick_play are properties of the mode, not of one instance, so every world of that mode is equally hidden. See Join context.

Telling the room someone arrived

Core does not push a join notification to the players already waiting. That is deliberate: what a lobby shows differs per game - a bare count, a full roster, nothing until it fills.

game.broadcast from your join callback is the whole of it, as above. It reaches every player currently in the session, and the example above arrives client-side as {"type": "world.lobby_update", "payload": {"players": ...}} - match.lobby_update from a match script. Naming rules and the SDK-side handler are in Custom events.

Chat in a lobby

There is no match: channel scheme. world:<WorldId>, zone:<WorldId>:<X>,<Y> and prox:<WorldId>:<X>,<Y> exist and are gated on world membership; matches have no equivalent, so a match lobby uses game.broadcast with your own message shape.

The room: scheme is not open-join - room:<GroupId> resolves to a membership check against that group.

Seeing what players see

The console's Matches screen is the finished-match record, not the live list: core writes one row when a match ends, so a waiting lobby never appears there. To see what a player browsing sees, call GET /api/v1/matches/live. There is no worlds screen either; use GET /api/v1/worlds. See Operator console.

Not included

  • Ready-up. No first-class ready state. Track it in your own game state and broadcast it; the join context and game.broadcast are enough. A game that wants a shared one can ship it as an extension method and call it over the rpc.call frame - see Extensions.
  • Party. You cannot queue as a group through the matchmaker. Play with specific people by sharing a world id or a join code, or add party grouping as an extension.
  • Rich filters. Discovery filters on mode and has_capacity only. Anything richer belongs in your strategy module, or in an extension method that returns the filtered list.
  • Member roster API. The joiner receives the roster on match.joined / world.joined; there is no separate "who is here" call. Keep the list in your game state, or expose it as an extension method.