feat: backfill into running matches, a joinable flag, and script-driven bots - #424
Merged
Conversation
🟡 Code Coverage — 76.2%7064 of 9273 lines covered. |
…en bots A forum report asked for four things around joining matches that have already started. Two of them already existed and one of those was broken; the other two did not. Already there: `match.list` discovers live matches and a `running` match accepts joins, so backfill needed no new call. But the session only ever learned its match_pid from the matchmaker, so a player who found a match and joined it by id - the backfill path - was left unbound: their `match.input` came back `not_in_match` and their `match.leave` silently did nothing. asobi_match_server now sends `match_joined` on the join it accepted, mirroring asobi_world_server, and `match_left` on the way out so a stale match_pid cannot outlive the match. The matchmaker's own send goes away with it; it fired even when the join it announced had failed. New: - A runtime `joinable` flag. `game.match.set_joinable(false)` closes a match to new players without ending it; further joins are answered `match_locked`. It is reported in every listing and filterable in `match.list`, `GET /api/v1/matches/live` and asobi_match_lobby, because room and willingness are different questions - a match with three free slots may have closed, and a full one has not. - Lua `join` can refuse. `return nil, "reason"` turns a player away instead of the documented workaround of admitting them with a spectator flag. The refused player never reaches the roster and the Lua state is discarded, so a refusal leaves no trace. The reason is game vocabulary and travels in `details.refused_reason` under a fixed code - a script still cannot mint one. A `join` that returns nothing is now a refusal too; it used to be a case_clause that killed the match and everyone in it. - `game.bots.add/remove`, so a match script places bots itself rather than relying on matchmaker queue fill. Both go through asobi_bot_spawner because asobi_bot joins from its own init, and share the MAX_BOT_FILL ceiling. The periodic scan now skips bots that already have an AI, which it would otherwise double-start. Also: `match.full`, `match.locked` and `match.join_refused` are first-class codes rather than `ws.request_failed`; a non-atom reason reaching the socket is reported as `internal` instead of being caught as `invalid_payload` and blaming the client for a server-side fault; and `started_at` is stamped only on the waiting -> running transition, so a backfill join no longer restarts the clock the persisted match duration is measured from.
…n bounds Mutation testing on the changed lines surfaced three gaps: the new `joinable` query parameter on /matches/live had no test at all, asobi_presence:bot_pids/1 was only ever reached through a mock, and the refusal-reason bounds (64 bytes, printable ASCII) were asserted loosely enough that either edge could move a byte without failing. Also pins that Lua's other falsey value refuses too - `return false` is an ordinary way to write a refusal and only `nil` was covered.
Nothing supplied it. The matchmaker builds match config from mode config and never sets it, and neither asobi_game_modes nor asobi_lua_config knows the name, so no game mode could reach it. It was symmetry with `listed` and nothing else - and unlike `listed` it has no coherent static meaning: a match that started closed could never be joined, so it would sit in `waiting` until the timeout stopped it.
A world and a zone VM bind `match_pid` too - to the world server - so
matching on the key admitted both. asobi_world_server answers `get_info` and
`{join, _, _}` with the same shapes the match server uses, so
`game.bots.add` from a world script would have quietly seated a bot in a
world and started it sending match input at the world server. Worse,
`running/3` there has no catch-all, so the `set_joinable` cast would have
died on function_clause and taken the world down with every player in it -
the same shape as asobi#285/#290.
Both now gate on vm_kind/1 and answer a world or zone script with an error at
the call site naming the VM it is in. The guides already said "match only";
this makes it true.
Taure
force-pushed
the
feat/match-backfill
branch
from
August 10, 2026 17:45
0c38606 to
2dfeb0c
Compare
This was referenced Aug 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A forum post asked for four things around joining matches that have already
started. Two already existed and one of those was broken; two did not.
Already there, and broken
match.listdiscovers live matches and arunningmatch accepts joins, sobackfill needed no new call. But the session only ever learned its
match_pidfrom the matchmaker, so a player who found a match and joined it by id - the
backfill path - was left unbound: their
match.inputcame backnot_in_matchand theirmatch.leavesilently did nothing.asobi_match_servernow sendsmatch_joinedon the join it accepted,mirroring
asobi_world_server, andmatch_lefton the way out so a stalematch_pidcannot outlive the match. The matchmaker's own send goes away withit - it fired even when the join it announced had failed, because the fan-out
discards the join result.
ws_backfill_join_binds_the_sessiondrives the whole path over a real socket.Reverting the one-line fix makes it fail with exactly the
not_in_matchframea player would have hit.
New
A runtime
joinableflag.game.match.set_joinable(false)closes a matchto new players without ending it; further joins get
match_locked. Reportedin every listing and filterable in
match.list,GET /api/v1/matches/liveand
asobi_match_lobby.Deliberately separate from
has_capacity: a match with three free slots mayhave closed itself, and a full one has not - it may free a slot on the next
leave. A client looking for somewhere to play asks both.
There is no config key.
listedis static because "should this mode bebrowsable" is a property of the mode; "will this match take another player
right now" is a property of the moment, and a match that started closed could
never be joined at all.
Lua
joincan refuse.return nil, "reason"(andreturn false- Lua hastwo falsey values and
return cond and state or falseis ordinary). Replacesthe documented workaround of admitting the player with a spectator flag.
A refused player never reaches the roster and the Lua state is discarded, so a
refusal leaves no trace - a client cannot drive a script by being turned away
repeatedly. The reason is game vocabulary and rides in
details.refused_reasonunder the fixed codematch.join_refused; a scriptstill cannot mint a code. Bounded at 64 bytes of printable ASCII.
game.bots.add/remove. A match script placing bots itself instead ofrelying on matchmaker queue fill. Routed through
asobi_bot_spawnerbecauseasobi_botjoins from its owninit/1- starting one from inside the matchprocess would block that process in
supervisor:start_child/2while the newbot waited on it to answer the join. Shares the
MAX_BOT_FILLceiling.Independent of
bots.enabled, which governs queue fill only.Fixed along the way
jointhat returned nothing was acase_clausethat killed the matchserver and everyone in it. Now a logged refusal - a script bug should cost
the author their own join, not the whole match.
started_atwas restamped by every join pastmin_players, so eachbackfill joiner restarted the clock the persisted match duration is measured
from.
to_reason_binary/1clause;safe_handle_messagecaught thefunction_clauseand answeredinvalid_payload, blaming the client for a server-side fault. Nowinternal.have got an AI - and could have got a second one. It now skips bots that
already have a process.
game.match.*andgame.bots.*matched onmatch_pid, which a world and azone VM also bind - to the world server.
asobi_world_serveranswersget_infoand{join, _, _}with the same shapes, sobots.addfrom aworld script would have quietly seated a bot in a world; and its
running/3has no catch-all, so the
set_joinablecast would have died onfunction_clauseand taken the world down with every player in it. Both nowgate on
vm_kind/1.Wire and API surface
match.full,match.locked,match.join_refused.match_fullpreviously came through as
ws.request_failed.joinableonmatch.joinedand everymatch.listentry. Additive;both protocol fixtures updated.
joinableonmatch.listandGET /api/v1/matches/live.game.matchandgame.bots.{match_left, pid()}.The SDKs do not read
joinableor branch on the new codes yet - worth a/sync-sdkspass before this is announced.Verification
fmt, xref, dialyzer, ex_doc clean. 1730 eunit + 356 CT, zero failures.
Mutation testing on the changed lines:
asobi_match_server,asobi_ws_handler,asobi_bot_spawner,asobi_match_lobbyat 100%,asobi_lua_matchat 97% (the survivor is a log call with no observableeffect). Its first run found three genuine gaps - the REST filter untested,
bot_pidsonly reached through a mock, loose refusal bounds - which is thesecond commit.
eqwalizer is net zero against
main(312 both sides). That standing count isa pre-existing repo-wide condition across files this branch does not touch.
Docs
lua-scripting,lua-api,lua-bots,lobbies,matchmaking,rest-apiand
websocket-protocol, plus both protocol fixtures.lobbies.mdgains a"Joining a match already in progress" section and
matchmaking.mdstatesplainly that the matchmaker never routes a queued player into a running match