Creating a Cross-Platform Multi-Player Game in Unity — Part 3

In the third part of this tutorial, you’ll learn how to deal with shaky networks, provide a winning condition, and deal with clients who exit the session. By Todd Kerpelman.

Leave a rating/review
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Trick #2: Extrapolation

Rather than halting the car until the next update comes, you can take advantage of the velocity information you've received and keep the car moving until you get the next update. This is a cheap and easy trick that smoothes over network lag issues.

Open OpponentCarController and add the following variable:

private Vector3 _lastKnownVel;

This variable will hold the Vector location of the last known velocity of the car.

Add the following line near the end of SetCarInformation, right before you set _lastUpdateTime:

_lastKnownVel = new Vector3 (velX, velY, 0);

This sets the last known velocity based off the velocity that was used to set the car's information.

Finally, modify the if block in Update() as follows:

if (pctDone <= 1.0) {
    // Previous code
    // ...
} else {
    // Guess where we might be
    transform.position = transform.position + (_lastKnownVel * Time.deltaTime);
}

This sets the position based off the velocity value.

Build and run your game again; only update one of your devices to compare the motion between the two versions of the game. You should see a lot less lurching around than before.

Note: While your game certainly looks better, it's actually less accurate than before. Why? When you receive an update from another player, you're seeing their position at some point in the past.

Adding this extrapolation means it takes you longer to bring the player to the point where they were a few milliseconds ago! And if your opponent's updates take too long to arrive, their car will calmly drive off the edge of the playing field.

This loss of accuracy probably doesn't matter much in this game; if you needed to be more accurate, you could try to guess the position of your opponent's car — and you might end up with some slightly more accurate car positions.

This would probably involve calculating some accurate ping times for your clients, as well as adding a bit of AI. This is beyond the scope of this tutorial, but it would make for some great experience with AI and game physics if you wanted to tackle it on your own!

So you've got some smooth looking car action — but you still don't have a way to end the game. Time to fix that!

Finishing a Game

In this game, the race is won once a competitor completes three laps. At that point, you need to do three things:

  1. Stop the local player's car from moving.
  2. Send a "game over" message to all the other players once you've finished and note your final time.
  3. Show the game over screen once you've received a "game over" message from all other players.

Why do you need to send your final time in step 2 — why not just mark the order that you receive a "game over" message? Like everything in multiplayer games, it comes down to network lag. Imagine a situation where Player 1 finishes first, but by the time she sends a "game over" message to Player 2, Player 2 has already finished his game, so he thinks he's finished first.

And since all players in your networked game don't necessarily start at the exact same time, a faster player could receive a "game over" message by the slower player before they finish!

Crazy Time!

You can't rely on the order of messages to carry meaning in a networked game. You can only guarantee a fair resolution to the match by letting each client report their finishing times to all other clients.

Sending a Game Over Call

Open GameController find the following code in Update():

    if (_multiplayerGame) {
        // TODO: Probably need to let the game know we're done.
    } else {

...and replace it with the following code:

  if (_multiplayerGame) {
    // 1
    myCar.GetComponent<CarController>().Stop();
    // 2
    MultiplayerController.Instance.SendMyUpdate(myCar.transform.position.x,
                                                myCar.transform.position.y,
                                                new Vector2(0,0),
                                        myCar.transform.rotation.eulerAngles.z);
    // 3
    MultiplayerController.Instance.SendFinishMessage(_timePlayed);

  } else {

Looking in depth at the logic you added above:

  1. First, you tell the car controller to stop moving, at which point it will ignore all further updates from the interface.
  2. Next, you send an update to all other players with your current position, and a velocity of 0,0. This ensures your car stops at the finish line on all game clients.
  3. Finally, you call SendFinishMessage, which is a yet-to-be-written method in MultiplayerController which tells all of your opponents you've finished.

Open MultiplayerController and add the following private variable near the beginning of your class to track the length of the message:

// Byte + Byte + 1 float for finish time
private int _finishMessageLength = 6;

Now create the following method just after SendMyUpdate():

public void SendFinishMessage(float totalTime) {
    List<byte> bytes = new List<byte>(_finishMessageLength); 
    bytes.Add (_protocolVersion);
    bytes.Add ((byte)'F');
    bytes.AddRange(System.BitConverter.GetBytes(totalTime));  
    byte[] messageToSend = bytes.ToArray ();
    PlayGamesPlatform.Instance.RealTime.SendMessageToAll (true, messageToSend);
}

This is quite similar to SendMyUpdate(); the 'F' character lets other players know this is an "I'm done and here's my final time" call, followed by a float value containing the total elapsed time.

A big difference is the true argument in the SendMessageToAll call, which sends this message reliably, instead of via the unreliable mechanism you've used all along. Why? This time it's extra-super-important that all players know your car has finished. If this message were lost, your game would never end.

Even though it's important that your message is delivered, the timing of your message doesn't matter all that much. Even if this call was delayed by two or three seconds, it just means that the final screen wouldn't appear for a few seconds.

Receiving the Game Over Call

Now you'll need some logic to handle this "game over" message.

Still working in MultiplayerController, add the following code to the end of the if (messageType == 'U' ... block in OnRealTimeMessageReceived:

} else if (messageType == 'F' && data.Length == _finishMessageLength) {
    // We received a final time!
    float finalTime = System.BitConverter.ToSingle(data, 2);
    Debug.Log ("Player " + senderId + " has finished with a time of " + finalTime);    
}

This code checks to see if the message is a game-over message, at which point, it parses the final time and prints out a log message.

Build and run your game; race your cars around the track and once a player completes three laps, they'll come to a stop and a message similar to Player p_CPT-6O-nlpXvVBAB has finished with a time of 15.67341 message should appear in your console log.

Note: To speed up testing, set _lapsRemaining to 1 in SetupMultiplayerGame of GameController.cs. But don't forget to set it back when you want to play a real game! :]

Although the cars have stopped, you still need to handle the end-game logic and figure out who won.

Add the following line to MPUpdateListener in MPInterfaces:

void PlayerFinished(string senderId, float finalTime);

This declares that PlayerFinished is part of the MPUpdateListener interface.

Now you need to handle this information in GameController! Add the followiong variable to GameController.cs:

private Dictionary<string, float> _finishTimes;

This sets up a dictionary to map the finish times to participantIDs.

Inside of SetupMultiplayerGame(), add the following line before the start of the for-loop:

_finishTimes = new Dictionary<string, float>(allPlayers.Count);

Just inside the for-loop, add the following the line of code just underneath the first line like so:

for (int i =0; i < allPlayers.Count; i++) {
    string nextParticipantId = allPlayers[i].ParticipantId;             
     _finishTimes[nextParticipantId] = -1;   // <-- New line here! 
    ...

You initialize each entry with a negative number, which is an easy way to indicate that this player hasn't finished yet.

Next, add the following method to GameController.cs:

public void PlayerFinished(string senderId, float finalTime) {
    Debug.Log ("Participant " + senderId + " has finished with a time of " + finalTime);
    if (_finishTimes[senderId] < 0) {
        _finishTimes[senderId] = finalTime;
    }
    CheckForMPGameOver();
}

This simply records the finishing time of this player in the dictionary.

Next, add the following method underneath the previous one:

void CheckForMPGameOver() {
    float myTime = _finishTimes [_myParticipantId];
    int fasterThanMe = 0;
    foreach (float nextTime in _finishTimes.Values) {
        if (nextTime < 0) { // Somebody's not done yet
            return; 
        }
        if (nextTime < myTime) {
            fasterThanMe++;
        }
    }
    string[] places = new string[]{"1st", "2nd", "3rd", "4th"};
    gameOvertext = "Game over! You are in " + places[fasterThanMe] + " place!";
    PauseGame(); // Should be redundant at this point
    _showingGameOver = true;
    // TODO: Leave the room and go back to the main menu
}

In the code above you're iterating through the finish times of all of the players in your dictionary. If any of them are negative, it means they haven't finished yet and you can jump out early. Otherwise, you keep track of how many finish times are faster than the local player's so you can display the appropriate game over text. Then you set _showingGameOver to true so that your OnGUI() method knows to display the game over dialog box.

Next, add the following line to OnRealTimeMessageReceived() in MultiplayerController.cs, just after the Debug.Log line in the else-if block:

updateListener.PlayerFinished(senderId, finalTime);

Finally, you need to tell the local device that the game is done, as calling SendMessageToAll() doesn't send a message to the local player.

Open GameController.cs and in Update(), add the following code directly underneath the MultiplayerController.Instance.SendFinishMessage(_timePlayed); line:

PlayerFinished(_myParticipantId, _timePlayed);

Build and run your game on both devices; race both cars around the track and you should now have a lovely game over dialog! ...and once again, your game is stuck.

Game Over Dialog

It's high time that this game has a proper exit strategy! Fortunately, that's your very next task. :]

Todd Kerpelman

Contributors

Todd Kerpelman

Author

Over 300 content creators. Join our team.