Skip to content

Asynchronous Multiplayer

This tutorial shows how to implement an asynchronous multiplayer tic tac toe game in ChilliConnect and Unity. This tutorial is based on the Unity Tic Tac Toe UI Tutorial, extending the single player game to a multiplayer game.

The full Unity project for this tutorial is available on the ChilliConnect GitHub samples repository under the folder "TicTacToe". To run the project under your own ChilliConnect game you should change the GAME_TOKEN constant in SceneController.cs.

 Download Sample Code

Defining the Match Type

The first step in integrating ChilliConnect Async Multiplayer in your game is to define your Match Type. A Match Type in ChilliConnect is used to define what type of turn order your game supports, what parameters are available for match making and default time out settings. You can also associate custom meta data with each match type as well that can be retrieved from your game at runtime.

From your dashboard, select the "Connect" menu option, then "Async Multiplayer". Select the "Add Type" button to define a new Match Type. As this demo will only contain a single match type, we can simply call it "Tic Tac Toe". Define Turn Type as "Sequential" (players must take their turns in a sequential order, one after the other), and set both Default Timeouts to 10 minutes:

On the "Properties" tab, we can define what properties the match type has available for match making. When a match is created, the developer can define a value for each property that can then be used to search for matches with a certain criteria when match making. You can define up to 5 properties per Match Type. For the demo, we'll maintain a simple SkillLevel for each player that is incremented when the player wins a match. Define the property as an Integer:

Logging In

The ChilliConnect SDK is instantiated in SceneController.cs. The AccountSystem class in AccountSystem.cs checks for an existing player account and either logs the existing account in to ChilliConnect, or creates a new account and then logs in. Once the player is logged in, we also retrieve their SkillLevel from Player Data. If there is no existing Skill Level, it is initialised to 0.

Note In this case we are using simple text files to store and load the players ChilliConnect credentials, and we'll do the same later on for saving the current Match in progress. The main reason is that this makes it easy to locally test the game from the Unity Player by simply renaming the text files before running the game to switch to a new player, and then changing them back. This way, we can jump between two player accounts without having to add extra UI during the tutorial.

public class AccountSystem 
{   
    ///...
    public void Initialise(ChilliConnectSdk chilliConnect)
    {
        m_chilliConnect = chilliConnect;
        var player = LoadPlayer ();
        if (player != null) {
            Login (player.ChilliConnectId, player.ChilliConnectSecret);
        } else {
            CreateNewAccount ();
        }
    }

    private PlayerCredentials LoadPlayer()
    {
        if (!File.Exists (SAVE_FILE)) {
            return null;
        }

        var parts = File.ReadAllLines(SAVE_FILE);
        if (parts.Length != 2) {
            return null;
        }

        var playerCredentials = new PlayerCredentials ();
        playerCredentials.ChilliConnectId = parts[0];
        playerCredentials.ChilliConnectSecret = parts[1];

        return playerCredentials;
    }

    private void LoadSkillLevel() {

        UnityEngine.Debug.Log ("Loading Skill Level");

        m_chilliConnect.CloudData.GetPlayerData (new List<string>{"SkillLevel"},
            (request, response) => OnPlayerDataLoaded (response),
            (request, error) => Debug.LogError (error.ErrorDescription));
    }

    private void OnPlayerDataLoaded(GetPlayerDataResponse response) {
        if (response.Values.Count == 0) {
            SkillLevel = 0;
        } else {
            SkillLevel = response.Values [0].Value.AsInt ();
        }

        UnityEngine.Debug.Log ("Loaded Skill Level:" + SkillLevel);

        OnPlayerLoggedIn(m_loggedInChilliConnectId);
    }

    private void SavePlayer(string chilliConnectId, string chilliConnectSecret)
    {
        File.WriteAllLines(SAVE_FILE, new string[]{ chilliConnectId , chilliConnectSecret });
    }

    public void CreateNewAccount()
    {
        var requestDesc = new CreatePlayerRequestDesc();
        m_chilliConnect.PlayerAccounts.CreatePlayer(requestDesc, (request, response) => OnChilliConnectAccountCreated(response), (request, createError) => Debug.LogError(createError.ErrorDescription));
    }

    private void OnChilliConnectLoggedIn(string chilliConnectId, string chilliConnectSecret)
    {
        UnityEngine.Debug.Log ("Logged in as player " + chilliConnectId);
        m_loggedInChilliConnectId = chilliConnectId;
        LoadSkillLevel ();
    }

    private void OnChilliConnectAccountCreated(CreatePlayerResponse response)
    {
        SavePlayer( response.ChilliConnectId, response.ChilliConnectSecret);
        Login (response.ChilliConnectId, response.ChilliConnectSecret);
    }

    private void Login(string chilliConnectId, string chilliConnectSecret)
    {
        var loginDesc = new LogInUsingChilliConnectRequestDesc (chilliConnectId, chilliConnectSecret);

        m_chilliConnect.PlayerAccounts.LogInUsingChilliConnect(loginDesc, 
            (loginRequest, loginResponse) => OnChilliConnectLoggedIn( chilliConnectId, chilliConnectSecret), 
            (loginRequest, error) => Debug.LogError(error.ErrorDescription));
    }
}

Once the player is logged and their SkillLevel loaded from Player Data, the AccountSystem then broadcasts a player logged in event. The SceneController listens to this event and then starts the match making process.

Starting a Match

The MatchSystem class contained in MatchSystem.cs is responsible for implementing the main match work-flow. Once the player is logged in, the MatchSystem will first check to see if there is an existing in progress match (again, like the player credentials, for this example we store persistent data using the file system to simplify testing):

public void LoadExistingOrFindNewGame(string chilliConnectId)
{
    m_chilliConnectId = chilliConnectId;

    var existingMatchId = LoadMatchId ();
    if (existingMatchId.Length == 0 ) {
        UnityEngine.Debug.Log("No existing game, looking for a new match");
        StartMatchmaking ();
    }
    else {
        UnityEngine.Debug.Log("Found existing game [" +  existingMatchId + "], refreshing from server");
        CurrentGame.MatchId = existingMatchId;
        RefreshMatchFromServer ();
    }
}

If there is no existing match, then the match making process starts. To attempt to join an available match, the JoinAvailableMatch method is used. JoinAvailableMatch can be used to find available matches within a particular criteria by defining a query string. Multiple queries can be provided that will be executed in order so that matchmaking can look for the best match possible and then broaden search criteria if no suitable match is found.

For this example, we first try to find a match with the same exact SkillLevel as the currently logged in player. If that fails, then we search for matches that are within a range of +/-5 of the current player. We also instruct ChilliConnect NOT to fall-back to any available match if our queries fail:

public void StartMatchmaking()
{
    UnityEngine.Debug.Log("Looking for new matches");

    OnMatchMakingStarted ();

    var joinMatchRequest = new JoinAvailableMatchRequestDesc(MATCH_TYPE);
    joinMatchRequest.Query = new List<string> () {
        "Properties.SkillLevel = :skillLevel",
        "Properties.SkillLevel > :skillLevelLower AND Properties.SkillLevel < :skillLevelUpper",
    };

    joinMatchRequest.Params = new Dictionary<string, SdkCore.MultiTypeValue> ();
    joinMatchRequest.Params ["skillLevel"] = SkillLevel;
    joinMatchRequest.Params ["skillLevelLower"] = SkillLevel - 5;
    joinMatchRequest.Params ["skillLevelUpper"] = SkillLevel + 5;

    joinMatchRequest.FallbackToAny = false;

    m_chilliConnect.AsyncMultiplayer.JoinAvailableMatch(joinMatchRequest,
        (request, response ) => JoinAvailableMatchCallBack(response),
        (request, error) => Debug.Log(error.ErrorDescription) );
}

The JoinAvailableMatchCallBack handles the response of the JoinAvailableMatch call:

private void JoinAvailableMatchCallBack(JoinAvailableMatchResponse response)
{
    if (response.Success)
    {
        var matchObject = response.Match;
        if (matchObject.State != GameState.MATCHSTATE_READY) {
            UnityEngine.Debug.Log("INVALID MATCH STATE:" + matchObject.State);
        }

        SaveMatchId(matchObject.MatchId);

        CurrentGame.MatchId = matchObject.MatchId;
        CurrentGame.Update(matchObject);
        CurrentGame.OccupyEmptyPlayerPosition(m_chilliConnectId);

        StartMatch (matchObject.MatchId);

        OnMatchMakingSuceeded (CurrentGame);
    }
    else
    {
        OnMatchMakingFailed ();
    }
}

If an available match was joined, we save the MatchID to disk so it can be loaded when the player next starts the game. We then update the CurrentGame property of MatchSystem with the current MatchId and match state. CurrentGame is an instance of the GameState class - this is used to store the current state of the game board, the state of the match and keep track of what player is X and what player is O:

public class GameState
{
    public const string PLAYER_X = "X";
    public const string PLAYER_O = "O";

    public const string BOARD_STARTING_STATE = "?????????";

    public string Board { get; set; }
    public string MatchState { get; set; }
    public string PlayerO { get; set; }
    public string PlayerX { get; set; }
    public string MatchId { get; set; }

    private string nextPlayerChilliConnectId;

    public GameState()
    {
        Board = BOARD_STARTING_STATE;
        MatchState = MATCHSTATE_WAITING;
        PlayerO = string.Empty;
        PlayerX = string.Empty;
        MatchId = string.Empty;
    }

    public SdkCore.MultiTypeDictionary AsMultiTypeDictionary()
    {
        var dictionary = new SdkCore.MultiTypeDictionaryBuilder();

        dictionary.Add("PlayerO", PlayerO);
        dictionary.Add("PlayerX", PlayerX);
        dictionary.Add("Board", Board);

        return dictionary.Build();
    }

    public void SetNewGame(string selectedSide, string chilliConnectId)
    {
        if (selectedSide.Equals(PLAYER_X)) 
        {
            PlayerX = chilliConnectId;
            PlayerO = string.Empty;
        } 
        else 
        {
            PlayerO = chilliConnectId;
            PlayerX = string.Empty;
        }

        MatchState = MATCHSTATE_WAITING;
        Board = BOARD_STARTING_STATE;
        MatchId = string.Empty;
    }

    public bool IsPlayersTurn(string chilliConnectId)
    {
        return MatchState == MATCHSTATE_IN_PROGRESS && nextPlayerChilliConnectId == chilliConnectId;
    }

    public bool IsWaitingForTurn ()
    {
        return MatchState == MATCHSTATE_IN_PROGRESS;
    }

    public void Update(Match match)
    {
        var matchStateData = match.StateData.AsDictionary ();
        PlayerO = matchStateData.GetString ("PlayerO");
        PlayerX = matchStateData.GetString ("PlayerX");
        Board = matchStateData.GetString ("Board");

        MatchState = match.State;

        if (MatchState == MATCHSTATE_IN_PROGRESS) {
            nextPlayerChilliConnectId = match.CurrentTurn.PlayersWaitingFor[0].ChilliConnectId;
        }
    }

    public string OccupyEmptyPlayerPosition(string chilliId)
    {
        if (PlayerO == string.Empty) 
        {
            PlayerO = chilliId;
            return PLAYER_O;
        }

        PlayerX = chilliId;
        return PLAYER_X;
    }

    public string GetPlayerSide (string m_chilliConnectId)
    {
        if (m_chilliConnectId == PlayerO) {
            return PLAYER_O;
        }

        return PLAYER_X;
    }

    public GameState Copy ()
    {
        var copy = new GameState ();
        copy.MatchId = MatchId;
        copy.PlayerO = PlayerO;
        copy.PlayerX = PlayerX;
        copy.Board = Board;
        copy.MatchState = MatchState;

        return copy;
    }
}

Once the CurrentGame has been updated, we then call the StartMatch method to move the match from the READY state to the IN_PROGRESS state. We update the StateData of the Match using the data from CurrentGame. This will update the stored match data on the server:

private void StartMatch(String matchId)
{
    var startMatchDesc = new StartMatchRequestDesc (matchId);
    startMatchDesc.StateData = CurrentGame.AsMultiTypeDictionary ();

    m_chilliConnect.AsyncMultiplayer.StartMatch (startMatchDesc,
        (request, response) => StartMatchCallBack(response),
        (request, error) => Debug.Log(error.ErrorDescription) );
}

Creating a New Match

If no suitable existing matches are found, then a new match can be created by using the CreateMatch method:

public void CreateNewGame (string selectedSide)
{
    CurrentGame.SetNewGame (selectedSide, m_chilliConnectId);

    var maxPlayers = 2;
    var createMatchRequest = new CreateMatchRequestDesc (MATCH_TYPE, TURN_TYPE_SEQUENTIAL, maxPlayers);
    createMatchRequest.StateData = CurrentGame.AsMultiTypeDictionary ();
    createMatchRequest.TurnOrderType = "RANDOM";
    createMatchRequest.AutoStart = false;
    createMatchRequest.Properties = new Dictionary<string, SdkCore.MultiTypeValue> ();
    createMatchRequest.Properties ["SkillLevel"] = SkillLevel;

    m_chilliConnect.AsyncMultiplayer.CreateMatch(createMatchRequest,
        (request, response) => CreateMatchCallBack(response),
        (request, error) => Debug.Log(error.ErrorDescription) );        
}

We initialise the CurrentGame with the players selected side before creating a new CreateMatchRequestDesc. We set the initial Match StateData so that the creating players selected side is stored as well as the initial board state. We set TurnOrderType to "RANDOM", this means that ChilliConnect will randomly set the turn order once the match is started. AutoStart is set to false - this means that once the match is full, it will move in to the READY state rather than IN_PROGESS. This ensures that we have a chance to set up the State Data of the match before manually starting with a call to StartMatch. Finally, the Match Properties are initialised to contain the current skill level of the player.

Updating the Match

Once a match is in progress, it is periodically refreshed from the server using the GetMatch call:

public void RefreshMatchFromServer()
{
    UnityEngine.Debug.Log("Refreshing game: " + Time.time);

    m_chilliConnect.AsyncMultiplayer.GetMatch (CurrentGame.MatchId,
        (request, response) => GetMatchCallBack(response),
        (request, error) => Debug.Log(error.ErrorDescription) );
}

The returned Match object from ChilliConnect will contain the updated Match StateData, updated State, and also the CurrentTurn which can be used to check who the next player is. In this example, we call Update on the GameState with the returned Match object:

public void Update(Match match)
{
    var matchStateData = match.StateData.AsDictionary ();
    PlayerO = matchStateData.GetString ("PlayerO");
    PlayerX = matchStateData.GetString ("PlayerX");
    Board = matchStateData.GetString ("Board");

    MatchState = match.State;

    if (MatchState == MATCHSTATE_IN_PROGRESS) {
        nextPlayerChilliConnectId = match.CurrentTurn.PlayersWaitingFor[0].ChilliConnectId;
    }
}

If your game allows a player to be actively involved in multiple matches at the same time, the GetPlayerMatches method can be used to return the status of all matches the player is participating in with a single call.

Submitting Turns

Turns are submitted using the SubmitTurn method. If the Match has been completed, we also set the Completed flag to true, and provide OutcomeData that contains the winning player. We also update the SkillLevel of the player.

public void SubmitTurn()
{
    UnityEngine.Debug.Log("Submitting turn");

    var submitTurnRequest = new SubmitTurnRequestDesc (CurrentGame.MatchId);
    submitTurnRequest.StateData = CurrentGame.AsMultiTypeDictionary ();
    if (CurrentGame.MatchState == GameState.MATCHSTATE_COMPLETE) {
        var outcomeDataBuilder = new SdkCore.MultiTypeDictionaryBuilder ();
        outcomeDataBuilder.Add ("Winner", m_chilliConnectId);
        submitTurnRequest.OutcomeData = outcomeDataBuilder.Build ();
        submitTurnRequest.Completed = true;

        SkillLevel++;
        var setPlayerDataRequest = new SetPlayerDataRequestDesc ("SkillLevel", SkillLevel);
        m_chilliConnect.CloudData.SetPlayerData (setPlayerDataRequest, 
            (request, response) => Debug.Log ("Player Data Updated"),
            (request, error) => Debug.Log (error.ErrorDescription));
    }

    m_chilliConnect.AsyncMultiplayer.SubmitTurn(submitTurnRequest,
        (request, response) => SubmitTurnCallBack(response),
        (request, error) => Debug.Log(error.ErrorDescription) );
}

Sending Push Notifications

A common pattern for Asynchronous Multiplayer games is to send a Push Notification to a player when it is their turn. This can be done in ChilliConnect using a Event Cloud Code Script. From the dashboard, select "Cloud Code" and "Add Script". Define the Type as "Event", and "Event Type" as "AsyncMultiplayer.submitTurn":

This will create a Cloud Code script that will be automatically called by ChilliConnect whenever the SubmitTurn API call is invoked. Paste in the below code:

/**
 * Get the updated match from the Request data
 */
var updatedMatchId = ChilliConnect.Event.Request.MatchID;

ChilliConnect.Logger.info( "Turn submitted for match:" + updatedMatchId );

/**
 * Get the updated match data from the Response data
 */ 
var updatedMatch = ChilliConnect.Event.Response.Match;

/**
 * Check updated state if match is still in progress
 */ 
var updatedState = updatedMatch.State;
if (updatedState == "IN_PROGRESS") { 

    /**
     * Get the next player
     */ 
    var nextPlayer = updatedMatch.CurrentTurn.PlayersWaitingFor[0].ChilliConnectID;

    ChilliConnect.PushNotifications.sendToChilliConnectIds( 
        [nextPlayer], "It's your turn!", null, "Match Updated");

    ChilliConnect.Logger.info( "Sent Push Notification to Player:" + nextPlayer);
}

This script will check the returned Match object to first ensure that the match is still in progress, and then send a Push Notification to the next player.

Next Steps

This tutorial has shown how simple ChilliConnect makes Asynchronous Multiplayer games. From here, there are a number of updates that could be made to the example:

  • Extend the basic match making system to something more sophisticated.
  • Implement rewards for winning players by modifying the Event Cloud Code script.
  • Maintain a Leaderboard that records consecutive player victories.
  • Make the game server authoritative using Cloud Code scripts to update the game state, rather than trusting the client. You can do this by disabling public access to the SubmitTurn API method through the applications ACL in your dashboard, and defining a Cloud Code Script that first checks the submitted turn data before calling SubmitTurn.