Synchronization and hit detection in a slow-projectile based game
Table of contents
For my first ever Unity project I made a fast-paced 1v1 dodgeball game. After finishing the project I had aspirations to transform the game from local multiplayer to online multiplayer. I very quickly found out this required me basically rebuilding the entire project from scratch, and decided it was too tall of a task at the time.
Now that I have the opportunity to do research, I return to this project with new-found basic knowledge of multiplayer programming. Since I’m interested in creating more multiplayer games, this research will function as my stepping stone towards that goal. If you have a similar goal, reading this could be interesting for you! Big questions I want to answer are:
- How do I synchronize the positions of players?
- How do I synchronize projectiles so they are in the exact same place on different clients, regardless of lag, and should they?
- How do I make hit detection feel good, so when a player dodges a ball, it doesn’t look like it hits on someone elses screen?
- How do I make the game fair and not exploitable by lagging players?
- How do I combat cheating/hacking?
I used this machine gun of multiplayer questions to find the essence of what I want to research. Combining that essence with the time scope of 3.5 weeks, I arrived at my research subject: “Synchronization of players and projectiles, hit detection, and lag compensation.” More concretely; I want to make the original project’s core mechanics work in multiplayer, implement player and projectile synchronization as accurate and with as little concessions as possible, and answer the question of where and how I should do hit detection. Having the core mechanics already defined provides me with a good starting point.
In the second chapter I describe how I set up the multiplayer environment using Photon. The third chapter I go over how I implemented the core mechanics working in multiplayer. Then in chapter four I explain how to synchronize the projectiles. In the fifth chapter I go over my efforts to improve the hit detection. And in the conclusion I reflect and discuss the future of this project.
2. Connecting players
The first goal was to set up the multiplayer environment, connect multiple players to the same game and synchronize their positions over the network.
Choosing a framework
Rather than programming all the multiplayer back-end myself, I wanted to use a framework. After all, my research isn’t about creating the infrastructure. As I had some experience interacting with Photon and connecting players in a previous project, the choice was made quite quickly. However, reading into the alternatives does highlight the differences, strengths, and weaknesses that I’ll have to take into account.
The biggest difference between Photon and other frameworks is its model. It follows a client-server model with the disadvantages of a peer-to-peer model: All network communication is done using dedicated servers, however Photon’s servers function mostly as relays, and can’t be used authoratively. The only entity that can run some authoritative logic is the client assigned as masterclient. This poses anti-cheat challenges, however I decided those were outside of my research scope, and I should use the tool I’m most comfortable with.
Photon room system
To connect players, I made use of Photon’s built-in lobby system. All I had to do was create the UI to connect to a room and then introduce a ready-up system to bring the players to the game scene.
Once all the players are in the game, I start having to get into the multiplayer side of things. Firstly, how do I spawn a player that is visible to all clients, and secondly, how do I share player movement over the network.
To start with spawning a player: I need a consistent way to reference an object between two clients. Luckily Photon has the PhotonView component, which attaches an ID to every object that I instantiate over the network. That means that if client 1 tells client 2 “Hey the object with ID 2 did something”, they both know they are talking about the same object.
Knowing PhotonView covers the identification, all I need to do is actually instantiate the player. Only where normally I instantiate using
GameObject.Instantiate now I instantiate using
PhotonNetwork.Instantiate. It’s that easy. Similarly, when I want to destroy the object, need to destroy it using
PhotonNetwork.Destroy rather than
Basic player movement
The only thing that’s missing is moving the player. First of all, with multiple player characters on screen, how do I only move mine? The PhotonView component I attached to the player earlier comes in handy, as it has a
PhotonView.IsMine property that I can use to check which player is ours. That way, when I want to handle input in
Update(), I can return if the player isn’t ours.
Secondly I need to share the movement of your player with other clients, and vice versa. Photon has a neat interface called IPunObservable. This component allows us to use a stream to write and read information at a constant rate. I can use this to write and read the player position, synchronizing its movement over the network. The rate at which this stream writes and reads – the serialization rate – is by default 10 times per second, which I’ll leave it at.
As you can see, the movement is really choppy because the serialization rate is way lower than the rate at which
update() runs. I didn’t smooth the player movement until later because it wasn’t a priority, but for continuity sake I’ll describe next how I made it smooth.
Smooth player movement
After looking into Photons documentation about lag compensation, I decided to implement what they suggested. Instead of setting the
transform.position to the received position, I introduced a variable
networkPosition. This position represents the position read from the stream, plus a form of lag compensation; predicting where the networkPosition will be, based on current movement.
This solution helps a lot if your player has movement acceleration, as the prediction can actually impact where you move. It doesn’t have a large effect on my game though, as my player doesn’t have any movement acceleration. This meant that my player always lagged behind where the player actually was, which will impact hit detection (see chapter 5). I left the lag compensation in however, as I probably will implement movement acceleration in the future.
3. Basic throwing and getting hit
Now that the multiplayer environment is set up, it’s time to introduce the core mechanics of throwing and hitting and make them work with multiplayer.
Throwing a ball
Similar to how I spawned players, I can also spawn balls. I implemented that when you press SPACE, a ball is instantiated on your position and flies in the direction that you assign using the arrowkeys. For now, the movement of the ball is local, and not synchronized. I’ll go over how I worked on that in the next chapter.
Picking up a ball
For a first version, when you get hit by ball, you pick it up. To make this I need some way to send other clients a message like “Hello I am the player with ID 2 and I got hit by this ball with ID 5!”. Photon offers two ways of sending a message to other clients. One is using RPCs (remote procedure calls), and the other using RaiseEvent. In short, a remote procedure call is telling the other clients to execute a specific method with given parameters. RaiseEvent is triggering an event with given parameters, and having other clients listen to it wherever they want. Read more about the difference here.
Because I prefer separating the networking code from classes like Ball and Player, I chose for events. I called the event
pickUpBallEvent. Because my balls and players have colliders, I can simply check when these two collide, and handle the pickup there. That looks like this:
In my first version, I made a very poor decision for hit detection: Checking for collision on the side of the ball thrower rather than the one who needs to dodge the ball. In chapter 6 I take a better look at hit detection.
When a player picks up a ball, I want to display on the player what balls they have picked up. I did this by raising the pickup event, and then locally for every player displaying that the ball was picked up. Here’s what the event raising and listening looks like now:
Distinction between ‘hit’ and ‘pickup’
The last core mechanic I wanted to implement was the distinction between getting hit by the ball, and picking up the ball. Currently, when a ball flies towards you and hits you, you instantly pick it up. I would rather have the player only be able to pick up the ball when it is stationary on the ground. If the ball is flying through the air, the collision should cause a ‘hit event’, bouncing the ball off the player and it coming to a stop, then allowing for a pickup.
For this I had to introduce a second event called
hitBallEvent. The code actually looks the exact same, only what happens locally is different; Instead of deleting the ball and displaying the ball on the player, I just reflect the direction of the ball and have it come to a stop.
4. Synchronizing balls
The core mechanics are implemented, however the two connected clients don’t quite see the information at the same time. Lag causes balls to quite litterally lag behind compared to where they should be. In theory however, the trajectory and speed of a ball is 100% predictable, so I should be able to synchronize it perfectly.
Introducing time diff
If I throw the ball at time 0.000, and it takes 0.520 seconds for this message to arrive at the other client, this client can calculate where this ball should be at 0.520, and have the ball continue from there. So to synchronize the ball I need to:
- Calculate the time diff between throwing the ball and my client receiving the throw message.
- Simulate where the ball should be after that time diff.
- Have the ball proceed from there.
Calculating the time diff is quite easy since Photon supplies us with
PhotonNetwork.ServerTimestamp, a variable that is the exact same on all clients. This means that when I instantiate a ball, I just need to pass this timestamp, and calculate the difference when the instantiation happens. It took me a while to figure out Photon has a wonderful callback for instantiation, ironically named
IPunInstantiateMagicCallback. I used this to calculate the time diff and trigger the
Throw() method. That’s where the simulation will have to take place.
Simulate using time diff
My initial thought for simulation was that it was going to be easy: I know how long the ball has travelled, I know how fast it went; Simple calculation.
After testing, I very quickly found out that I overlooked collision! If the ball would have hit a wall during the time diff, the simulation attempt above would simply move the ball through and beyond the wall. Instead, I have to incrementally simulate the ball moving from point A to B, along the way checking for collision, hence changing point B. This revealed a bigger issue, namely that Unity’s collision systems (colliders, OnCollision events) only update once per update tick. If I for instance want to check the collision 50 times in one update tick with small increments of movement, I can’t use the positions of Unity colliders, or rely on OnCollision events to trigger. Hence I had to make my own ball-collision:
Even though this provides me with the simulation that I need, I found out later that this doesn’t fix everything: Collisions between a ball and a player still only happen once per tick, meaning it’s possible to dodge a ball by standing very close to the thrower. Though a bummer, it’s something I can try and fix in the future.
Resync on hit
I could apply the same simulation when a ball is thrown to when a ball hits a player, to resynchronize it’s position to where it actually should be after the hit. When a ball hits a player, it linearly loses speed to 0 over 1 second. I did this so I can easily resync the ball after fixing a high school math problem:
A ball goes at 18 km/h. It linearly loses all its speed over 1 second. A second client has 0.2s of lag. How far has the ball travelled after 0.2s, and what is it’s current speed?
If you’re dying to know the answer, it travelled 0.9m, and its new speed is 14.4 km/h. I used that to ‘perfectly’ synchronize the ball after it hit a player. I have perfectly in quotation marks because I’m not so happy with reaching the goal of perfectly resynchronizing the ball and having to pay with a concession: a large blink of the ball. Find a better solution for this – synchronizing the ball after an unpredictable bounce – is also a topic for future experimentation.
Another synchronization issue I had to resolve was that if the application hangs in any sort of way, the awesome simulation using time diff I implemented is basically useless. At this stage throwing a ball was like launching a rocket without any onboard controls: if anything happens along the way; tough luck. That wasn’t really acceptable so I went back to the drawing board.
My theory was to use the read & write stream to receive it’s actual position, and then use the simulation I already made to calculate where it should be in the current tick. I ran into issues however. The ball would jitter around continuously, slightly forward and backwards. My theory is that it’s caused by the difference between the read & write rate (10/s) and the fixed update rate (50/s), because the time diff is different every read-tick and doesn’t equate to an exact amount of fixedUpdateTicks, causing the ball to jitter continuously. It seemed like a poor solution, especially considering that the desync issue it was meant to fix was very rare. I still think it could work, but it would require more experimenting time. In the next feedback session, someone also asked why I didn’t instead store the throwtime and position, and re-simulate whenever desync happens. This is also a solution I would like try in the future.
Instead, I fixed the problem by only resynchronizing if the desync was larger than a certain distance. E.g. if the ball is more than half a meter away from where the read position is, snap it to the read position, plus lag compensation. Similar to the ‘resync on hit’, it technically fixed the problem, however it comes with a concession: The ball is sometimes a bit desynced, and when the ball has desynced slightly too much, it blinks.
5. Better hit detection
Now that balls are synchronized over the network, I can focus on hit detection. I mentioned in chapter 3 that current hit detection is done locally on the client of the player that threw the ball. I knew that wasn’t going to feel good, and after testing I can safely say it indeed doesn’t.
Where to do hit detection?
I want hit detection to be fair and feel good. There’s a few options:
- On the client that threw the ball
- On the client of the player that is trying to dodge
- On the masterclient
Option 1 feels terrible because as the player trying to dodge, on your screen it might look like you dodged, but the thrower registers a hit. Because the projectiles are relatively slow, I think it’s more important that it looks good for the player trying to dodge. For a similar reason, option 3 would be bad aswell, as that would cause 1 of the clients to have hit detection that feels good, but the rest would practically experience option 1.
New hit detection
Hence, I’m going to implement option 2: Hit detection on the client of the player that is trying to dodge. Implementing this was mainly a case of changing the OnCollisionEnter method, and changing who the hit events would be sent to, as the local player doesn’t need to receive the events.
An issue I ran into was that the ball can only be deleted by the person that instantiates the ball, meaning the thrower. This meant that when the player collides with a ball, the ball wouldn’t disappear until the thrower would receive the hitEvent. My simple fix for this was to make the ball invisible on impact, waiting for it to be removed.
Finished hit detection?
No. No, it’s not finished. In the future I would like to experiment with hit detection. What if I check hit detection local on all clients to get rid of desync artifacts surrounding hits? Can I reconcilliate if the ball didn’t actually hit?
An even bigger fish that I didn’t tackle is cheating and hacking. When I was choosing what to research, I mentioned cheating and hacking. I got feedback early on confirming my thought that cheating and hacking is a subject large enough for its own research. It is however something I will definitely look into in the future.
That’s currently it for this project. I’m happy with the progress I made. Looking at my original goal of using this research as a stepping stone towards making more multiplayer games, I definitely feel like I succeeded. Looking at it more critically, I succeeded in making the original core mechanics work in multiplayer, and was able to answer the question of where to do hit detection. However I wasn’t able to implement player and projectile synchronization accurate and with few concessions. The last part – accurate and with few concessions – was quite optimistic. My thought was that since the mechanics were very simple, I’d be able to get near-perfect synchronization. In reality, combining my lack of initial experience with the time limit of 3.5 weeks lead to me having to do quite some concessions, leaving many questions for further research. Even though I have mixed feelings about the amount of polish and completeness of the final product, I still think the research was a success.
As I mentioned countless times throughout this report, it doesn’t stop here for this project. There is a lot of things I would like to do in the future:
- Polish projectile synchronization.
- Player movement acceleration improving synchronization.
- Minimizing hit detection impact on (de)synchronization.
- Simulating ball physics and collision perfectly.
- The effect of lag compensation solution on competitive fairness.
- How to combat cheating/hacking.