Async Match: turn-based multiplayer with no connection held open
Lobbies: the realtime alternative this series covers next; async match trades a live connection for a simpler request/response turn cycle.
brainCloud calls this pattern Turn-by-Turn Async, the same model popularized by Words with Friends and Draw Something. TicTacToe (repositories/unity-examples/TicTacToe) plays an entire match (challenge, alternating turns, win detection, rematch) through plain request/response calls. No lobby, no relay, no connection held open between turns. The one piece of realtime it uses is optional: an RTT callback that reports a turn happened, so the waiting player doesn't have to poll. With that connection open, the notification arrives the moment the opponent's turn lands, immediate enough to feel like a live socket even though no connection is ever held open for the match itself. Finding an opponent is a separate concern from playing one: TicTacToe layers real, rating-based MatchMaking and a real Lobby-based quick-match option on top of the async match loop, and in this codebase's own history, both were added after the core turn-by-turn loop was already working, in under ten minutes. Async Match doesn't need matchmaking to function; the two meet at exactly one narrow point, a chosen opponent's profile id, which is what makes bolting one onto the other that fast.
How it works
- Finding an opponent is a real matchmaking search, not a hardcoded name. EnableMatchMaking opts the local player into being discoverable once, at startup; FindPlayers(rangeDelta, numMatches) then returns a pool of similarly-rated opponents to pick from. A second path, a real Lobby, pairs two players automatically for a quick match instead. Either way, what comes out is a specific opponent profile id.
- CreateMatchWithInitialTurn creates the match against whichever opponent was just picked. It takes that profile id, the initial board state, a push-notification message, and whose turn it is. The match exists immediately once called; the waiting-for-opponent phase already happened in matchmaking or the lobby, not here.
- Every turn is a versioned write. SubmitTurn takes the match's current version number alongside the new state. That's what lets the server reject a stale or out-of-order submission instead of silently overwriting a newer state.
- The RTT callback signals a change; it doesn't hand you the turn data. Registering RegisterRTTAsyncMatchCallback only tells the client something changed. The handler then re-requests the full match state with ReadMatch instead of trusting a partial payload.
- In-progress and completed matches live in two different lists. FindMatches returns active matches; FindCompleteMatches is a separate call for finished ones. A match moves from one list to the other rather than picking up a status flag on a shared list.
- Match history isn't carried in the match object; you fetch it on demand. ReadMatchHistory is its own call, used once a match is detected as finished, to show the full turn-by-turn replay.
Code
App.Bc.RTTService.RegisterRTTAsyncMatchCallback(queryMatchStateRTT);
private void queryMatchStateRTT(string in_json)
{
queryMatchState(); // re-fetch, don't trust the RTT payload directly
}
private void queryMatchState()
{
App.Bc.AsyncMatchService.ReadMatch(App.OwnerId, App.MatchId, (response, cbObject) =>
{
var data = JsonReader.Deserialize<Dictionary<string, object>>(response)["data"] as Dictionary<string, object>;
int newVersion = int.Parse(data["version"].ToString());
if (App.MatchVersion + 1 < (ulong)newVersion)
{
App.MatchVersion = (ulong)newVersion;
App.BoardState = (string)(data["matchState"] as Dictionary<string, object>)["board"];
}
});
}App.Bc.AsyncMatchService.SubmitTurn(
App.OwnerId,
App.MatchId,
App.MatchVersion,
JsonWriter.Serialize(boardStateJson),
"A turn has been played", // push notification message
null, null, null,
OnTurnSubmitted, OnFailureCallback);// Enable Match Making, so other Users can also challenge this Profile
App.Bc.MatchMakingService.EnableMatchMaking(null, (status, code, error, cbObject) =>
{
Debug.Log("MatchMaking enabled failed");
});
// ...later, to search:
App.Bc.MatchMakingService.FindPlayers(RANGE_DELTA, NUMBER_OF_MATCHES, OnFindPlayers);public void OnPickOpponent(PlayerInfo matchedProfile, bool randomTurnFirst = true)
{
var yourTurnFirst = randomTurnFirst ? Random.Range(0, 100) < 50 : true;
// ...build summaryData and matchState...
App.Bc.AsyncMatchService.CreateMatchWithInitialTurn(
"[{\"platform\":\"BC\",\"id\":\"" + matchedProfile.ProfileId + "\"}]", // opponents
JsonWriter.Serialize(matchState), // initial state
"A friend has challenged you to a match of Tic Tac Toe.", // push message
yourTurnFirst ? App.ProfileId : matchedProfile.ProfileId, // whose turn
JsonWriter.Serialize(summaryData),
OnCreateMatchSuccess, OnCreateMatchFailed, null);
}App.Bc.AsyncMatchService.FindMatches(OnFindMatches);
// ...inside OnFindMatches, after populating the active list:
App.Bc.AsyncMatchService.FindCompleteMatches(OnFindCompletedMatches);Gotchas worth knowing up front
- Don't expect the RTT payload to carry match data. RegisterRTTAsyncMatchCallback's payload isn't parsed for match data in this reference app at all. It only triggers a fresh ReadMatch. A handler that tries to extract board state from the RTT event itself would find nothing there.
- The MatchVersion check guards against replaying stale state, not just detecting change. The comparison is App.MatchVersion + 1 < newVersion, stricter than a plain not-equal check.
- This match really is connectionless between turns. There's no relay socket and no lobby membership to maintain. The only standing registration is the one RTT callback, and even that's optional: a game could poll ReadMatch instead.
- Matchmaking and the match itself are genuinely decoupled, not just in theory. Both MatchMaking and the Lobby-based quick-match path were added to this codebase after the core async-match loop already worked, in under ten minutes. CreateMatchWithInitialTurn only ever needs one thing from either of them, an opponent's profile id, which is why bolting a discovery mechanism on afterward doesn't touch the match code at all.
- Watch for Cloud Code wrapping the response on match creation. OnCreateMatchSuccess explicitly checks for a response wrapper key, commented as "Cloud Code returns wrap the data in a responseJson." That indicates this reference app expects CreateMatchWithInitialTurn to sometimes be fronted by a script instead of always being a direct client call.