Creating a Cross-Platform Multiplayer Game in Unity — Part 4

Todd Kerpelman

Prepare for the final lap where you will learn how to handle drivers sending mixed signals!

Prepare for the final lap where you will learn how to handle drivers sending mixed signals!

Welcome back to the final part of this series on implementing multiplayer in your games. The previous three sections of this tutorial series walked you through creating a functional multiplayer game, including matching with random players across the internet, dealing with infrequent updates, properly ending the game, and handling players who leave mid-game.

You’re not quite done; to put some polish on your app you’ll have to get your cross-platform app running properly on Android and let more than two people play at once among other things! However, they aren’t huge issues to fix — and you’ll take care of all of them in this part of the tutorial.

The most pressing problem to fix, however, is dealing with your unreliable messaging protocol and handling the inevitable out-of order messages.

In this tutorial, you will be learning the following:

  • How to respond to messages that are received out of order
  • How to cancel auto matchmaking
  • A whole lot of game theory regarding network connectivity

You can download the starter project over here.

If you are starting this project with the starter project, you will need to configure based on the first part of the tutorial series.

Handling Out of Order Messages

Since you’re likely testing with your devices on a fast internal network, there’s a good chance most of your messages arrive in order; throttling back on your updates as you did in the previous chapter also means that statistically, you’ll experience fewer out-of-order messages.

However, real users play games while on public transportation that meanders in and out of good cell coverage, and some players just have cruddy mobile connections all the time. You can easily imagine what might happen if you receive an update message out of order. Think about the following car traveling around the track with messages #1 through #6 received in order:

Cars in order

But what if message #3 wasn’t received until sometime after message #5? You’d end up with a zig-zagging car as the following image illustrates:

Zig Zagging Car!

The car will appear to teleport all over the place — what a mess! Here’s a very simple strategy you can use to manage this issue:

  • Anytime you send out an update message, start the message with a number that you automatically increment.
  • Anytime you receive an update message from another player, look at the auto-increment number; if it’s smaller than the number from the previous message you received, then it’s an out-of-order message, and you simply discard it.

In the example above, you would simply discard car location #3, since you received it after message #5. Sure, you’d be missing a location update, but the car would continue on with the same velocity from message #2 and recover when it received location #4:

Skipping in out of order message

Re-open the Circuit Racer project, then open MultiplayerController.cs in the scripts folder. Add the following variable to track your own auto-increment message number:

private int _myMessageNum;

Next, update _updateMessageLength, so that you have space for this new 4-byte integer.

// Byte + Byte + 1 int for message num + 2 floats for position + 2 floats for velcocity + 1 float for rotZ
private int _updateMessageLength = 26;

Add the following code to the OnRoomConnected callback, right before your Application.LoadLevel call:

_myMessageNum = 0;

This simply initializes _myMessageNum to a starting value.

Now, modify SendMyUpdate as follows to include the new message number:

public void SendMyUpdate(float posX, float posY, Vector2 velocity, float rotZ) {
    _updateMessage.Clear ();
    _updateMessage.Add (_protocolVersion);
    _updateMessage.Add ((byte)'U');
    _updateMessage.AddRange(System.BitConverter.GetBytes(++_myMessageNum)); // THIS IS THE NEW LINE
    ...

You won’t need to add any kind of message number to SendFinishMessage; that’s because this message is sent reliably and is guaranteed to be received in order. Also, you’re only sending one of these messages, which means sending it out of order is mathematically impossible. :]

Now that you’re sending this message number, you need to handle it on the receiving end.

if (messageType == 'U' && data.Length == _updateMessageLength) { 
        int messageNum = System.BitConverter.ToInt32(data, 2);
	float posX = System.BitConverter.ToSingle(data, 6);
	float posY = System.BitConverter.ToSingle(data, 10);
	float velX = System.BitConverter.ToSingle(data, 14);
	float velY = System.BitConverter.ToSingle(data, 18);
	float rotZ = System.BitConverter.ToSingle(data, 22);
 
    if (updateListener != null) {
        updateListener.UpdateReceived(senderId, messageNum, posX, posY, velX, velY, rotZ);
    }
} else if ...

This change means you’ll have to adjust your method’s signature in MPUpdateListener.

Open your MPUpdateListener interface in MPInterfaces.cs and change UpdateReceived as follows:

void UpdateReceived(string participantId, int messageNum, float posX, float posY, float velX, float velY, float rotZ);

Next, open your GameController class and change UpdateReceived as follows:

public void UpdateReceived(string senderId, int messageNum, float posX, float posY, float velX, float velY, float rotZ) {
    if (_multiplayerReady) {
        OpponentCarController opponent = _opponentScripts[senderId];
        if (opponent != null) {
            opponent.SetCarInformation(messageNum, posX, posY, velX, velY, rotZ);
        }
    }
}

You’re now passing messageNum to SetCarInformation() of OpponentCarController.

Open OpponentCarController.cs, and add the following variable:

private int _lastMessageNum;

This variable contains that last message number that it received. That way, you can determine if the next message is an older or newer one.

Now initialize that variable in Start():

_lastMessageNum = 0;

Now change as follows:

public void SetCarInformation(int messageNum, float posX, float posY, float velX, float velY, float rotZ) {
    if (messageNum <= _lastMessageNum) {
        // Discard any out of order messages
        return;
    }
    _lastMessageNum = messageNum;
 
    // The rest of the method is the same as before.
}

Whenever you receive a message number from an opponent, you compare it to the lastMessageNum you have stored for that player. If it’s a smaller number than what you’ve received in the past, you know this message has been received out of order and you toss it.

That was a wide-ranging change; build and run your game to make sure everything is still working. But just for kicks, update your game on only one device and see what happens…

Your clients are no longer talking to each other! In fact, pretty soon your players will time out on each other’s devices because no messages are getting through. Can you guess why?

Solution Inside: Why are our clients giving each other the silent treatment? SelectShow

In a way, you got lucky in this scenario. Ignoring each other’s messages is probably the best outcome. What would have happened if you didn’t bother checking the message length, or if you removed a few bytes elsewhere in the message so that it ended up the same length as before?

You could have created a situation where one client thinks it’s sending an integer representing the message number, but the other client is interpreting those 4 bytes as a float representing the car’s x position.

It’s not a theoretical problem — this could totally happen in real life. When you release a new version of your game, not all players will update right away. Your client has no way of knowing it’s using an older message protocol…unless you had a way of sending a version number across with every message.

Wait — you do have a way to do that. Remember that _protocolVersion variable you set way back in the last lesson? The one Unity keeps complaining that we’re not using? It’s time to put it to work.

We could not secure the rights to Dr. Zoidberg, but that isn't going to stop you from reading this in his voice.

We could not secure the rights to Dr. Zoidberg, but that isn’t going to stop you from reading this in his voice.

First, you should change your version number, since your message format has changed.

Change the value of _protocolVersion near the top of MultiplayerController as follows:

private byte _protocolVersion = 2;

Next, you will modify OnRealTimeMessageReceived so you check the message version before you do anything else.

Add the following code to the begining of OnRealTimeMessageReceived, right underneath the line byte messageVersion = (byte)data[0];

if (messageVersion < _protocolVersion) {
    // Our opponent seems to be out of date.
    Debug.Log("Our opponent is using an older client.");
    return;
} else if (messageVersion > _protocolVersion) {
    // Our opponents seem to be using a newer client version than our own! 
    // In a real game, we might want to prompt the user to update their game.
    Debug.Log("Our opponent has a newer client!");
    return;
}

Build your game and update it on the one device you updated earlier; launch a multiplayer game and you’ll see that your two clients still won’t talk to each other — but at least now you know why! :]

Your newer client recognizes that its protocol version is out of sync with the other clients and it’s rejecting the message for that reason instead of having to rely on just the message length. You’re only displaying this in your console log for now, but in a real game you could display this message to the player and give them a better understanding of why their two games aren’t talking to each other.

This is a rather simple strategy for dealing with out of sync clients. If you wanted to be more flexible, you could add some degree of backwards compatibility into your clients. For instance, you could still accept messages based on protocol version 1; you simply wouldn’t check for an auto-incrementing message number.

With backwards compatibility built into your game, your clients could declare at the start of a match what protocol version they’re using and how backwards compatible they are.

Agreeing on Protocol, part 1

If there’s a “lowest common protocol” version that all of your clients support, they could agree to use that one, and your different-versioned games could still play with one another!

Agreeing on Protocol, Part 2

Supporting multiple protocols can be a lot of work; sometimes the benefits of a breaking change outweigh the need for backward compatibility. It’s up to you and your game’s design to decide if this approach makes sense to you.

Another alternative is to set the game’s auto-match variant equal to your protocol version, which would ensure that only players of the same client version could be auto-matched to play with each other. This would certainly minimize this problem — but it would also decrease the number of possible competitors in each pool. No one ever said this multiplayer thing was easy! :]

Load the newest version of your game onto both devices; everything should be working just about the same as before, although the game will now smartly discard any message received out of order — and you’re also prepared for a world where people might have different client versions.

Cancelling Auto-Matching

What if your player gets tired of waiting for people to play with and wants to play a single-player game instead? To solve that, you will add a cancel button to the auto-match dialog.

Leaving a room you’re waiting in is as simple as calling Google Play Games platform’s LeaveRoom(), which you’ve already implemented as LeaveGame() in MultiplayerController.

Open MainMenuScript.cs; find the following code in OnGUI():

if (_showLobbyDialog) {
	GUI.skin = guiSkin;
	GUI.Box(new Rect(Screen.width * 0.25f, Screen.height * 0.4f, Screen.width * 0.5f, Screen.height * 0.5f), _lobbyMessage);
}

…and replace it with the following:

if (_showLobbyDialog) {
	GUI.skin = guiSkin;
	GUI.Box(new Rect(Screen.width * 0.25f, Screen.height * 0.4f, Screen.width * 0.5f, Screen.height * 0.5f), _lobbyMessage);
	if (GUI.Button(new Rect(Screen.width * 0.6f, 
                                Screen.height * 0.76f, 
                                Screen.width * 0.1f, 
                                Screen.height * 0.07f), "Cancel")) {
		MultiplayerController.Instance.LeaveGame();
		HideLobby();
	}
}

You call HideLobby() right away instead of calling it from a listener method in your MultiplayerController. In many cases, when you leave a room in the middle of setting it up you won’t get a callback in the form of OnLeftRoom(). It’s more likely to surface as an OnRoomConnected(false) error.

Build and run your game; start a multiplayer game, then cancel it and watch that dialog disappear!

Note: Don’t be tempted to start and cancel games in rapid succession, or you may find yourself in a weird state where you can’t join any games for about ten minutes or so. Google engineers tell me this is because quickly joining and canceling a room too many times could get you into a weird state where the service thinks your client is broken and starts to rate-limit you.

Personally, I suspect it’s because the service is a little ticked off that you were messing with it and it’s giving you the silent treatment in an insolent kind of way. It’s sensitive like that. :]

Adding More Players

Not everybody has three devices they can develop with, but if you do, then you can easily update your game to permit more than two players!

To start, replace StartMatchMaking in MultiplayerController with the following:

private void StartMatchMaking() {
    PlayGamesPlatform.Instance.RealTime.CreateQuickGame(2, 2, 0, this);
}

The next step is…oh, wait, that’s all you have to do! :] Now you understand why you used those dictionary lists to track opponents.

Build and run your project on three different devices and play to your heart’s content! In fact, there’s nothing to stop you from making this a four-player game…except for the fact that I ran out of car artwork. :]

3 Player Awesomeness

The first two arguments to CreateQuickGame means the game will accept a minimum of two and a maximum of two other opponents. If you changed that call to CreateQuickGame(1, 2, 0, this), your game would try to find a three-player game, but it would start a two-player game if it didn’t find enough opponents in time.

If you’re having a problem getting all three devices into a game, check the two following things — which both happened to me as I wrote this tutorial:

  1. Make sure you’re not signed in with the same account on two (or all) devices.
  2. Make sure you’re signed in with an account that has been added as a tester.
At this point, the actual tutorial part of the ends and the following sections is a fascinating look at various aspects of game networking

Synchronizing Game Start

Depending on your devices and how quickly they receive all the setup information from Play Game services, you may have noticed that not all of your games start at exactly the same time.

This doesn’t end up affecting the fairness of the game, because each individual client reports back the total time it took them to go around the track, regardless of when they actually started the race. But it does mean that it might look like you finished your game earlier than your opponent — only to find out your opponent had a better race time than you.

It’s a bit tricky to get your players to start at the same time, but one potential solution could look like this:

  1. Choose your “host”; the easiest way is to pick the player listed first in your GetAllPlayers() call, which all clients should agree upon since this call returns a sorted list of participant IDs.
  2. Have your host send out ping messages to all the other players. These messages would likely include the local time in milliseconds and, perhaps, a numeric ID.
  3. Have all your other players reply back to these pings. These replies would include the original send time.
  4. When your host receives these replies, it can compare its current time to when it sent the original message, divide by 2 to get the one-way trip time, and it will know approximately how long it takes for a message to make the round-trip from the host to the other player.
  5. Repeat this until your player receives about seven or eight replies from all other players to calculate a decent average ping time.
  6. Once that’s done, you can create a synchronized start by looking at the longest ping time, adding a little buffer, and then sending out a message to each player telling them to start.

For example, suppose you’re the host and you find it takes 200 ms to send a message to Player 1 and 25 ms to Player 2. You could send a message to Player 1 saying “start the game 50 ms from now”, a message to player 2 saying “start the game 225 ms from now” and a message to yourself saying, “Start the game 250 ms from now”. That would make all three players start at approximately the same time.

Here’s a diagram showing this process:

Synchronized Start

One complication is that this kind of timing test only works if you’re using unreliable messaging; the timing of reliable messaging varies too much to be of use. So your clients need to send a “Yeah, I got the game start message; stop bothering me about it” reply to your host, and your host needs to keep telling the other players to start (adjusting the start time offset along the way) until the host has confirmed that everybody received the start message.

That’s a lot of work just to ensure all players start at the same time. However, if there’s enough interest in seeing a fully coded solution, let us know and perhaps we can add it as a follow-up tutorial.

Synchronizing the State of the Game World

(or, the Big Dark Secret About Multiplayer Tutorials)

You’ve probably noticed that those crates on the track don’t match up on each device — which means you can see your opponents drive straight through a crate:

The car drove right through the crate! It must be a g-g-g-g-ghost car!

The car drove right through the crate! It must be a g-g-g-g-ghost car!

You might also notice that an opponent car seems to get stuck on nothing at all — perhaps a particularly dense patch of air? :] And come to think of it, why aren’t the cars colliding with each other? The more you think about it, the weirder this game gets…

Congratulations — you’ve hit upon the big deep dark dirty secret of multiplayer tutorials. Have you noticed that nearly all multiplayer tutorials you see involve racing games? And not just that, but racing games where nothing you do really affects the other player?

That’s because trying to write a game where all clients agree on the shared state of the world is, to use a technical term, REALLY HARD. Imagine you’re playing a game where both your car and your opponent’s car are heading towards the same crate.

Due to network latency, you see your car run into the crate a few milliseconds before your opponent, but your opponent sees that she runs into the crate first. Just a few milliseconds difference means the two player worlds end up in drastically different states:

One minor difference, two drastically different game worlds

One minor difference, two drastically different game worlds

Generally, games like this fake it by making all players participate in what ends up being a collection of single player games, and then simply report the end result of those single player games to each other. Puzzle fighter games, in particular, work this way. Of course, any real-time games that rely on slow turns — like the theoretical poker example — don’t need to deal with this problem at all.

But you’ve surely played fast-paced, online multiplayer games where players do interact with each other in a shared world. How do they do it, and why aren’t we covering this here?

There are a lot of different strategies to solve this REALLY HARD problem, but here are two of the most common:

Strategy 1: Using Discrete Turns

In real-time strategy games, the game itself is defined as a series of very fast, discrete turns. You assume that when a player makes a move, it won’t get carried out until a few turns later. For instance, if you decide to move your tank on turn 1056, it won’t start moving until turn 1059, which should be enough time for your “Hey, Todd wants his tank to start moving on turn 1059” message to make it out to all of the other opponents.

On your device, some user interface sleight-of-hand along with some audio feedback (“Rolling out, commander!”) would hide the fact that your tank doesn’t move right away. This way, each client is able to perfectly recreate the state of the world locally by having everybody perform the exact same actions on the exact same turns, as illustrated below:

RTS Strategy

Strategy 2: Using a Server to Sync

In first-person shooter games, a separate server determines the state of the world. Sometimes — particularly in mobile games — this “server” is just a player’s device. When a player decides to take an action, such as jumping, your client sends a “Start jumping” message to the server. The server then responds back with a message of what actually happened when you started jumping, and your game then updates to reflect the reality as reported by the server.

This solves your multiplayer divergent worlds problem, because the server is the one true source of what’s going on in the world. But it would be a terribly laggy experience to wait for a response from the server before you can start jumping. Many games solve this by taking a really good guess as to what they think should happen when you start jumping, but then alter your game state as required when they gets the definitive result from the server.

Here’s a quick diagram showing this strategy in action:

Client / "Server"  strategy

Keeping the world in sync in a multiplayer game is a fascinating topic, and there’s a lot more detail than can be included here. If you want to delve deeper into this topic, I highly recommend reading 1500 Archers on a 28.8 Network by the developers of Age of Empires, and What Every Programmer Needs to Know About Game Networking by Glenn Fiedler.

These are really interesting problems to solve, but as you can see, they’re also quite complicated with lots of thorny issues. And just like the “synchronized start” problem, they are best saved for a future tutorial.

Where to Go From Here?

Congratulations! You now have an awesome multiplayer game that, in spite of its simplicity, is a lot of fun to play against one or more opponents. There are still a few outstanding issues to solve in your game, such as the synchronous world issue described above, but if there’s enough interest, we could look into discussing some of these in a future series.

In the meantime, now that you’ve got the basics down for a multiplayer game, try making your own! Maybe there are some gameplay changes you can add to Circuit Racer to make it more fun. How about giving your player a power-up that messes with their opponent if you can make it through an entire lap without hitting a crate?

You can also take what you’ve learned here and apply it to your own game. Even a simple game like a single-player puzzle game can have a fun multiplayer component if you turn it into a simple contest of “who can reach the target score first?” If you do make something using what you’ve learned here, let us know — we’d love to hear about it in the discussion below!

Todd Kerpelman

Todd Kerpelman used to be a halfway decent game designer, until he tricked Google into hiring him on as a Developer Advocate, and now he spends his time making YouTube videos. He figures it's just a matter of time until they discover he doesn't know what he's talking about, so he's stockpiling snacks in the meantime.

Other Items of Interest

Save time.
Learn more with our video courses.

raywenderlich.com Weekly

Sign up to receive the latest tutorials from raywenderlich.com each week, and receive a free epic-length tutorial as a bonus!

Advertise with Us!

PragmaConf 2016 Come check out Alt U

Our Books

Our Team

Video Team

... 19 total!

Swift Team

... 15 total!

iOS Team

... 33 total!

Android Team

... 15 total!

macOS Team

... 10 total!

Apple Game Frameworks Team

... 11 total!

Unity Team

... 11 total!

Articles Team

... 12 total!

Resident Authors Team

... 15 total!