Reconnecting PlayerSessions - Gamelift Realtime

Using GameLift realtime, if a PlayerSession is created, client connects, then dropped (e.g. network issue) there appears no way to reconnect that client without creating a new player session.
Or am I missing something.

What should ideally happen:

  • Client calls “connect” with the same PlayerSessionID and get a handle back on the same Game, same PeerID.

What appears to actually happen:

  • The connect is rejected as "GameLiftServerAPI.AcceptPlayerSession failed for player session id: psess-XXXX with error: [GameLiftError: ErrorType=5, ErrorMessage=PlayerSession (XXX) has a status of COMPLETED instead of RESERVED]

What this means:

  • All interaction with the server and between clients needs to be based on an external ID (e.g. PlayerID or passed in PlayerData)
  • Extra processing on the ClientServer to handle to request and re-create the session (as a new session)
  • Extra processing on the GameLiftServer to keep track of the additional session (the are actually the same)
  • As neither PlayerID or PlayerData are available in RealTime server (WHY NOT?!) this means the PlayerID needs to be retrieved from the ClientServer based on the PlayerSession.

This therefore involves a total of 5 network interactions (latency = 5 * 2 both ways) where 1 should do, and a map of PlayerSessions to PlayerIDs maintained. A lot of extra processing and complication. Network drop-outs/application crashes happen so this is unavoidable.

Or am I missing something - is there a way to reconnect a PlayerSession back to GameSession without the rigmarole?

1 Like

Some additional information from logs:

When the client disconnects, here are the logs:

[INFO] (index.js) 474: Player 1 disconnected from IP:X.X.X.X
[INFO] (gamelift.js) 93: Calling GameLiftServerAPI.RemovePlayerSession with player session id: psess-XXX
[INFO] (gamelift.js) 98: GameLiftServerAPI.RemovePlayerSession succeeded with player session id: psess-ff7fdade-bd86-4767-8d01-9eafc7d0ae3a

Ideally we ask the application if we “RemovePlayerSession” or hold it open? And even if we do, can a client reconnect to an “ACTIVE” session?

When using a PlayerSession, I don’t know if you can reopen a player session thats been marked closed (via RemovePlayerSession).

The ping/pong connection health I believe is managed by GameLift Realtime and looking at https://docs.aws.amazon.com/gamelift/latest/developerguide/realtime-script-callbacks.html there doesn’t seem to be a way to intercept change this behaviour.

I can see a situation where you do the scenario you pointed out:

  • Player reconnects with an invalid player session
  • Server sees that player session was previously hosted on session and makes a new session
  • Accepts player on new session
  • Lets the player know of the new session id

Seems a little messy as you pointed out or you can make a new player session for the player on connection failure.

I will ping the GameLift service team to see if they have any advice.

My studio handles this the route of ignoring peerid for the most part and tracking players with PlayerID. This gives some advantages as well that should be noted:

  • Their status in the game can be decoupled from their connection
  • They can reconnect to the gamesession with a different device and still get their player data from the previous device (this is the largest benefit in my opinion).

In general I believe using peerid in terms of connections that have occurred and went through a lifecycle is the standard of digital communication systems and isn’t intended to actually identify users.

  • Player reconnects with an invalid player session
  • Server sees that player session was previously hosted on session and makes a new session
  • Accepts player on new session
  • Lets the player know of the new session id

This actually would be a nice addition to GameLift. Currently in my code, if a disconnection happens due to connection failure or my KeepAlive checks fail (the client OnConnectionErrorEvent/OnCloseEvent are really slow at realizing it has lost connection), I disconnect the client and immediately create a new playersession using the stored gamesession id.

However, I also have a system already in place to handle connecting to playersessions informed from the server (since this is essentially what FlexMatch does as well).

Having the server automate this process would remove a small amount of computing from the client (although not enough to really be notable), but would be a nice quality of life addition.

I can expand a little on what @Sky_Copeland said in terms on how this can be handled on the back end.

A few things start.

  1. I know there are lots of ways that you can go about making PlayerSessions and GameSessions. What I am about to present represents a particular workflow. This is not how you have to solve this problem, this is just an overview of how we solved this problem.

  2. There will be times where I probably make assumptions about how well people know AWS/js or I will keep things short for the sake of not making this an AWS/js tutorial and I may hand wave a few steps. Feel free to yell at me.

  3. As best as I can tell the the object you get on onPlayerConnect just does not have enough information to get the PlayerName. It is kind of baffling frankly. This is an example dump of the connectMessage passed in https://pastebin.com/sRcKkcTS The payload part does translate to a playername however that was provided explicitly by the client and not really secure. More on that later.

With that out of the way. Here is how we dealt with this.

Our player sessions are created by lambdas making authenticated calls to to a restful API backed by API Gateway. Roughly speaking it looks like this.

    //So really I do all this in TypeScript but since it is all just js in the end 
    //and more people will probably know js 
    //I will try to keep this examples as vanilla as I can.
    const aws = require("aws-sdk");
    const gameLift = new aws .GameLift();
    const standardApiResponse = (payload, status) => {
        const response = {
            statusCode: status,
            body: payload
        };
        return response;
    };
    const createPlayerSession = async (userName, sessionId) => {    
        const playerSessionInput = {
            PlayerId: userName,
            GameSessionId: sessionId
        };
        const playerSession = await gameLift.createPlayerSession(playerSessionInput).promise();
        return playerSession;
    };
    //This is the lambda that is actually getting hooked up the the API call. This is kind of where
    //This can be come a 10 page tutorial just on AWS depending on how deep we want to go into things. 
    //The assumes that the caller has a game session ready to go. Again this is just one workflow. 
    //This is a lot of missing validation here for the sake of being simple.
    exports.addPlayerSession = async (event) => {
        if (!event || !event.body) {
            return standardApiResponse("Missing post body", 400);
        }
        const body = JSON.parse(event.body);
        const { gameSession } = body;    

        //Hand waving here. This is where you make sure PLAYER_X is the the one making the call
        //and that that are the only one who can make a PlaerSession in the name of PLAYER_X. 
        //This is an over simplification of what we do. 
        //How you do this really depends on how your game works
        //and your security needs
        const { requestContext: { authorizer } } = event;
        if (!authorizer) {
            return standardApiResponse("Problem getting authorizer", 400);
        }
        const userName = authorizer.userName;
        
        if (!gameSession ) {
            return standardApiResponse("The field gameSession is required in the post body", 400);
        }
        try {
            const playerSession = await createPlayerSession(userName, gameSession);        
            return standardApiResponse(JSON.stringify(playerSession), 200);
        }
        catch (error) {
            return standardApiResponse(error.message, 400);
        }
    }

The above just means that if you have a PLAYER_X. Then only PLAYER_X should be able to make that call and great a session in the name of PLAYER_X. It will return the ploop of json I am sure you are already familiar working with. You may not need any of the above.

This is where things get strange. Armed with this PlayerSession a client can now connect to a game and send a payload when it does. This is where we enter the realm of the realtime script. In my opinion there are really 2 problem to solve now.

  1. How to we verify a person who connects really is PLAYER_X
  2. How do deal with that player leaving and coming back for any number of reasons.

In terms of dealing with #1. You can look at that above pastbin link for an example of what the connectMsg is in

function onPlayerConnect(connectMsg) {
    return true;
}

There are two ways as best I can tell to figure out a name backing that PlayerSession.

  1. The client can pass a payload in when it connects. It becomes a byte array and can be turned back into a string. This is quick, dirty, and not at all secure.
  2. That object does have the playerSessionId and your realtime script can do something like make an API call to access a resource where it passes in the playerSessionId and gets back the information it needs that is not in connectMsg… you know like the name of the the player that you can see in the gamelift console.

For the sake of keeping it short I will use this first one for this example. The second one can be left for further discussion if needed or as an exercise for the reader.

Really I just want somebody to step in and tell me I am missing something because man that part is frustrating.

Anyhow on to your actual problem. This is stripped down parts of a basic realtime script that you should already be familiar with. Again I do things mostly in typescript so I may have missed a few things, if something is wrong please just yell.

let peerMap = {};

const STATUS_PLAYER_CONNECTED = 100;
const STATUS_PLAYER_DISCONNECTED = 200;
const USER_GROUPS_GLOBAL = 1;

const sendToGlobalGroup = (opCode, message) => {
    const outMsg = session.newTextGameMessage(opCode, session.getServerId(), message);
    session.sendReliableGroupMessage(outMsg, USER_GROUPS_GLOBAL);
}; 

const messagePayload2String = (array) => {
    var result = "";
    for (var i = 0; i < array.length; i++) {
        result += String.fromCharCode(array[i]);
    }
    return result;
};

const onPlayerConnect = (connectMsg) => {
    const name = messagePayload2String(connectMsg.payload); //Sad hack here
    let returningPlayer = false;
    for (let key in peerMap) {
        const playerConnection = peerMap[key];
        if (playerConnection && playerConnection.name == name) {
            returningPlayer = true;
            break;
        }
    }
    if (returningPlayer) {
        //Hand waving

        //Do something like
        const data = CallAFunctionThatGetsTheOldPlayersDataByName(name);
        //send that data to connectMsg.player.peerId
        
        //send a message to all players letting them know name is back.

        //If you have anything in your code the references a resource by peerID this
        //is where you update that. However really you should strive to associate things
        //by name if you can. 

        //update the peermap!
        //Because this is over simplified it looks like the below code could be
        //cut down. Really for your game you should have more player data here
        //and how you update the peermap will be different on connect vs
        //on reconnect. 
        peerMap[connectMsg.player.peerId] = {
            name: name,            
            lostConnection: false
        };
    }
    else {        
        peerMap[connectMsg.player.peerId] = {
            name: name,            
            lostConnection: false            
        };
    }
    return true;
};

const onPlayerAccepted = (player) => {
    if (peerMap[player.peerId]) {
        const connectingPLayer = {
            playerName: peerMap[player.peerId].name            
        };
        //Again here is where you should add your data for whatever you are doing
        sendToGlobalGroup(STATUS_PLAYER_CONNECTED, JSON.stringify(connectingPLayer));
    }
};

const onPlayerDisconnect = (peerId) => {
    //This is again based on our workflow where we try to communicate in terms of the player name
    //as ofen as we can.
    if (peerMap[peerId]) {
        globalMessage(STATUS_PLAYER_DISCONNECTED, peerMap[peerId].name);
        peerMap[peerId].lostConnection = true;
    }
    sendToGlobalGroup(STATUS_PLAYER_CONNECTED, JSON.stringify(connectingPLayer));
};

const onMessage = (gameMessage) => {
    const userName = peerMap[gameMessage.sender].name;
    doSomeActionBasedOnOpCodeAndUserName(username, gameMessage.opCode, gameMessage.getPayloadAsText())
    //If everything works you should get a playername here and move forward in your game
    //using that as the identifier    
}

Most of the actual game code in our game assumes a unique username. We try to make sure all the glueing is done in onPlayerConnect, onPlayerDisconnect, and onMessage. We try to abstract peerid away from being a thing that the actual game code needs to care about.

Hope this helps.

@Pip Thanks again for the response. There are several little features that would make GameLift Realtime that little easier to implement. As you can see from the other responses there are always solutions, but just unnecessary and add that complexity compared to how other MP engines work.

@Sky_Copeland @ElNahal Thanks to both of you for both confirming and solutions.

The methods you have described are (practically) exactly how it’s being currently handled, namely:

  • PlayerID is our unique handler that signified the User/ClientApp making the call.
  • PlayerID (and/or other details) are passed in the OnPayerConnect using the payload.
  • This PlayerID is passed to the game object and held in a “PENDING” state. At this stage you can only marry against the PlayerSessionID. (Note: this overwrites the existing PlayerID record and prevents calls to the user - but is probably necessary as the client will have changed anyway.)
  • When we get the peerID (OnPlayerAccepted) the player’s details (“PENDING”) is located by PlayerSessionID and changed to “JOINED” with the new PeerID - communications can start/resume with that user.
  • When a client disconnects, the PlayerID record is marked as “DELETED” but not actually removed - in the hope that the player rejoins. This also stops communications.
  • A tick function cleans up old states.

Disadvantages

  • There are two looping “findByPlayerSessionID” and “findByPeerID” functions. While possible to duplicate/cache these by separate arrays, those separate arrays will need cleaning up which is not ideal/adds extra work (even async).
  • As noted in my opening post, each re-connection involves a trip back to the main server. Every time you minimise an iPhone app it will disconnect; so imagine that you create a GameRoom and share those details via Social - every time you share you need to reconnect.

TL;DR: it’s working - could be so much easier!

Other notes (for others that may follow this thread):

  • Another trick we are testing is using REDIS as a holder for passing larger information. It seemingly works very well in tests, but the REDIS calls need to await while fetching - so no idea how well this will work under load.
  • We have also used SQS/SNS. We’ll use SQS to get messages back to the main application but probably not into GameLift (timing / reliability concerns).