Skip to content

feat: send match.find_or_create, the race-free route into a live match - #48

Merged
Taure merged 3 commits into
mainfrom
feat/match-find-or-create
Aug 14, 2026
Merged

Taure merged 3 commits into
mainfrom
feat/match-find-or-create

Conversation

@Taure

@Taure Taure commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Adds MatchFindOrCreateAsync(string mode) to AsobiRealtime, the send side of
asobi's new match.find_or_create inbound frame (widgrensit/asobi#482).

var raw = await client.Realtime.MatchFindOrCreateAsync("arena");
// raw is a match.joined envelope, same as JoinMatchAsync's reply

What it does

The matchmaker groups co-queued tickets and spawns; it never joins a player into
a running match. MatchListAsync then JoinMatchAsync can only join a match
that already exists, and no client call spawns one, so an empty listing left the
caller with nothing but the queue. match.find_or_create finds a live match or
spawns one, resolved server-side and serialized, so simultaneous callers
converge on one match.

Earlier revisions of this PR sold it as closing a browse-then-join race, with
two clients reading the same empty listing each creating a match. That is the
race at the protocol level for a client that can create; it is not why a Unity
developer should reach for this call, because neither MatchListAsync nor
JoinMatchAsync creates anything.

Refusals

not_found, quick_play_disabled, wrong_mode_type, match_capacity_reached
and join_rate_limited. not_found is the one a caller hits first, since
asobi_match_lobby:eligible/2 returns it for a mode that is unknown or not
configured; the doc calls the list open rather than exhaustive.

A refusal faults the task with AsobiException(-1, raw), so StatusCode is
hardcoded and carries nothing, and payload.error.code is ws.request_failed
for all of these bar join_rate_limited. The reason is only at
payload.reason, so the doc names it and shows the read with the SDK's own
public scanner rather than naming reasons nothing can branch on.

Why nothing else changed

The reply is match.joined, the frame match.join already answers with, not a
new match.created leaf. The dispatch case and OnMatchJoined already exist and
are pinned by DispatcherTests (match.joined -> OnMatchJoined), so the only
gap was a way to send the frame.

Naming

MatchFindOrCreateAsync, not FindOrCreateMatchAsync: it sits beside
MatchListAsync, the most recent match-frame addition, and mirrors
WorldFindOrCreateAsync name-for-name. Placed between MatchListAsync and
JoinMatchAsync so the match block reads list / find_or_create / join, as the
world block does.

Docs

The XML doc on the method, which is where MatchListAsync and WorldInputAsync
carry theirs. No README section: the README documents WorldFindOrCreateAsync
only inside "Worlds (client-side prediction)", and a match frame does not belong
there.

The mode param doc no longer says mode is the only field the frame takes:
the handler also parses an optional join context out of the same payload. This
SDK exposes no join context on match.join, so none is exposed or documented
here either, which keeps the two frames symmetric.

Note for the reviewer: version floor

The doc comment states v0.86.0, not v0.85.0. match.find_or_create landed in
asobi commit 8f7d950 (#482), which is tagged v0.86.0; v0.85.0 is 97f4cbb
(#481) and does not carry the frame. Worth checking the other six SDK PRs say the
same.

Checks

  • dotnet test on Tests/AsobiCore.NET: 129 passed, 0 failed.
  • AsobiRealtime.cs compiled against a UnityEngine shim (it is not linked into
    the license-free test project); builds clean, and every new see cref resolves.
  • One new test, AWsRefusalReasonIsReadFromThePayloadNotFromTheErrorCode, which
    pins the refusal-reading recipe the doc hands the caller. The send itself is
    still untested: the reply routing is already covered, and the only Unity-free
    seam, WsFrame.Request, is covered by WsFrameTests.

Taure added 3 commits August 14, 2026 13:48
The match twin of world.find_or_create (widgrensit/asobi#482). The
matchmaker groups co-queued tickets and spawns; it never joins a player
into a running match, so the only route into a live one was match.list
then match.join, which races. This resolves server-side and is
serialized, so simultaneous callers converge on one match.

It replies with match.joined, the frame match.join already answers with,
so the SDK's existing reply routing covers it and no new event is needed.
The payload is mode only.

Named MatchFindOrCreateAsync to sit beside MatchListAsync and mirror
WorldFindOrCreateAsync.
`not_found` is the refusal a caller hits first, and the doc omitted it:
`asobi_match_lobby:eligible/2` returns it when `mode_config/1` hands back an
empty map, so a misspelt or unregistered mode lands there. The list now reads
"refusals include", since more can be added.

The old rationale was false for this SDK. Neither `MatchListAsync` nor
`JoinMatchAsync` creates a match, so two clients reading the same empty listing
do not race to create one, they both find nothing. What is true here: no client
call spawns a match at all, and the matchmaker only spawns once it has grouped
enough co-queued tickets, so before this frame an empty listing left the caller
with nothing but the queue.

The refusals were also unreadable. A WS error frame faults the task with
`AsobiException(-1, raw)`, so `StatusCode` carries nothing and the reason is
only inside the raw envelope, at `payload.reason`. The doc now says how to read
it with the SDK's own public scanner, and a test pins that recipe next to
`payload.error.code`, which is `ws.request_failed` for every refusal here bar
`join_rate_limited` and so cannot tell them apart.

`mode` is not the only field the frame takes either: the handler parses an
optional join context out of the same payload. This SDK exposes no join context
on `match.join`, so none is exposed here and none is documented, but the param
doc no longer claims `mode` is all the frame accepts.
Two corrections from verification, both checked against asobi source.

The doc claimed payload.error.code is ws.request_failed for every
refusal bar join_rate_limited. False: join_refused, match_full and
match_locked all carry their own mapped codes (asobi_error.erl:290-292).
The reason string is still the right thing to branch on, but for the
opposite reason to the one stated - not because the code is always
generic, but because it is the only field that separates every case.

And the read recipe threw away the one refusal string a game actually
authors. match.find_or_create can refuse in two stages: the lobby before
the join, and the join itself after a match is found. When a game's own
Lua join script refuses, reason is the fixed literal join_refused and the
script's wording is at payload.error.details.refused_reason - a script
cannot mint an error code, so its text travels as a detail. That is the
string to show a player, and the doc never mentioned the field existed.
@Taure
Taure merged commit e4217d7 into main Aug 14, 2026
3 checks passed
@Taure
Taure deleted the feat/match-find-or-create branch August 14, 2026 13:04
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