Creating a P2P game from the ground up in Unity.
Made by Johannes Melis
- The P2P Network
- Sending/receiving data
- Converting Objects to Bytes
- Packet management
- Lobby Menu Connections
- Syncing objects
- Joining without setting up a port forward
- Sending data reliably
- Syncing players
- Syncing objects
- Syncing enemies
- Syncing projectiles
I set out to create a p2p network for a game within unity. I wanted to build the network from the ground up without the use of any existing multiplayer frameworks. The idea for this project was to make a game in which at least 3 players could join and play the game.
By doing this project I want to learn how to manage packets and learn how to handle data in a multiplayer game.
The inspiration for the game came from the Trine series, 3 player co-op solving puzzles in a linear story with 3 different characters. The players would have to work together with the differing abilities of each character to overcome the puzzles.
The goal for my own game was to create interactions between the players and the environments such as enemies, objects, projectiles and objectives. To realise this I want to look into creating a network with the ability to synchronize objects over the internet.
The first step was finding out how I was going to structure the network code to make it manageable. For the network I already knew I wanted to create a p2p network as this would be easier to set up and this would prevent users from having to host a dedicated server. As one peer could act as a host and control certain aspects of the game.
So for the network I chose to create a non authoritative peer network, with this decision clients will do their own calculations and send that data as “authoritative”, so data would not be checked on trustworthiness, thus not preventing cheating.
But in a game where you will be mostly playing with two friends who will have to share their ip to join in a game, as no matchmaking servers are going to be available.
Most of the things on screen are locally simulated and sent to other peers, only the enemy behaviour and object synchronization is done on the host peer.
The beginnings of the project started with how to send and receive data over the network. For this I could easily use the UdpClient, as this would allow me to send data to a given ip address as bytes. With this client I could start testing with sending and receiving data.
The P2P Network
The test I started with was opening two clients which would listen and send on two different ports. With these two ports open I could send a string to myself on another instance on different ports.
The clients had to run on two different threads as the receiving side would otherwise block the whole process unless a packet was received.
For the sending side it was also better to have it run on another thread, so data can be sent as fast as possible to prevent waiting for the next frame.
To send data a queue of packets is formed which can be send
A packet contains information about when it was created, what type of packet it is and from what peer it was.
The receiving thread also throws all the received packets in a queue which can be used on the main thread to use the data and change values of synced objects.
Converting Objects to Bytes
The system needed to be able to easily convert objects to an byte array. To do this I added a converter script which would convert public properties in a type to bytes.
The main problem was that not the whole class should be converted to bytes and sent. To prevent this from happening the custom “SyncData” attribute was added to tag properties which had to be converted.
For the actual conversion I mostly used the “BitConverter” class.
To manage the incoming packets and execute the packets within the code. The packets that are stored in the receiving queue are dequeued one at a time until it is empty, this is done every frame. The packet event throws a new event with the packet as reference. Anywhere where the data is needed can assign a function to the event. The packet type is used to filter where in the code the packet is used to unpack the data within.
This allows for simple scaling, by just adding packet types to the list the functionality could be extended.
The packet value is also important to filter, because it determines what should be done with the data. The values determine if it is a confirmation message, it needs to delete, add or change something.
Lobby Menu Connections
When a peer joins via an ip and port, the message gets sent to the host. The host receives the peer and assigns it a new id. When it has been given an id the whole peer network is sent over to all connected peers. On the connecting peer the list is received and the clients list is overridden by the list given, so now the id of this peer is the same over the whole network making it easy to synchronize objects assigned to this peer, such as players, any interaction or projectiles.
Disconnecting peers send out a signal that they want to disconnect and each peer changes the peer list accordingly. The disconnect event is also thrown with the id of the peer that is disconnecting. This is also called when the player itself is disconnecting.
When the host disconnects, a new host is assigned to the player with the lowest remaining id. So the game will always be able to continue even when the host disconnects.
Syncing objects is done by adding a “ISyncObj” interface which includes a byte id and two methods for converting from object to bytes and vice versa.
This would allow the bytes to be converted and applied to the correct object. The object can be found by the id it is assigned. This id is the same over the whole network.
To manage multiple syncable objects I created a special implementation for a list to manage unpacking and syncing a complete list.
This also helps with managing lists of objects as the list could be managed on the id of the objects.
Joining without setting up a port forward
An issue I had with the setup had was that connecting to another peer in another network would require a port forward. This can be quite a hassle sometimes, as port forwarding can sometimes be blocked by certain providers and modems. So to solve this I had to get the receiving and sending clients on one port, this would allow the modem to send data to the correct machine. As the packet the machine sent would get a return packet from the same ip and port address.
To do this I needed to create the UdpClient and assign it to the same port later on when the client has been allowed to bind multiple clients to the port. To allow this the settings have to be set on the socket it is going to bind to.
Sending data reliably
To solve sending data reliably, I had to set up a way for confirming messages and managing send packets.
To do this I created a packet wrapper which would be stored in a list to check for a timeout value. If the packet was not confirmed by the other peer within that time the packet would be resend. The packet also has a packet failure and packet success callback to make it easier to handle successfully joining a lobby and failing to join.
If a packet does not get confirmed in 3 tries the client starts pinging the client for connectivity, if it doesn’t reply to that 3 times, the peer will be disconnected from the network.
On the receiving end the packet would be stored in a received packet list in which duplicate packets could be handled, as confirming packet could also get lost and would have to be resend.
Syncing up the players requires the position sent over the network to which the system can interpolate the new network position. After doing some tests using rigidbody and the use of extrapolation after interpolation resulted in the use of the rigidbody method, as this was the most reliable with the interactions of physics objects.
This however does occasionally cause some weird slight jitter with the problem probably lying in the interpolation of the rigidbody and the movement of the camera.
The attacks of the players are separately sent over the network, not by converting an object.
The position and direction of an attack are sent and are simulated on the client side of the system. The type of the attack is also sent to check whether it is a primary or secondary attack.
Note: The players all have the same attack which is shooting a projectile. But this will be used in the future development for different attack types.
Physics objects are quite hard to sync up perfectly over a network as these require the simulations of the two clients to be identical. But the rigidbodies in unity are not deterministic.
To sync up the physics for all players, the system should predict where the object should be in the future to check if that is valid with the position given by the host. As the host simulates the interactions with the objects and sends them to all the peers. When the packet arrives, the client is in the future and the prediction has to compensate with the time difference and the interval of sending physics info.
If this prediction is correct, the local physics can keep going. But when the difference between the two positions is not within the margin it snaps it back to the position of what the host sees.
This margin changes depending on the acceleration and velocity of the object. As the object accelerates the margin is large, and becomes smaller when it is at constant velocity. If the object is stationary the margin becomes extremely accurate.
This system however has some drawbacks. For example, when an object is pushed over a ledge and falls down. The object falls down on both clients, but on one the object has more horizontal velocity then the other, causing the object to fall further on the host. When this happens the clients prediction is based on what the host says. The object is snapped back when it hits the ground as the object has not fallen in the same way the host said it did.
The enemies in the current state can not move and are fixed turrets, targeting players who come too close.
The enemy behaviour is all done on the host peer. The host checks if one of the players is within range of the enemy, it sends out a signal that the target of the enemy has changed. The enemy starts shooting projectiles at the current position of the player.
Note: Enemies do not check if they can actually “see” the player. They will start shooting when in range not taking in account if the player is behind something.
The projectiles of the player are simulated locally on the players machine. When a projectile is spawned it checks if the id of the entity it belongs to is the same as the local entity. When the projectile hits something the despawn event is called with an id of the entity the projectile hit. The damage can then be done on all clients.
The projectiles of the enemies are all simulated on the host machine, so any interaction between the other players is about 50ms late.
Each projectile is spawned at the exact location where it is on the client that shot it. As the lag compensation fixes the gap between the time it was shot and the time it was processed.
The final product is a small “game” which showcases the system and how the peer network works and what functionality it has. The product contains a lobby menu with character selection and a test level which can be played with 3 people max. The level has objects which can be moved around, enemies which will shoot at any player close enough and coins can be collected.
Two main problems still need to be addressed.
The first one is the way packets arrive at the peers. As the order of packets can get mixed up over the internet, it could cause problems with the synchronization of the system. By allowing packets with id’s lower than the latest received packet, the system could receive old and potentially invalid data. If this packet is then processed, it could cause weird sync issues. This could be solved by checking the id of the packet and discarding it when it is lower then the latest one. But only when the types of packet are the same, as discarding a new shoot projectile command over a movement packet would not make sense.
Physics objects that snap when the simulation is not the same can be quite jarring. To fix this the simulation of physics needs to be deterministic. Which can be done with deterministic lockstep and syncing up the rigidbody simulations. By sending only the inputs of the players instead of position and velocity. If the simulation of rigidbodies is simulated in the same way as the other peers it could help with the differences the system now has to deal with.
Bevilacqua, F. (2013, August 12). Building a Peer-to-Peer Multiplayer Networked Game. Game Development Envato Tuts+. https://gamedevelopment.tutsplus.com/tutorials/building-a-peer-to-peer-multiplayer-networked-game–gamedev-10074
Entity Interpolation – Gabriel Gambetta. (n.d.). Entity Interpolation. Retrieved October 2020, from https://www.gabrielgambetta.com/entity-interpolation.html
K. (n.d.). UdpClient Class (System.Net.Sockets). Microsoft Docs. Retrieved 2020, from https://docs.microsoft.com/en-us/dotnet/api/system.net.sockets.udpclient?view=netcore-3.1
potential use in the future
Deterministic Lockstep. (2014, November 29). Gaffer On Games. https://gafferongames.com/post/deterministic_lockstep/
source code: https://gitlab.fdmci.hva.nl/melisj/multiplayer-game