Admiral: WW2
1 Intro
Gaming is fun. Strategy games are fun. Multiplayer is fun. That’s the idea behind this project.
In the past I developed some games with the Unity engine – mainly 2D strategy games – and so I thought it is now time for an awesome 3D multiplayer game; or more like a prototype.
The focus of this blog post is not on the game development though, but rather on the multiplayer part with the help of Cloud Computing.
However where to start? There are many ways one can implement a multiplayer game. I chose a quite simple, yet most of the time very effective and cheap approach: Peer-to-Peer (P2P).
But first, let us dive in the gameplay of Admiral: WW2 (working title).
2 Game Demo
2.1 Gameplay
Admiral: WW2 is basically like the classic board game “Battleships”. You’ve got a fleet and the enemy player has got a fleet. Destroy the enemy’s fleet before your own fleet is sunk. The big difference is that Admiral: WW2 is a real-time strategy game. So the gameplay is more like a real-life simulation where you as the admiral can command your ships via direct orders:
- Set speed of a ship (stop, slow ahead, full ahead, …)
- Set course of a ship
- Set the target of the ship (select a ship in the enemy fleet)
Currently there is only one ship class (the German cruiser Admiral Hipper), so the tactical options are limited. Other classes like battleships, destroyers or even aircraft carriers would greatly improve replayability; on the other hand they would need many other game mechanics to be implemented first.
Ships have multiple damage zones:
- Hull (decreases the ship’s hitpoints or triggers a water ingress [water level of the ship increases and reduces the hitpoints based on the amount of water in the hull])
- Turrets (disables the gun turrets)
- Rudder (rudder cannot change direction anymore)
- Engine/Propeller (ship cannot accelerate anymore)
If a ship loses all hitpoints the ship will sink and is not controllable.
2.2 The Lobby Menu
Before entering the gameplay action the player needs to connect to another player to play against. This is done via the lobby menu.
Here is the place where games are hosted and all available matches are listed.
On the right hand side is the host panel. To create a game the host must enter a unique name and a port. If the IP & Port combination of the host already exists, hosting is blocked.
After entering valid infos the public IP of the host is obtained via an external service (e.g. icanhazip.com). Then the match is registered on a server and the host waits for incoming connections from other players.
On the left hand side there is the join panel. The player must enter a port before viewing the match list. After clicking “Join”, a Peer-to-Peer connection to the host is established. Currently the game only supports two players, so after both peers (host and player) are connected the game will launch.
More on the connection process later.
3 Multiplayer Communication with Peer2Peer
3.1 Peer-to-Peer
P2P allows a direct connection between the peers with UDP packets – in this case the game host and player.
So in between no dedicated server handling all the game traffic data is needed, thus reducing hosting costs immensely.
Because most peers are behind a NAT and therefore connection requests between peers are blocked, one can make use of the NAT-Traversal method Hole-Punching.
3.1.1 P2P Connection with Hole-Punching
Given peer A and peer B. A direct connection between A and B is possible if:
- A knows the public IP of B
- A knows the UDP port B will open
- B knows the public IP of A
- B knows the UDP port A will open
- A and B initiate the connection simultaneously
This works without port-forwarding, because each peer keeps the port open as if they would contact a simple web server and wait for the response.
To exchange the public IPs and ports of each peer a Rendezvous-Server behind no NAT is required.
3.1.2 Rendezvous-Server
The Rendezvous-Server needs to be hosted in the public web, so behind no NAT. Both peers now can send simple web requests as if the users would browse the internet.
If peer A tells the server he wants to host a game, the server saves the public IP and port of A.
If B now decides to join A’s game the server informs B of the IP and port of A.
A is informed of B’s public IP and port as well.
After this process A and B can now hole-punch through their NATs and establish a P2P connection to each other.
A Rendezvous-Server can be very cheap, because the workload is quite small.
But there are some cases where Hole-Punching does not succeed (“…we find that about 82% of the NATs tested support hole punching for UDP…”, https://bford.info/pub/net/p2pnat/).
In those cases a Relay-Server is needed.
3.1.3 Relay-Server
The Relay-Server is only used as a backup in case P2P fails. It has to be hosted in the public internet, so behind no NAT.
Its only task is the transfer of all game data from one origin peer to all other peers. So the game data just takes a little detour to the Relay-Server before continuing it’s usual way to the peers.
This comes at a price though. Since all of the game traffic is now travelling through this server the workload can be quite tough depending on the amount of information the game needs to exchange. Naturally the ping or RTT (Round Trip Time: the time it takes to send a packet from peer to peer) of a packet is increased resulting in lags. And finally multiple Relay-Servers would be required in each region (Europe, America, Asia, …). Otherwise players far away from the Relay-Server suffer heavy lags. All of these lead to high demands on the server hardware. To be clear: a proper Relay-Server architecture can be expensive in time and money.
Because of that in this project I ignored the worst-case and focused on the default Peer-to-Peer mechanism.
3.1.4 Peer2Peer Conclusion
The big advantage of this method: it’s mainly serverless, so the operation costs of the multiplayer is very low. Because of that, P2P is a very viable multiplayer solution for small projects and indie games. The only thing that is needed is a cheap Rendezvous-Server (of course only if no Relay-Server is used). P2P also does not require to port-forward, which can be a difficult and/or time consuming task depending on the player’s knowledge.
But there are disadvantages:
- A home network bandwidth may not be enough to host larger games with much traffic; a server hosted at a server farm has much more bandwidth
- The game stops if a P2P host leaves the game
- No server authority
- every player has a slightly different game state that needs to be synchronized often; a dedicated server has only one state and distributes it to the players; players only send inputs to the server
- anti-cheat has to be performed by every peer and not just the server
- random is handled better if only the server generates random values, otherwise seeds have to be used
- game states may need to be interpolated between peers, which is not the case if only the server owns the game state
A dedicated server would solve these disadvantages but in return the hardware requirements are much higher making this approach more expensive. Also multiple servers would be needed in all regions of the world to reduce ping/RTT.
3.2 Game Connection Process
After starting the game the player sees the multiplayer games lobby. As described previously the player can host or join a game from the list.
3.2.1 Hosting a game
The host needs to input a unique game name and the port he will open for the connection. When the host button is clicked the following procedure is triggered:
- Obtain public IP address
- Originally this should be handled by the Rendezvous-Server, because it is hosted behind no NAT and can see the public IP of requests, but limitations of the chosen hosting service prevented this approach (more on that later)
- Instead I used a web request to free services like icanhazip.com or bot.whatismyipaddress.com as a second backup in case the first service is down; these websites respond with a plain text containing the ipv6 or ipv4 of client/request
- The Rendezvous-Server is notified of the new multiplayer game entry and saves the game with public IP and port, both sent to the server by the host
- Host sends GET-Request to the server (web server) containing all the information needed
/registermpgame?name=GameOne&hostIP=1.1.1.1&hostPort=4141
- On success the game is registered and a token is returned to the host; the token is needed for further actions affecting the created multiplayer game
- Host sends GET-Request to the server (web server) containing all the information needed
- The host now waits for incoming connections from other players/peers
- The host sends another GET-Request to the Rendezvous-Server
/listenforjoin?token=XYZ123
- This is a long-polling request (websocket alternative): the connection is held open by the server until a player joined the multiplayer game
- If that is the case the GET-Request is resolved with the public IP and port of the joined player, so that hole-punching is possible
- If no player joins until the timeout is reached (I’ve set the timeout to 15 seconds), the request is resolved with http status code
204 No content
and no body - In that case the GET-Request has to be sent again and again until a player joins
- The host sends another GET-Request to the Rendezvous-Server
- On player join both peers init a connection and punch through NAT
- If successful the game starts
- (Otherwise a Relay-Server is needed; explained previously)
- The host closes the game with another GET
/startorremovempgame?token=XYZ123
3.2.2 Joining a game
The player first needs to input a valid port. After that he is presented with a list of multiplayer games by retrieving the information from the Rendezvous-Server with a GET-Request to the endpoint /mpgameslist
. This returns a JSON list with game data objects containing the following infos:
name
: multiplayer game namehostIP
: public IP of the hosthostPort
: port the host will open for the connection
If the player clicks “Join” on a specific game list item the following process handles the connection with the host:
- Obtain public IP address
- Originally this should be handled by the Rendezvous-Server, because it is hosted behind no NAT and can see the public IP of requests, but limitations of the chosen hosting service prevented this approach (more on that later)
- Instead I used a web request to free services like icanhazip.com or bot.whatismyipaddress.com as a second backup in case the first service is down; these websites respond with a plain text containing the ipv6 or ipv4 of the client/request
- Inform the Rendezvous-Server of the join
- Send a GET-Request with all the information needed
/joinmpgame?name=GameOne&ownIP=2.2.2.2&hostPort=2222
- Now the host is informed by the server if the host was listening
- The server resolves the request with the public IP and port of the host
- Now the player and the host try to establish a P2P connection with hole-punching
- If successful the game starts
- (Otherwise a Relay-Server is needed; explained previously)
- Send a GET-Request with all the information needed
3.3 Game Synchronization
Real-time synchronization of game states is a big challenge. Unlike turn-based games the game does not wait until all infos are received from the other players. The game always goes on with a desirably minimal amount of lag.
Of course the whole game state could be serialized and sent to all players, but this would have to happen very frequently and the package size would be very large. Thus resulting in very high bandwidth demand.
Another approach is to only send user inputs/orders, which yields far less network traffic. I used this lightweight idea, so when the player issues an order the order is immediately transmitted to the other player. There the order is executed as well.
The following game events are synchronized:
GameStart
: After the game scene is loaded the game is paused and the peer sends this message to the other player periodically until he receives the same message from the other peer; then the game is startedRandomSeed
: Per game a “random seed master” (the host) periodically generates a random seed and distributes that seed to the other player; this seed is then used for all random calculations- All 3 ship orders:
ShipCourse
ShipSpeed
ShipTarget
GameSync
: All of the previous messages still led to diverging game states, so a complete game serialization and synchronization is scheduled to happen every 30 seconds- Projectile positions, rotations, velocities are synched
- The whole ship state is synched
- Both game states (the received one and the own one) are interpolated, because I don’t use an authoritative server model and so both game states are “valid”
The following game events should have a positive impact on game sync, but are not implemented yet:
ProjectileFire
: Syncs projectiles being firedWaves
: Because the waves have a small impact on the position where projectiles are fired and hit the ship the waves should be in-sync as well
3.3.1 IDs
In game development you mostly work with references. So for example a ship has a reference to another ship as the firing target. In code this has the benefit of easy access to the target ship’s properties, fields and methods.
The problem is with networking these references do not work. Every machine has different references although it may represent the same ship. So if we want to transfer the order “Ship1 course 180” we cannot use the local reference value to Ship1.
Ship1 needs an unique ID that is exactly the same on all machines. Now we can send “ShipWithID1234 course 180” and every machine knows which ship to address.
In code this is a bit more tedious, because the received ID has to be resolved to the appropriate ship reference.
The most difficult part is finding unique IDs for all gameobjects.
Ships can obtain an ID easily on game start by the host. Projectiles are a bit more tricky, because they are spawned later on. I solved this by counting the shots fired by a gun turret and combining the gun turret’s ID with the shot number to generate a guaranteed unique ID, provided the gun turret ID is unique. Gun turret IDs are combined as well: Ship ID + gun turret location (sternA, sternB, bowA, bowB, …).
Of course with an authoritative server this gets easier as only the server generates IDs and distributes them to all clients.
3.3.2 Lockstep
Additionally there is an interesting and promising approach to discretize the continuous game time called Lockstep. It is used in prominent real-time strategy games like Age of Empires (https://www.gamasutra.com/view/feature/131503/1500_archers_on_a_288_network_.php). The basic idea is to split up the time in small time chunks, for example 200ms intervals. In this time frame every player can do exactly one action that gets transferred to all the other players. Of course this action can also be “no action”. The action is then executed in the next interval almost simultaneously for all players. This way the real-time game is transformed into a turn-based game. It is important to adjust the interval based on the connection speeds between the players, so that no player lags behind. For the players the small order input delay is usually unnoticed, if the interval is small enough.
An important requirement is that the game is deterministic and orders issued by players have the same outcome on all machines. Sure there are ways to handle random game actions, but because AdmiralWW:2 uses random for many important calculations and my development time frame was limited I unfortunately did not implement this technique.
4 Rendezvous-Server Hosting
There are almost unlimited hosting options on the internet. Usually the selection shrinks after a specific programming language is picked. But because I used NodeJS with Typescript, which transpiles the code to default Javascript, there were still plenty of hosting options. If I decided to write the server in C# and therefore run a .NET Core application like the game is written with (Unity uses C# or some exotic programmers use Javascript) many hosting providers drop out.
4.1 Alternatives
Of course there is the option of renting an own dedicated server: very expensive for a simple Rendezvous-Server and maintenance heavy, but powerful and flexible (.NET ok).
There’s the option of a managed server: little maintenance but very, very expensive.
We have VPS (Virtual Private Servers): dedicated servers that are used by many customers and the hardware is distributed among them, cheaper.
Then there are the big players like AWS, Google Cloud Platform, IBM Cloud and Microsoft Azure: they can get very expensive, but in return they offer vast opportunities and flexibility; it is easy to scale and monitor your whole infrastructure and a load-balancer can increase availability and efficiency of your server(s); on the other hand the learning-curve is steeper and setting up a project needs more time.
4.2 Heroku
Heroku is a cloud based Platform-as-a-service (PaaS) offering hosting of many common programming languages like Javascript/NodeJS (which I used), Python and Ruby. It does not offer as many possibilities as AWS and co, but it is way simpler to learn and set up.
Also it does have a completely free plan, which grants over 500 hours uptime per month. This is not enough to run the whole month with 30 * 24 = 720 hours, but the application sleeps after 1 hour with no actions and automatically wakes up again if needed. This is perfectly fine for a Rendezvous-Server, because it is not used all the time. The wake up time is not that bad as well (around 4-8 seconds).
Of course Heroku offers scaling so that the performance is massively increased and the app will never sleep, but this comes with a price tag.
In a paid plan Heroku also has a solid monitoring page with events, up- and downtimes, traffic and so on.
Server logs are easily accessible as well.
For setup you just need to create a “Procfile” in your project folder that defines what to execute after the build is completed: web: npm run start
will run the npm script called start
as a web service. The application is then publicly reachable on your-app-name.herokuapp.com. The NodeJS web server can then listen on the port that is provided by Heroku in the environment variable process.env.PORT
.
Deployment is automated: just push to your github master branch (or the branch you specified in Heroku); after that a github webhook triggers the build of your app in Heroku.
But during development I discovered a big disadvantage: Heroku does not support ipv6.
This is a problem, because I wanted to use the Rendezvous-Server as a STUN-Server as well, which can determine and save the public IPs of client requests. But if a client like me only has Dual-Stack lite (unique ipv6 but the ipv4 address is shared among multiple customers) Peer2Peer is not possible with the shared ipv4.
As a workaround the clients obtain their public ipv4 or ipv6 via GET-Request from icanhazip.com or as a backup from bot.whatismyipaddress.com. These websites return a plain text body containing the public IP. After that the peers send their public IP to the Rendezvous-Server as explained previously.
5 Architecture Overview
Typescript usually is a very good choice for larger projects, simply because of the type-safety and development-time error checking. This guarantees no more searching for bugs like typos as it is the case in plain Javascript.
To realize the web server I used the very popular ExpressJS, which does not need any introduction and should be well-known by this time.
6 Conclusion
Real-time multiplayer games are tricky. The game states quickly diverge and much effort has to be done to counteract this. Game time differences and lag drastically compound this. But methods such as Lockstep can help to synchronize the time across multiple players.
While developing, try to keep the game as deterministic as possible, so that player actions yield the same result on every machine. Random is usually problematic, but can be handled via a dedicated game server or seeds.
Peer-to-Peer is a simple and great solution for smaller indie multiplayer games, but comes with some disadvantages. For larger projects dedicated/authoritative servers are favourable.
Heroku offers a fast and simple setup for hosting cloud applications and the free plan is great for smaller projects. If demand increases scaling is no problem and the deployment is automated. But be aware of the missing ipv6 support of Heroku.
All in all: Gaming is fun. Strategy games are fun. Multiplayer is fun – for the player and an exciting challenge for developers.
Leave a Reply
You must be logged in to post a comment.