Advertisement
Implementation

Building a Peer-to-Peer Multiplayer Networked Game

by

Playing a multiplayer game is always fun. Instead of beating AI-controlled opponents, the player must face strategies created by another human being. This tutorial presents the implementation of a multiplayer game played over the network using a non-authoritative peer-to-peer (P2P) approach.

Note: Although this tutorial is written using AS3 and Flash, you should be able to use the same techniques and concepts in almost any game development environment. You must have a basic understanding of networking communication.

You can download or fork the final code from the GitHub repo or the zipped source files.


Final Result Preview

Network demo. Controls: arrows or WASD to move, Space to shoot, B to deploy a bomb.

Art from Remastered Tyrian Graphics, Iron Plague and Hard Vacuum by Daniel Cook (Lost Garden).


Introduction

A multiplayer game played over the network can be implemented using several different approaches, which can be categorized into two groups: authoritative and non-authoritative.

In the authoritative group, the most common approach is the client-server architecture, where a central entity (the authoritative server) controls the whole game. Every client connected to the server constantly receives data, locally creating a representation of the game state. It's a bit like watching TV.

Authoritative implementation using client-server architecture.

If a client performs an action, such as moving from one point to another, that information is sent to the server. The server checks whether the information is correct, then updates its game state. After that it propagates the information to all clients, so they can update their game state accordingly.

In the non-authoritative group, there is no central entity and every peer (game) controls its game state. In a peer-to-peer (P2P) approach, a peer sends data to all other peers and receives data from them, assuming that information is reliable and correct (cheating-free):

Non-authoritative implementation using P2P architecture.

In this tutorial I present the implementation of a multiplayer game played over the network using a non-authoritative P2P approach. The game is a deathmatch arena where each player controls a ship able to shoot and drop bombs.

I'm going to focus on the communication and synchronization of peer states. The game and the networking code are abstracted as much as possible for the sake of simplification.

Tip: the authoritative approach is more secure against cheating, because the server fully controls the game state and can ignore any suspicious message, like an entity saying it moved 200 pixels when it could only have moved 10.

Defining a Non-Authoritative Game

A non-authoritative multiplayer game has no central entity to control the game state, so every peer must control its own game state, communicating any changes and important actions to the others. As a consequence, the player sees two scenarios simultaneously: his ship moving according to his input and a simulation of all other ships controlled by the opponents:

Player's ship is controlled locally. Opponent ships are simulated based on network communication.

The player's ship's movement and actions are guided by local input, so the player's game state is updated almost instantly. For the movement of all the other ships, the player must receive a network message from every opponent informing where their ships are.

Those messages take time to travel over the network from one computer to another, so when the player receives an information saying an opponent's ship is at (x, y), it's probably not there any more - that's why it's a simulation:

Communication delay caused by the network.

In order to keep the simulation accurate, every peer is responsible for propagating only the information about its ship, not the others. This means that, if the game has four players - say A, B, C and D - player A is the only one able to inform where ship A is, if it got hit, if it fired a bullet or dropped a bomb, and so on.  All other players will receive messages from A informing about his actions and they will react accordingly, so if A's bullet got C's ship, then C will broadcast a message informing it was destroyed.

As a consequence, each player will see all other ships (and their actions) according to the received messages. In a perfect world, there would be no network latency, so messages would come and go instantly and the simulation would be extremely accurate.

As the latency increases, however, the simulation becomes inaccurate. For example, player A shoots and locally sees the bullet hitting B's ship, but nothing happens; that's because A's view of B is delayed due to network lag. When B actually received A's bullet message, B was at a different position, so no hit was propagated.


Mapping Relevant Actions

An important step in implementing the game and ensuring that every player will be able to see the same simulation accurately is the identification of relevant actions. Those actions change the current game state, such as moving from one point to another, dropping a bomb, etc.

In our game, the important actions are:

  • shoot (player's ship fired a bullet or a bomb)
  • move (player's ship moved)
  • die (player's ship was destroyed)

Player actions during the game.

Every action must be sent over the network, so it's important to find a balance between the amount of actions and the size of the network messages they will generate. The bigger the message is (that is, the more data it contains), the longer it will take to be transported, because it might need more than one network package.

Short messages demand fewer CPU time to pack, send, and unpack. Small network messages also result in more messages being sent at the same time, which increases the throughput.


Performing Actions Independently

After the relevant actions are mapped, it's time to make them reproducible without user input. Even though that's a principle of good software engineering, it might not be obvious from a multiplayer game point of view.

Using the shooting action of our game as an example, if it's deeply interconnected with the input logic, it's not possible to re-use that same shooting code in different situations:

Performing actions independently.

When the shooting code is decoupled from the input logic, for instance, it's possible to use the same code to shoot the player's bullets and the opponent's bullets (when such a network message arrives). It avoids code replication and prevents a lot of headache.

The Ship class in our game, for instance, has no multiplayer code; it is completely decoupled. It describes a ship, be it local or not. The class, however, has several methods for manipulating the ship, such as rotate() and a setter for changing its position. As a consequence, the multiplayer code can rotate a ship the same way the user input code does - the difference is that one is based on local input, while the other is based on network messages.


Exchanging Data Based on Actions

Now that all relevant actions are mapped, it's time to exchange messages among the peers to create the simulation. Before exchanging any data, a communication protocol must be formulated. Regarding a multiplayer game communication, a protocol can be defined as a set of rules that describe how a message is structured, so everyone can send, read, and understand those messages.

The messages exchanged in the game will be described as objects, all containing a mandatory property called op (operation code). The op is used to identify the message type and indicate the properties the message object has. This is the structure of all messages:

tructure of network messages.
  • The OP_DIE message states that a ship was destroyed. Its x and y properties contain the ship's location when it was destroyed.
  • The OP_POSITION message contains the current location of a peer's ship. Its x and y properties contain the ship's coordinates on the screen, while angle is the ship's current rotation angle.
  • The OP_SHOT message states that a ship fired something (a bullet or a bomb). The x and y properties contain the ship's location when it fired; the dx and dy properties indicate the ship direction, which ensures the bullet will be replicated in all peers using the same angle the firing ship used when it was aiming; and the b property defines the projectile's type (bullet or bomb).

The Multiplayer Class

In order to organize the multiplayer code, we create a Multiplayer class. It is responsible for sending and receiving messages, as well as updating the local ships according to the received messages to reflect the current state of the game simulation.

Its initial structure, containing only the message code, is:

public class Multiplayer
{
	public const OP_SHOT 		:String = "S";
	public const OP_DIE 		:String = "D";
	public const OP_POSITION 	:String = "P";

	public function Multiplayer() {
		// Connection code was omitted.
	}

	public function sendObject(obj :Object) :void	{
		// Network code used to send the object was omitted.
	}
}

Sending Action Messages

For every relevant action mapped previously, a network message must be sent, so all peers will be informed about that action.

The OP_DIE action should be sent when the player is hit by a bullet or a bomb explosion. There is already a method in the game code that destroys the player ship when it is hit, so it's updated to propagate that information:

public function onPlayerHitByBullet() :void {
	// Destoy player's ship
	playerShip.kill();

	// MULTIPLAYER:
	// Send a message to all other players informing
	// the ship was destroyed.
	multiplayer.sendObject({op: Multiplayer.OP_DIE, x: platerShip.x, y: playerShip.y});
}

The OP_POSITION action should be sent every time the player changes its current position. The multiplayer code is injected into the game code to propagate that information, too:

public function updatePlayerInput():void {
	var moved :Boolean = false;

	if (wasMoveKeysPressed()) {
		playerShip.x += playerShip.direction.x;
		playerShip.y += playerShip.direction.y;

		moved = true;
	}

	if (wasRotateKeysPressed()) {
		playerShip.rotate(10);
		moved = true;
	}

	// MULTIPLAYER:
	// If player moved (or rotated), propagate the information.
	if (moved) {
		multiplayer.sendObject({op: Multiplayer.OP_POSITION, x: playerShip.x, y: playerShip.y, angle: playerShip.angle});
	}
}

Finally, the OP_SHOT action must be sent every time the player fires something. The sent message contains the bullet type that was fired, so that every peer will see the correct projectile:

if (wasShootingKeysPressed()) {
	var bulletType :Class = getBulletType();
	game.shoot(playerShip, bulletType);

	// MULTIPLAYER:
	// Inform all other players that we fired a projectile.
	multiplayer.sendObject({op: Multiplayer.OP_SHOT, x: playerShip.x, y: playerShip.y, dx: playerShip.direction.x, dy: playerShip.direction.y, b: bBulletType)});
}

Synchronizing Based on Received Data

At this point, each player is able to control and see their ship. Under the hood, the network messages are sent based on relevant actions. The only missing piece is the addition of the opponents, so that each player can see the other ships and interact with them.

In the game, the ships are organized as an array. That array had just a single ship (the player) until now. In order to create the simulation for all other players, the Multiplayer class will be changed to add a new ship to that array whenever a new player joins the arena:

public class Multiplayer
{
	public const OP_SHOT 		:String = "S";
	public const OP_DIE 		:String = "D";
	public const OP_POSITION 	:String = "P";

	(...)

	// This method is invoked every time a new user joins the arena.
	protected function handleUserAdded(user :UserObject) :void {
		// Create a new ship base on the new user's id.
		var ship :Ship = new ship(user.id);
		// Add the ship the to array of already existing ships.
		game.ships.add(ship);
	}
}

The message exchanging code automatically provides a unique identifier for every player (the user.id in the code above). That identification is used by the multiplayer code to create a new ship when a player joins the arena; this way, every ship has a unique identifier. Using the author identifier of every received message, it's possible to look up that ship in the array of ships.

Finally, it's time to add the handleGetObject() to the Multiplayer class. This method is invoked every time a new message arrives:

public class Multiplayer
{
	public const OP_SHOT 		:String = "S";
	public const OP_DIE 		:String = "D";
	public const OP_POSITION 	:String = "P";

	(...)

	// This method is invoked every time a new user joins the arena.
	protected function handleUserAdded(user :UserObject) :void {
		// Create a new ship base on the new user's id.
		var ship :Ship = new ship(user.id);
		// Add the ship the to array of already existing ships.
		game.ships.add(ship);
	}

	protected function handleGetObject(userId :String, data :Object) :void {
		var opCode :String = data.op;

		// Find the ship of the player who sent the message
		var ship :Ship = getShipById(userId);

		switch(opCode) {
			case OP_POSITION:
				// Message to update the author's ship position.
				ship.x  = data.x;
				ship.y  = data.y;
				ship.angle  = data.angle;
				break;

			case OP_SHOT:
				// Message informing the author' ship fired a projecle.
				// First of all, update the ship position and direction.
				ship.x  = data.x;
				ship.y  = data.y;
				ship.direction.x = data.dx;
				ship.direction.y = data.dy;

				// Fire the projectile from the author's ship location.
				game.shoot(ship, data.b);
				break;

			case OP_DIE:
				// Message informing the author's ship was destroyed.
				ship.kill();
				break;
		}
	}
}

When a new message arrives, the handleGetObject() method is invoked with two parameters: the author ID (unique identifier) and the message data. Analyzing the message data, the operation code is extracted and, based on that, all other properties are extracted as well.

Using the extracted data, the multiplayer code reproduces all actions that were received over the network. Taking the OP_SHOT message as an example, these are the steps performed to update the current game state:

  1. Look up the local ship identified by userId.
  2. Update Ship's position and angle according to received data.
  3. Update Ship's direction according to received data.
  4. Invoke the game method responsible for firing projectiles, firing  a bullet or a bomb.

As previously described, the shooting code is decoupled from the player and the input logic, so the projectile fired behaves exactly like one fired by the player locally.


Mitigating Latency Issues

If the game exclusively moves entities based on network updates, any lost or delayed message will cause the entity to "teleport" from one point to another. That can be mitigated with local predictions.

Using  interpolation, for instance, the entity movement is locally interpolated from one point to another (both received by network updates). As a result, the entity will smoothly move between those points. Ideally, the latency should not exceed the time an entity takes to be interpolated from one point to another.

Another trick is extrapolation, which locally moves entities based on its current state. It assumes that the entity will not change its current route, so it's safe to make it move according to its current direction and velocity, for instance. If the latency is not too high, the extrapolation accurately reproduces the entity expected movement until a new network update arrives, resulting in a smooth movement pattern.

Despite those tricks, the network latency can be extremely high and unmanageable sometimes. The easiest approach to eliminate that is to disconnect the problematic peers. A safe approach for that is to use a timeout: if the peer takes more than an specified time to answer, it is disconnected.


Conclusion

Making a multiplayer game played over the network is a challenging and exciting task. It requires a different way of seeing things since all relevant actions must be sent and reproduced by all peers. As a consequence, all players see a simulation of what is happening, except for the local ship, which has no network latency.

This tutorial described the implementation of a multiplayer game using a non-authoritative P2P approach. All the concepts presented can be expanded to implement different multiplayer mechanics. Let the multiplayer game making begin!

Related Posts