⌘K
↑↓ Navigate ↵ Open Esc Close
Tier 4: Asynchronous Multiplayer

Multiplayer with no connection held open

Turn-based matches and offline raid attacks: two ways to play against another person without a live socket between you.

Tier 4Async MatchRTT20 / 29

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

Push notification for "your turn" is registered once, re-fetches on signal (TicTacToe.cs:189, 210-215)
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"];
        }
    });
}
Submitting a turn: versioned, so a stale write can be rejectedTicTacToe.cs:303-312
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);
Finding an opponent: real, rating-based matchmakingMatchSelect.cs:37-42,98-101
// 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);
Creating a match against whichever opponent got pickedMatchSelect.cs:505-533
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);
}
Active and completed matches are separate listsMatchSelect.cs:121-144
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.