Multiplayer

Create turn-based games with a matchmaking mechanism

This guide gathers a couple of examples that can shed some light on some of the basic mechanisms you will probably want to add to your online games. One is a matchmaking system to find rivals willing to play against each other in your game. The second one is an example on how to manage each player's turn on a turn based game.

Gamedonia calls will only work under a valid Gamedonia session. This means there needs to be a user logged in with Gamedonia. The main exceptions are, for obvious reasons, creating and logging users in.

Matchmaking

In this guide you will learn how to generate multiplayer matches with players of similar levels with the implementation of an Elo rating system. You can also challenge friends to a match.

Data Structures

You'll need two data structures to make this work. One to keep track of the player level, and the other to store the matches.

First, you need to define the Match structure:

    {
        "max_players": {integer},
        "players": [
            {
                "uid": {string},
                "elo": {integer}
            }
        ],
        "elo": {integer},
        "closed": {boolean}
    }

This is what each field models:

  • max_players - Maximum number of players allowed in the match.
  • players - List of players that joined the match.
  • elo - Indicates the level of the match.
  • closed - When all players have joined the match and the game starts.

To keep track of the player's ability, you are going to include a new field in his profile.

To include this new field, create a pre-save script at the Dashboard. Go to the Server Hooks section on the left menu, and at the Users row and Pre-Save column, hit the Add script button to add a new script.

    var profile = request.user.profile;
     
    if (profile.ability) {
        if (profile.ability < 0) {
            profile.ability = 0;
        }
    } else {
        // Add an ability field and initialize it with 1000 points
        profile.ability = 1000;
    }

When a new user is created, the script fills the profile data with a new ability field and sets its value to 1000. When it's not a new user, the script won't allow the ability value to drop to zero. This way you prevent users from getting a negative elo value.

Join/Create a Match

Server side code

Do a search for an existing match with players of similar elo. If this match doesn't exist, a new match is created with the players' elo level.

Create a server side custom script to be called from the game client in order to enter a match. You can create it by clicking on the Custom Scripts side menu at the Dashboard. This new script will return a match with the player registered to it, or it will show an error if the process fails at some point. When the match is filled with the maximum allowed players, the server will send a notification to all registered players telling that the match has started.

Create a custom script on the server with the following code:

var epsilon = 10;
 
var player_elo = request.user.profile.ability;
var max_elo = player_elo + epsilon;
var min_elo = player_elo - epsilon;
var query = "{$and:[{\"closed\":false},{\"elo\":{$gt:" + min_elo + "}},{\"elo\":{$lt:" + max_elo + "}}]}";
 
function updateMatch(match) {
    var player = new Object();
    player.uid = request.user.id;
    player.elo = request.user.profile.ability;
    
    match.players.add(player);
    
    if (match.max_players <= match.players.size()) {
        match.closed = true;
    }
 
    gamedonia.operateAsMaster();
    gamedonia.data.update("matches", match, {
        success: function(entity) {
                    for (var i = 0; i < entity.players.size(); i++) {
                        var player_info = entity.players[i];
                        var uid = player_info.uid;
                        
                        notification = new Object();
                        notification.type = "matchStart";
                        notification.matchId = entity.get_id();
                        notification.alert = "Match found";
                        notification.badge = 1;

                        gamedonia.push.send(notification, uid);
                    }
            
                    response.success(entity);
                },
        error: function(error) {
                    log.error(error);
                    response.error("Could not enter the match");
        }
    });
    gamedonia.operateAsNormal();
}
 
function createMatch() {
    
    var player = new Object();
    player.uid = request.user.id;
    player.elo = request.user.profile.ability;
    
    var match = gamedonia.data.newEntity(); 
    match.max_players = 2;  
    match.players = [player];
    match.closed = false;
    match.elo = request.user.profile.ability;
    
    gamedonia.data.create("matches", match, {
        success: function(entity) {
                    response.success(entity);
                },
        error: function(error) {
                    log.error(error);
                    response.error("Could not create a new match");
        }
    });
}
 
gamedonia.data.search("matches", query, 1, {
    success: function(entities) {
                if (entities.size() == 0) {
                    createMatch();
                }else{
                    var match = entities.get(0);  
                    updateMatch(match);
                }
             },         
    error: function(error) {
            log.error(error.message);
            response.error("Could not find a match");
    }
});

Client side code

On the client, you only have to call the Gamedonia SDK to run the script you just created at the Dashboard.

Write this call anywhere in your code, to join a match and get the match data in return:

// Create the callback function
    void OnMatchJoined(bool success, object data) {
        if (success) {
            // Data contains a Match object as described above.
            //TODO: Save the match ID for future operations.
        } else {
            string errorMsg = Gamedonia.getLastError().ToString();
            Debug.Log(errorMsg);
        }
    }

// Run the server-script to join or create a match
GamedoniaScripts.Run("joinmatch", new Dictionary<string,object>(){}, OnMatchJoined);
// Create the callback function
void MyClass::onMatchJoined(bool success, Value* data) {
    if (success) {
        // Data contains a Match object as described above.
        //TODO: Save the match ID for future operations.
    } else {
        //TODO: Process the error
    }
}

// Run the server-script to join or create a match
Value parameters;
GamedoniaSDKScript::run("joinMatch", &parameters, this, gamedoniaResponseData_selector(MyClass::onMatchJoined));
[[Gamedonia script] run:@"joinMatch" parameters:[NSDictionary alloc] callback:^(BOOL success, NSDictionary *data) {
    if (success) {
        // Data contains a Match object as described above.
        //TODO: Save the match ID for future operations.
    } else {
        //TODO: Process the error
    }
}];
GamedoniaScripts.run("joinMatch", {}, function (success:Boolean, data:Object):void {
    if (success) {
        // Data contains a Match object as described above.
        //TODO: Save the match ID for future operations.
    } else {
        //TODO: Process the error
    }
});
-- Create the callback function
local onMatchJoined = function (success, data)
    if (success)
        -- Data contains a Match object as described above.
        -- TODO: Save the match ID for future operations.
    else 
        -- TODO: Process the error
    end
end

-- Run the server-script to join or create a match
Gamedonia.Scripts.Run("joinMatch", {}, OnGetMatches)

If you need to enable the Push functionality - whether you’re on iOS or Android - see the Push Notifications Guide for a full walkthrough.

End a Match

Terminates a running match and updates the players' ability based on a formula that depends on the difference in ability between the players. After updating the players' ability, the match is deleted.

Server side code

Create a new custom script and fill it with the following code:

function checkParams() {
    if (request.params.match_id == null || request.params.match_id.equals("")) {
        log.error("match_id is required"); 
        return false;
    }
    
    return true;
}

function notifyEndMatch(player_id, match_id) {
    notification = new Object();
    
    notification.type = "matchEnd";
    notification.matchId = match_id;
    notification.alert = "Match finished";
    notification.badge = 1;
    
    log.info("Send notification to uid:" + player_id);
    gamedonia.push.send(notification, player_id);
}

if (checkParams()) {
    var match_id = request.params.match_id;
    var winner_id = request.user.id;
     
    gamedonia.data.get("Matches", match_id, {
        success: function(match) {
             
            gamedonia.user.get(winner_id, {
                success: function(winner) {
                     
                    var winner_elo = winner.profile.ability;
                    var max_win_elo = 0;
                    for (var i = 0; i < match.players.size(); i++) {
                        var player = match.players[i];
                        var uid = player.uid;
                        var player_elo = player.elo;
                        
                        if (uid != winner_id) {
                            // Punishes losers downgrading their elo
                            var elo_diff = Math.abs(player_elo - winner_elo) + 5;
                            max_win_elo = Math.max(elo_diff, max_win_elo);
                            
                            player_info = new Object(); 
                            player_info.ability = player_elo - elo_diff;
                            
                            gamedonia.operateAsMaster();
                            gamedonia.user.update(uid, player_info, {
                                success: function(losser) {
                                },
                                error: function(error) {
                                    log.error(error);
                                }
                            });
                            gamedonia.operateAsNormal();
                            
                            notifyEndMatch(uid, match_id);
                        }
                    }
                     
                    // Grants winners with more elo
                    winner.profile.ability = winner_elo + max_win_elo;
                    gamedonia.user.update(winner.profile, {});
                     
                    // You no longer need the ongoing match
                    gamedonia.data.remove("matches", match_id, {});
                },
                error: function(error) {
                    log.error(error);
                    response.error("User does not exist.");
                }
            });
        },
        error: function(error) {
            log.error(error);
            response.error("Match does not exist.");
        }
    });
     
} else {
    log.error("error checking paramters");
    response.error("error checking paramters");
}

Client side code

When a match finishes, only the winner calls the server sending the match ID. Put this code in your client to make the call when the match finishes:

GamedoniaScripts.Run("endmatch", new Dictionary<string, object>(){{"match_id","the_match_id"}}, null);
Value parameters;
GamedoniaSDKScript::run("endMatch", &parameters, this, gamedoniaResponseData_selector(MyClass::onMatchEnded));
[[Gamedonia script] run:@"endMatch" parameters:[NSDictionary alloc] callback:NULL];
GamedoniaScripts.run("endMatch", {}, null);
Gamedonia.Scripts.Run("endMatch", {}, OnMatchEnded)

Turn-Based Game

Basics

A turn-based match is a gaming session with multiple participants who take consecutive turns to update the game data during the match. Matches must be initiated by a player who is signed-in to Gamedonia. Matches take place asynchronously and participants do not need to be simultaneously connected to play.

In a turn-based multiplayer game, a single shared state is passed between multiple players, and only one player has permission to modify the shared state at a time. Players take turns asynchronously according to an order of play determined by the game. Using the Gamedonia SDK your game will be able to do the following tasks:

  • Look for random players to be automatically matched to your game.

  • Store participant and match state information on Gamedonia's servers and share updated match data asynchronously with all participants over the lifecycle of the turn-based match.

  • Send match start and turn notifications to players. Push notifications appear on all devices that support them.

A turn-based multiplayer match contains these properties:

  • Participants - A user can become a participant in a turn-based match by creating a match or by joining a match and being auto-matched into a game. Your game can retrieve the participant IDs for all players in a match.

  • Game data - Game-specific data for this match. As a match progresses, the current player can modify and store game data on Gamedonia's servers. The other participants can then retrieve and update this data on their turn. Your game must store the game data in an appropriate format so you can handle it the correct way.

  • Match state - A match can have one of the following states: ACTIVE, AUTO_MATCHING, COMPLETE, depending on participant actions during the match. The state of a match is managed by a server script. Your game can check the match state to determine whether a match can proceed, whether players can join by auto-match, or if the match is over (including if it finished normally or ended because of some user action).

Turn-taking

Only one match participant can take a turn at a time. Once the current player completes the actions for a turn, your game can specify the next participant to take a turn. Typically, the order of play is determined by the design of your game. Your game can pass the next turn back to the current player to another participant who has joined the match.

When it's another participant’s turn to play, a server script can send a turn notification to that participant’s mobile devices. Participants continue to take turns successively until the match is completed, canceled, or expires. From the server you can also send an update to match participants whenever a match event occurs (for example, when a match starts, is updated, or, canceled).

Match states

The following list gathers the possible states of a turn-based match that we are going to use in our example guide:

  • Active: Indicates that a new match is started and your game can let match participants take their turns.

  • Auto_Matching: This state indicates that there are still empty auto-match slots available and your game is waiting for an auto-matched player to join. Your game cannot let match participants take turns while the match is in this state.

  • Complete: Indicates that a match has been played to completion (for example, a player has won the match). A server script can send a notification to all match participants to inform them that the match is over. Players have a chance to save their final game data but may not modify the game state further.

State diagram for turn-based matches

Figure 1: Overview of turn-based multiplayer match states

We'll use a tic-tac-toe example to explain the mechanism of a typical turn-based game.

A new data structure

First of all you have to modify our matches collection to store our new needs. This is: game data, who is the next player to take a turn, and the state of the match. The final result looks like this:

{
    "state": {
        Active,
        Auto_Matching,
        Complete
    },
    "players": [
        {
            "uid": {string},
            "elo": {integer}
        }
    ],
    "max_players": {integer},
    "elo": {integer},
    "next_turn": {integer},
    "data": [{char}], //A 9 cell grid (3x3)
    "turn_number": {integer}
}

These are the new fields we created:

  • state - Stores the game state as described above. A match can be at one of the following states: Active, Auto_Matching, Complete.
  • next_turn - Contains the next player's turn of the players ordered list.
  • data - It's the game data stored as a 9 cell character array (3 rows and colums). The player movements can be X or O.
  • turn_number - An incremental value that indicates which is the current turn.

Game sequence

Start a new Game

Because we changed the data structure of a match, you need to make changes on the query we used to auto-match a game from the previous Matchmaking example. Modify the joinMatch.js and set the search query as follows:

var query = "{$and:[{\"status\":\"Auto_Matching\"},{\"elo\":{$gt:" + min_elo + "}},{\"elo\":{$lt:" + max_elo + "}}]}";

To add the new fields to a match and implementing the match start, you need to modify the joinMatch.js script. The script will look like this:

var epsilon = 10;
 
var player_elo = request.user.profile.ability;
var max_elo = player_elo + epsilon;
var min_elo = player_elo - epsilon;
var query = "{$and:[{\"status\":\"Auto_Matching\"},{\"elo\":{$gt:" + min_elo + "}},{\"elo\":{$lt:" + max_elo + "}}]}";
 
function sendStartMatch(match) {
    for (var i = 0; i < match.players.size(); i++) {
        var player = match.players[i];
        var uid = player.uid;
                        
        notification = new Object();
        notification.type = "matchStart";
        notification.matchId = match.get_id();
        notification.alert = "Match found";
        notification.badge = 1;

        gamedonia.push.send(notification, uid);
    }  
} 

function sendFirstTurn(match) {
    var players = match.players;
    var player = players[match.next_turn];
    var uid = player.uid;
    
    notification = new Object();
    notification.type = "matchTurn";
    notification.matchId = match.get_id();
    notification.alert = "Your turn";
    notification.badge = 1;

    gamedonia.push.send(notification, uid);
    
}
 
function updateMatch(match) {
    var player = new Object();
    player.uid = request.user.id;
    player.elo = request.user.profile.ability;
    
    match.players.add(player);
    
    if (match.max_players <= match.players.size()) {
        match.put("status", "Active");
        match.next_turn = Math.floor(Math.random() * match.max_players);
        match.turn_number = 1;
    }
 
    gamedonia.operateAsMaster();
    gamedonia.data.update("matches", match, {
        success: function(entity) {
                    sendStartMatch(entity);
                    sendFirstTurn(entity);
                    response.success(entity);
                },
        error: function(error) {
                    log.error(error);
                    response.error("Could not enter the match");
        }
    });
    gamedonia.operateAsNormal();
}
 
function createMatch() {
    
    var player = new Object();
    player.uid = request.user.id;
    player.elo = request.user.profile.ability;
    
    var match = gamedonia.data.newEntity(); 
    match.max_players = 2;  
    match.players = [player];
    match.data = ["-","-","-","-","-","-","-","-","-"];
    match.status = "Auto_Matching";
    match.elo = request.user.profile.ability;
    
    gamedonia.data.create("matches", match, {
        success: function(entity) {
                    response.success(entity);
                },
        error: function(error) {
                    log.error(error);
                    response.error("Could not create a new match");
        }
    });
}
 
gamedonia.data.search("matches", query, 1, {
    success: function(entities) {
                if (entities.size() == 0) {
                    createMatch();
                }else{
                    var match = entities[0];  
                    updateMatch(match);
                }
             },         
    error: function(error) {
            log.error(error.message);
            response.error("Could not find a match");
    }
});

This is how players would join a match. This code is a client side code that calls the SDK to run a custom script called joinmatch

// Create the callback function
void OnMatchJoined(bool success, object data) {
    if (success) {
        // Data contains a Match object as described above.
        //TODO: Save the match ID for future operations.
    } else {
        errorMsg = Gamedonia.getLastError().ToString();
        Debug.Log(errorMsg);
    }
}

// Run the server-script to join or create a match
GamedoniaScripts.Run("joinmatch", new Dictionary<string,object>(){}, OnMatchJoined);
// Create the callback function
void MyClass::onMatchJoined(bool success, CCDictionary* data) {
    if (success) {
        // Data contains a Match object as described above.
        //TODO: Save the match ID for future operations.
    } else {
        //TODO: Process the error
    }
}

// Run the server-script to join or create a match
GamedoniaSDKScript::run("joinMatch", CCDictionary::create(), this, gamedoniaResponseData_selector(MyClass::onMatchJoined));
[[Gamedonia script] run:@"joinmatch" parameters:[NSDictionary alloc] callback:^(BOOL success, NSDictionary *data) {
    if (success) {
        // Data contains a Match object as described above.
        //TODO: Save the match ID for future operations.
    } else {
        //TODO: Process the error
    }
}];
GamedoniaScripts.run("joinmatch", {}, function (success:Boolean, data:Object):void {
    if (success) {
        // Data contains a Match object as described above.
        //TODO: Save the match ID for future operations.
    } else {
        //TODO: Process the error
    }
});
-- Create the callback function
local onMatchJoined = function (success, data)
    if (success)
        -- Data contains a Match object as described above.
        -- TODO: Save the match ID for future operations.
    else 
        -- TODO: Process the error
    end
end

-- Run the server-script to join or create a match
Gamedonia.Scripts.Run("joinmatch", {}, onMatchJoined)

Take turn

When all players have joined the match, a notification is sent to each player indicating that the match is Active. A notification is also sent to the player who should take the first turn. When a player takes a new turn and updates the Match object, you need to verify whether it is the player's turn and if the movement is correct. The verification of the movement is out of the scope of this guide, so we will rely on the client for this verification. You will want to verify every movement in your game to avoid cheating. Store this script as a pre-save for the matches collection. As seen on our Server code guide, it will trigger when creating or modifying a match.

function sendNextTurn(match) {
    var players = match.players;
    var player = players[match.next_turn];
    var uid = player.uid;
    
    notification = new Object();
    notification.type = "matchTurn";
    notification.matchId = match.get_id();
    notification.alert = "Your turn";
    notification.badge = 1;

    gamedonia.push.send(notification, uid);
    
}

function sendFinished(match) {
    var players = match.players;
    var player = players[match.next_turn];
    var uid = player.uid;
    
    notification = new Object();
    notification.type = "matchFinish";
    notification.matchId = match.get_id();
    notification.alert = "Match finished";
    notification.badge = 1;

    gamedonia.push.send(notification, uid);
}

function isFinished(match) {
    //TODO: determine match finish
    var data = match.get("data");
    
    var count = 0;
    for (var i=0; i<data.size(); i++) {
        if (data[i] != "-") count++;
    }
    
    return (count >= 5);
}

var match = request.object;

if (isFinished(match)) {
    match.status = "Complete";
    sendFinished(match);
}else{
    var next_turn = (parseInt(match.next_turn) + 1) % match.max_players;
    match.next_turn = next_turn;
    match.turn_number = parseInt(match.turn_number) + 1;
    sendNextTurn(match);
}

To receive push notifications related to game changes on your devices, you must follow these steps:

NOTE: This is a brief example of how to receive push notifications for this purpose. If you haven't done it yet, you will need to enable and configure push notifications for your game following the push notifications guide.

//Handle push
GDPushService service = new GDPushService();
service.RegisterEvent += new RegisterEventHandler(OnGameUpdateNotification);
GamedoniaPushNotifications.AddService(service);

Then, create a new OnGameUpdateNotification into the body of your script.

void OnGameUpdateNotification(Dictionary<string,object> notification) {

        Hashtable payload = notification["payload"] != null ? (Hashtable) notification["payload"] : new Hashtable();
        string type = payload.ContainsKey("type") ? payload["type"].ToString() : "";
        switch(type) {
        case "matchStart":
            //TODO: process the message
            break;
        case "matchTurn":
            //TODO: process the message
            break;
        case "matchFinish":
            //TODO: process the message
            break;
        case "matchEnd":
            //TODO: process the message
            break;
        default:
            // Do nothing
            break;
        }
    }

First your class must inherit from GamedoniaSDKPushListener. After that you can set the handler of the push event.

//Handle push
GamedoniaSDKPush::setDidReceiveRemoteNotificationCallback(this);

Then, override the didReceiveRemoteNotification method into the body of your class.

void MyGame::didReceiveRemoteNotification(CCDictionary* notification)
{
    const CCString* type = notification->valueForKey("type");

    if (type->compare("matchStart") == 0) {
        //TODO: Make start match logic here.
    } else if (type->compare("matchNextTurn") == 0) {
        //TODO: Make take turn logic here.
    } else {
        // Do nothing
    }
}

On iOS it's really easy to start receiving push notifications as you only need to activate them in your appDelegate that inherits from UIResponder <UIApplicationDelegate>.

[application registerForRemoteNotificationTypes:UIRemoteNotificationTypeBadge];

Then, implement the didReceiveRemoteNotification method into the body of your class.

- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo {
    NSString *itemType = [[userInfo objectForKey:@"type"] description];

    if ([itemType isEqual: @"matchStart"]) {
        //TODO: Make start match logic here.
    } else if([itemType isEqual: @"matchNextTurn"]) {
        //TODO: Make take turn logic here.
    } else {
        // Do nothing
    }
}

First you need to attach a new event to start receiving push notifications.

GamedoniaPush.instance.addEventListener(GamedoniaPushEvent.REMOTE_NOTIFICATION_RECEIVED, onReceiveRemoteNotification);

Then, implement the onReceiveRemoteNotification method into the body of your class.

protected function onReceiveRemoteNotification(event:GamedoniaPushEvent) : void {
    var payload:Object = event.remoteNotification.payload;
    if (payload != null && payload.hasOwnProperty("type")) {
        var type:String = payload.type;
        if (type == "matchStart") {
            //TODO: Make start match logic here.
        } else if (type == "matchNextTurn") {
            //TODO: Make take turn logic here.
        } else {
            // Do nothing
        }
    }
}

First you need to attach a new event to start receiving push notifications.

Runtime:addEventListener( "notification", notificationListener )

Then, implement the notificationListener method into the body of your class.

local function notificationListener( event )

    if ( event.type == "remote" ) then
        --handle the push notification
    end
end

When the player has finished making a new movement, you will have to send the updated game state to the server. Make this call to update the match, thus triggering the take turn pre-save script seen above:

Dictionary<string,object> updated_match = new Dictionary<string,object>();
updated_match.add("data", new_board_data);

GamedoniaData.Update("matches", updated_match, null, false);
CCDictionary* updated_match = CCDictionary::create();
updated_match->setObject(new_board_data, "data");

GamedoniaSDKData::update ("matches", updated_match, false, this, gamedoniaResponseData_selector(MyClass::MatchUpdatedCallback));
NSDictionary * updated_match = [NSDictionary dictionaryWithObjectsAndKeys:
                           new_board_data, @"data",
                        nil];

[[Gamedonia data] update:@"matches" 
                  entity:updated_match
                  overwrite:false
                  callback:onMatchUpdated];
var updated_match:Object = new Object();
updated_match.data = new_board_data;

GamedoniaData.update("matches", updated_match, false, onMatchUpdated);
local updated_match = {}
updated_match.data = new_board_data

Gamedonia.Data.update ("matches", updated_match, false, onMatchUpdated)

End Match

Only the winner of a match calls the endmatch script on the server. Here we follow the same procedure as seen in the Matchmaking example:

//match_id is match identifier
GamedoniaScripts.Run("endmatch", new Dictionary(){{"match_id",matchId].ToString()}}, null);,>
CCDictionary parameters;
GamedoniaSDKScript::run("endmatch", &parameters, this, gamedoniaResponseData_selector(MyClass::onMatchEnded));
[[Gamedonia script] run:@"endmatch" parameters:[NSDictionary alloc] callback:NULL];
GamedoniaScripts.run("endmatch", {}, null);
Gamedonia.Scripts.Run("endmatch", {}, OnMatchEnded)