Skip to content

feat: backfill into running matches, a joinable flag, and script-driven bots - #424

Merged
Taure merged 4 commits into
mainfrom
feat/match-backfill
Aug 12, 2026
Merged

feat: backfill into running matches, a joinable flag, and script-driven bots#424
Taure merged 4 commits into
mainfrom
feat/match-backfill

Conversation

@Taure

@Taure Taure commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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.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, because the fan-out
discards the join result.

ws_backfill_join_binds_the_session drives the whole path over a real socket.
Reverting the one-line fix makes it fail with exactly the not_in_match frame
a player would have hit.

New

A runtime joinable flag. game.match.set_joinable(false) closes a match
to new players without ending it; further joins get match_locked. Reported
in every listing and filterable in match.list, GET /api/v1/matches/live
and asobi_match_lobby.

Deliberately separate from has_capacity: a match with three free slots may
have 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. listed is static because "should this mode be
browsable" 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 join can refuse. return nil, "reason" (and return false - Lua has
two falsey values and return cond and state or false is ordinary). Replaces
the 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_reason under the fixed code match.join_refused; a script
still cannot mint a code. Bounded at 64 bytes of printable ASCII.

game.bots.add/remove. A match script placing bots itself instead of
relying on matchmaker queue fill. Routed through asobi_bot_spawner because
asobi_bot joins from its own init/1 - starting one from inside the match
process would block that process in supervisor:start_child/2 while the new
bot waited on it to answer the join. Shares the MAX_BOT_FILL ceiling.
Independent of bots.enabled, which governs queue fill only.

Fixed along the way

  • A Lua join that returned nothing was a case_clause that killed the match
    server and everyone in it. Now a logged refusal - a script bug should cost
    the author their own join, not the whole match.
  • started_at was restamped by every join past min_players, so each
    backfill joiner restarted the clock the persisted match duration is measured
    from.
  • A non-atom reason at the socket hit no to_reason_binary/1 clause;
    safe_handle_message caught the function_clause and answered
    invalid_payload, blaming the client for a server-side fault. Now
    internal.
  • The bot scan only ran once per match id, so a script-placed bot would never
    have got an AI - and could have got a second one. It now skips bots that
    already have a process.
  • game.match.* and game.bots.* matched on match_pid, which a world and a
    zone VM also bind - to the world server. asobi_world_server answers
    get_info and {join, _, _} with the same shapes, so bots.add from a
    world script would have quietly seated a bot in a world; and its running/3
    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. Both now
    gate on vm_kind/1.

Wire and API surface

  • New error codes: match.full, match.locked, match.join_refused. match_full
    previously came through as ws.request_failed.
  • New field joinable on match.joined and every match.list entry. Additive;
    both protocol fixtures updated.
  • New filter joinable on match.list and GET /api/v1/matches/live.
  • New Lua namespaces game.match and game.bots.
  • New presence message {match_left, pid()}.

The SDKs do not read joinable or branch on the new codes yet - worth a
/sync-sdks pass 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_lobby at 100%,
asobi_lua_match at 97% (the survivor is a log call with no observable
effect). Its first run found three genuine gaps - the REST filter untested,
bot_pids only reached through a mock, loose refusal bounds - which is the
second commit.

eqwalizer is net zero against main (312 both sides). That standing count is
a pre-existing repo-wide condition across files this branch does not touch.

Docs

lua-scripting, lua-api, lua-bots, lobbies, matchmaking, rest-api
and websocket-protocol, plus both protocol fixtures. lobbies.md gains a
"Joining a match already in progress" section and matchmaking.md states
plainly that the matchmaker never routes a queued player into a running match

  • backfill is discovery, not a strategy.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

🟡 Code Coverage — 76.2%

7064 of 9273 lines covered.

Taure added 4 commits August 10, 2026 19:44
…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
Taure force-pushed the feat/match-backfill branch from 0c38606 to 2dfeb0c Compare August 10, 2026 17:45
@Taure
Taure merged commit 891b4d3 into main Aug 12, 2026
16 checks passed
@Taure
Taure deleted the feat/match-backfill branch August 12, 2026 04:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant