From a737e688a2c4ea927d98144065ce6cebd1544fee Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 14 Aug 2026 13:48:22 +0200 Subject: [PATCH 1/3] feat: send match.find_or_create, the race-free route into a live match 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. --- Runtime/WebSocket/AsobiRealtime.cs | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Runtime/WebSocket/AsobiRealtime.cs b/Runtime/WebSocket/AsobiRealtime.cs index ceb1286..f21d66e 100644 --- a/Runtime/WebSocket/AsobiRealtime.cs +++ b/Runtime/WebSocket/AsobiRealtime.cs @@ -145,6 +145,43 @@ public Task MatchListAsync(string mode = null, bool? hasCapacity = null, return SendAsync("match.list", payload); } + /// + /// Get into a live match of a mode, spawning one if there is none. + /// The match twin of . + /// + /// + /// The match mode. The only field the frame takes - every other match + /// parameter comes from server-side mode config. + /// + /// + /// The raw match.joined envelope, the same frame + /// replies with, so the reply routes + /// exactly as that one does. + /// + /// + /// Prefer this to followed by + /// : browse-then-join races, and two clients + /// reading the same empty listing each create a match. This resolves + /// server-side and is serialized, so simultaneous callers converge on + /// one match. + /// + /// A mode opts into it with quick_play, which defaults to false + /// for match modes; a mode that has not opted in is refused with + /// quick_play_disabled. That is a separate axis from + /// listed, which only decides browser visibility. Other refusals + /// a caller can see: match_capacity_reached (node-wide cap), + /// wrong_mode_type (a world mode), and join_rate_limited + /// (the same bucket as and + /// ). + /// + /// Requires asobi core v0.86.0 or later. + /// + public Task MatchFindOrCreateAsync(string mode) + { + var payload = JsonUtility.ToJson(new WsMatchmakerPayload { mode = mode }); + return SendAsync("match.find_or_create", payload); + } + public Task JoinMatchAsync(string matchId) { var payload = JsonUtility.ToJson(new WsMatchJoinPayload { match_id = matchId }); From 05e9512c7b4774332130be476b9fa887a3d82641 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 14 Aug 2026 14:51:22 +0200 Subject: [PATCH 2/3] docs: correct the match.find_or_create refusals and rationale `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. --- Runtime/WebSocket/AsobiRealtime.cs | 48 ++++++++++++++++++------- Tests/AsobiCore.NET/ErrorParserTests.cs | 20 +++++++++++ 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/Runtime/WebSocket/AsobiRealtime.cs b/Runtime/WebSocket/AsobiRealtime.cs index f21d66e..6c84bc5 100644 --- a/Runtime/WebSocket/AsobiRealtime.cs +++ b/Runtime/WebSocket/AsobiRealtime.cs @@ -150,8 +150,8 @@ public Task MatchListAsync(string mode = null, bool? hasCapacity = null, /// The match twin of . /// /// - /// The match mode. The only field the frame takes - every other match - /// parameter comes from server-side mode config. + /// The match mode. Match parameters are not client-chosen: size, min + /// players and the rest come from the server-side mode config. /// /// /// The raw match.joined envelope, the same frame @@ -159,20 +159,44 @@ public Task MatchListAsync(string mode = null, bool? hasCapacity = null, /// exactly as that one does. /// /// - /// Prefer this to followed by - /// : browse-then-join races, and two clients - /// reading the same empty listing each create a match. This resolves - /// server-side and is serialized, so simultaneous callers converge on - /// one match. + /// Reach for this when you want a player in a match now. + /// followed by + /// can only join a match that already + /// exists, and no client call spawns one: the matchmaker spawns only + /// once it has grouped enough co-queued tickets, and it never drops a + /// player into a running match. So before this frame an empty listing + /// left the caller with nothing but the queue. This finds a live match + /// or spawns one, resolved server-side and serialized, so simultaneous + /// callers converge on one match. /// /// A mode opts into it with quick_play, which defaults to false /// for match modes; a mode that has not opted in is refused with /// quick_play_disabled. That is a separate axis from - /// listed, which only decides browser visibility. Other refusals - /// a caller can see: match_capacity_reached (node-wide cap), - /// wrong_mode_type (a world mode), and join_rate_limited - /// (the same bucket as and - /// ). + /// listed, which only decides browser visibility. + /// + /// Refusals include not_found (the mode is unknown or not + /// configured, so a misspelt name lands here), quick_play_disabled, + /// wrong_mode_type (a world mode), match_capacity_reached + /// (node-wide cap) and join_rate_limited (the same bucket as + /// and ). More + /// can be added, so treat an unrecognised reason as a refusal as well. + /// + /// A refusal faults the task with , whose + /// StatusCode is always -1 for a WebSocket error frame and so + /// carries nothing. The reason is payload.reason in the raw + /// envelope, which is what Message holds. Read it with the SDK's + /// own scanner: + /// + /// catch (AsobiException ex) + /// { + /// var payload = JsonScan.ExtractField(ex.Message, "payload"); + /// var reason = JsonScan.Unquote(JsonScan.ExtractField(payload, "reason")); + /// if (reason == "quick_play_disabled") ShowModeClosed(); + /// } + /// + /// Branch on that, not on payload.error.code: the code is the + /// mapped one, ws.request_failed for every refusal here bar + /// join_rate_limited, so it cannot tell them apart. /// /// Requires asobi core v0.86.0 or later. /// diff --git a/Tests/AsobiCore.NET/ErrorParserTests.cs b/Tests/AsobiCore.NET/ErrorParserTests.cs index df659fb..d67c940 100644 --- a/Tests/AsobiCore.NET/ErrorParserTests.cs +++ b/Tests/AsobiCore.NET/ErrorParserTests.cs @@ -57,5 +57,25 @@ public void UnescapesAQuotedMessage() Assert.That(err.error, Is.EqualTo("He said \"no\".")); } + + // A WebSocket refusal faults the task with AsobiException carrying the + // raw envelope, so the reason is only readable off payload.reason. The + // nested error object maps most refusals onto one code, which is why + // MatchFindOrCreateAsync's doc points callers at the reason instead. + [Test] + public void AWsRefusalReasonIsReadFromThePayloadNotFromTheErrorCode() + { + const string raw = + "{\"type\":\"error\",\"payload\":{\"reason\":\"quick_play_disabled\"," + + "\"error\":{\"code\":\"ws.request_failed\"," + + "\"message\":\"The request failed. See `details.reason`.\"," + + "\"details\":{\"reason\":\"quick_play_disabled\"}}}}"; + + var payload = JsonScan.ExtractField(raw, "payload"); + var reason = JsonScan.Unquote(JsonScan.ExtractField(payload, "reason")); + + Assert.That(reason, Is.EqualTo("quick_play_disabled")); + Assert.That(AsobiErrorParser.Parse(raw).code, Is.EqualTo("ws.request_failed")); + } } } From 2f6faea251ed2e4657ef840b4cefb60b4caa1fc3 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 14 Aug 2026 15:01:28 +0200 Subject: [PATCH 3/3] docs: correct the refusal codes and surface refused_reason 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. --- Runtime/WebSocket/AsobiRealtime.cs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/Runtime/WebSocket/AsobiRealtime.cs b/Runtime/WebSocket/AsobiRealtime.cs index 6c84bc5..5c57aae 100644 --- a/Runtime/WebSocket/AsobiRealtime.cs +++ b/Runtime/WebSocket/AsobiRealtime.cs @@ -194,9 +194,30 @@ public Task MatchListAsync(string mode = null, bool? hasCapacity = null, /// if (reason == "quick_play_disabled") ShowModeClosed(); /// } /// - /// Branch on that, not on payload.error.code: the code is the - /// mapped one, ws.request_failed for every refusal here bar - /// join_rate_limited, so it cannot tell them apart. + /// Branch on payload.reason rather than payload.error.code: + /// several reasons here share the generic ws.request_failed code, + /// though not all do - join_refused, match_full and + /// match_locked carry their own mapped codes. The reason string + /// is the one field that tells every refusal apart. + /// + /// One refusal has a second level worth reading. The lobby can refuse + /// before the join (not_found, quick_play_disabled, + /// wrong_mode_type, match_capacity_reached), and the join + /// itself can refuse after a match is found (join_refused, + /// match_full, match_locked). When a game's own + /// join script refuses, reason is the fixed literal + /// join_refused and the script's own wording is one level + /// deeper, at payload.error.details.refused_reason - a script + /// cannot mint an error code, so its text travels as a detail. That + /// string is the one to show a player: + /// + /// if (reason == "join_refused") + /// { + /// var error = JsonScan.ExtractField(payload, "error"); + /// var details = JsonScan.ExtractField(error, "details"); + /// ShowRefused(JsonScan.Unquote(JsonScan.ExtractField(details, "refused_reason"))); + /// } + /// /// /// Requires asobi core v0.86.0 or later. ///