War on NAT, or how to let your players connect without port forwarding
3211 Words : 14 Minutes, 35 Seconds
2024-12-05 19:33 +0000 [Last Modified on : 2024-12-05 19:34 +0000]
One of the main problems I encountered working on my game was getting players to connect in the first place. Since I’m working on a peer to peer fighting game I can’t rely on a relay server, especially considering the competitive nature of those games - any delay is unacceptable. However, in order to let players connect some kind of NAT (Network address translation) traversal should be implemented.
In this blog post I will explain how to deal with this using command line tools, Godot and in general using any tool/language that can send UDP packets.
The Problem
To be able to receive packets from each other players need an open port on their router, otherwise the packets wouldn’t even get past the router, meaning that no kind of software on the players’ machines could do anything. Normally that would be done by port forwarding or UPnP, but not all players have access to those methods, or even ought to use them just to play a video game. However, there is another that would allow the players to connect.
Hole Punching
Theory
If you have sent a packet to a port you have not opened, you would simply never know this as it won’t even get past your router. However, when you are talking with a server on the open internet, they need a way to talk back to you. When you send a packet to a server, a hole is opened on your router to allow packets back from the server in question. With that being said, your router does not know what happened to your packet after you sent it left your router - it has no idea if you sent a packet to an actual server on the open internet, or just to someone you want to play a game with. This is how hole punching works, if you coordinate with someone and start sending packets to each other your router would think this is just a normal connection and let you connect.
Practical Usage
So how can you actually execute this method? You can use any tool that can listen for packets and send packets, but for this post I will be using netcat - specifically netcat-openbsd. This version of netcat is important if you want to get the example below to work. You can either get it from your package manager if you are on Linux or via WSL on Windows and again install it via the package manager there. Once you have it you need a server or a friend with whom you will preform a hole punch.
- Open a terminal on each machine and run:
machine1: $ nc -ul 9999
machine2: $ nc -ul 9998
Here 9999
and 9998
are the ports that you want to hole punch on each
machine - for machine1
we are hole punching 9999
, for machine2
we
are hole punching 9998
. The ports in question can be the same on both
machines. This command would start listening for packets on the specified port.
- Send a packet to each other on another terminal via:
machine1: $ echo 'hole punched' | nc -4u 123.123.123.123 9998 -p 9999
machine2: $ echo 'hole punched' | nc -4u 213.213.213.213 9999 -p 9998
In here 123.123.123.123
corresponds to the IP address of machine2
,
9998
corresponds to the port specified in step one again for machine2
and -p 9999
corresponds to the LOCAL port specified in step one for
machine1
. machine2
then mirrors this command. The echo
command
argument does not matter - you can send anything.
-
Assuming you did everything correctly, and you don’t have a problematic NAT on your machine (I will cover this later in this blog post). One of the 2 machines should have received the message
hole punched
. You can now run the command as many times you want on either machine with any message you want, and you should be able to communicate. -
In fact now you should be able to connect with any UDP connection through the hole you just punched. For example to connect via a Godot game you can use:
# machine1
var peer = ENetMultiplayerPeer.new()
var local_port = 9999 # the port we punched
peer.create_server(port, 1)
multiplayer.multiplayer_peer = peer
# machine2
var peer = ENetMultiplayerPeer.new()
var local_port = 9998 # the port we punched
var machine1_punched_port = 9999
var machine1_ip := "213.213.213.213"
peer.create_client(machine1_ip, machine1_punched_port, 0, 0, 0, local_port)
multiplayer.multiplayer_peer = peer
With this both machines are now connected and should be able to play the game together.
One important note is that all of this also works via IPv6 - in fact it works
even better over IPv6 as IPv6 does not suffer from some of the issues I will
discuss, below. The only difference in the method I described is that for
sending a packet instead of nc -4u
you should use nc -6u
and an IPv6
address instead of IPv4 address.
Port masking, NAT types and the bane of Hole Punching - Symmetric NAT
Port masking and signaling servers
The main reason why the method above could fail for some machines is port
masking. When you send a packet from local port XXXX
the router could
instead send a packet via another public port YYYY
. Because of this both
machines could not send packets to the correct local_port
of the other
machine. The problem here is - your machine have no idea how your router will
change the port or if it will change it at all. To figure this out you need a
signaling server.
A signaling server has many usages, but the main one we will use it now is to
get the actual public port YYYY
. When you send any packet to the signaling
server, it would receive the packet from the public port YYYY
- which
now lets you map the local XXXX
and public YYYY
ports. Now both
machines, can still listen to their local ports, but send packets to each
other’s public ports and connect. Below is a sample method with rust code for the
server (you can use anything that can bind sockets and read the IP and port of
incoming packets):
// Server machine
const SIGNAL_SERVER_PORT: &u16 = &54321;
fn main() -> std::io::Result<()> {
let socket: UdpSocket =
UdpSocket::bind(format!("[::]:{}", SIGNAL_SERVER_PORT)).expect("Failed to bind port.");
// Don't block the loop when listening for packets.
socket
.set_nonblocking(true)
.expect("Failed to make socket non-blocking.");
loop {
let mut buffer = [0; 1800];
// Parse packet
let Ok((_, src)) = socket.recv_from(&mut buffer) else {
continue;
};
// Print ip and port of received packet.
println!("{};{}", src.ip(), src.port());
}
}
Sending a packet to the server should now print the IP;PORT of each packet and give you the corresponding public port. Now using the new public port of each machine you got from the signaling server you can hole punch with the traditional method only changing the command in step 2 to:
machine1: $ echo 'hole punched' | nc -4u 123.123.123.123 $machine2_public_port -p 9999
machine2: $ echo 'hole punched' | nc -4u 213.213.213.213 $machine1_public_port -p 9998
With this you should be able to connect with almost all NAT types, but there is still an issue.
NAT types
There are different classifications of NAT types, the ones I will be using here are from the following proposed standard1. Specifically we will discuss only 3 of the variants:
Restricted Cone: A restricted cone NAT is one where all requests from the same internal IP address and port are mapped to the same external IP address and port. Unlike a full cone NAT, an external host (with IP address X) can send a packet to the internal host only if the internal host had previously sent a packet to IP address X.
- This is the least restricting type from the types we will discuss. Basically all the techniques that can be used with it can also be used by other less restrictive types I won’t mention in here. It can also be called “Address Restricted Cone”
Port Restricted Cone: A port restricted cone NAT is like a restricted cone NAT, but the restriction includes port numbers. Specifically, an external host can send a packet, with source IP address X and source port P, to the internal host only if the internal host had previously sent a packet to IP address X and port P.
- This is the most common type I’ve personally seen. I don’t have statistical data on this, but I believe it is safe to assume this as the default for most of your players.
Symmetric: A symmetric NAT is one where all requests from the same internal IP address and port, to a specific destination IP address and port, are mapped to the same external IP address and port. If the same host sends a packet with the same source address and port, but to a different destination, a different mapping is used. Furthermore, only the external host that receives a packet can send a UDP packet back to the internal host.
- This is the problematic NAT type we will deal with. You can also see it called “Double NAT”, or even CGNAT (Carrier-grade NAT). CGNAT would be incorrect though, it means something different, however machines behind CGNAT are usually behind symmetric NAT.
Connection between NAT types
So how do the possible connections between NAT types actually look like? There is a nice table from a paper on NAT traversal2:

The important thing of note here is that all NAT types besides symmetric note can be connected via Hole Punching (or directly). This is what I meant by problematic NAT earlier, using the Hole Punching with the signaling server I explained earlier would be enough to connect anyone, BUT players behind a symmetric NAT.
Symmetric NAT
The problem
The problem with symmetric NAT is that the masking of their public port does not depend JUST on their local port, but ALSO on the address and port they are sending a packet to. This essentially makes a signaling server useless as any port the signaling server finds on the packets the symmetric NAT player send will not match the port on the packets anyone else would receive. In fact the ports change with time as well - so even if you were somehow able to map the local port to the public port for a set address it is only temporary, and you won’t be able to reuse it.
How to deal with Symmetric NAT
IPv6
Symmetric NAT is something that affects IPv4. Technically you can build a symmetric NAT that affects IPv6 as well, but this is generally that your player should specifically activate on their router and not something their ISP would force upon them - hence if you encounter someone with symmetric NAT on their IPv6 I would advise you to let them deal with it themselves.
Now in the normal case you can just use IPv6 to connect symmetric NAT users to anyone else. Often people behind a symmetric NAT have IPv6 (I don’t have stats on that consider this a hearsay), so this would actually make it easy to connect a lot of people by just using the normal hole punch method, but using IPv6 addresses instead.
Address Restricted Cone to Symmetric NAT connection
Players behind Address Restricted Cone (ARC) have an advantage as they can connect even to symmetric NAT players. The trick is when ARC players send a packet to an address the hole they created allow them to receive packets from ANY port of the address they send a packet to. Normally with Port Restricted Cone you would need to know which local port corresponds to the public port since the other player would be able to send packets only via that local port. ARC on the other hand is not picky about that and the other player could use ANY local port to communicate with them as long as they are sending packets to the port the ARC hole punched.
So how does the method differs. The symmetric NAT player just does everything as the normal hole punch method. On the other side the ARC player hole punches by sending a packet to any port on the symmetric NAT IP, and then listens for packets from that IP. They should receive the symmetric NAT packets and find the port of those packets. Then they should just start communicating by sending packets to the symmetric NAT IP and the port they found.
UPnP and Open Ports
If the other player has provided any open ports or has UPnP enabled then you can simply let them host and connect the symmetric NAT player to them - no hole punching required. However not everyone has UPnP or want to provide an open port, so this is not a solution you can rely upon, but it is useful to at least do a check for it. A lot of routers have UPnP on by default, so you will be able to connect a lot more people if you make use of that.
It is also important to note that symmetric NAT players could ask their ISP to open a port for them or could have a router that is set up with a working UPnP for symmetric NAT. It would be beneficial to assist players that are behind a symmetric NAT and are willing to provide a port for connection.
For the open port you can add an option in your game for the players to provide an open port and then inform the signaling server about it. For UPnP you can use libraries for your language and likely your engine also has tools for that. Godot has a pretty good documentation on how to set up UPnP here: https://docs.godotengine.org/en/stable/classes/class_upnp.html
Again just send the UPnP port over the signaling server in a message so that it can be used for connecting the players.
Relay
If all else fails you can use a relay server. Essentially you send packets to a server, and it just relays all packets to the other player. This will add latency, and will have higher bandwidth cost, but it would at least let the players connect.
Are there really no other methods?
You could theoretically figure out how the symmetric NAT is doing the port masking and use the guess work to find the public port and establish connection. However, this all depends on the ISP, and it is basically guess work. There are papers on different methods, but I would not advise for implementing such methods - you will get inconsistent results for A LOT more work.
On that note you can also spray and pray - technically you can hit the correct port by chance and connect, there are after all only 65535 ports out there, shouldn’t be too hard to hit the correct port. If only symmetric NAT didn’t change your port every couple of minutes.
Matchmaking trick
While I’ve been talking about connecting 2 specific users, if we only focus on matchmaking we actually can do a small trick. We can find the capabilities of each player in the matchmaking (IPv6, UPnP, NAT type) and then depending on the capabilities only match people you can connect without relays. As long as you have some player base it should be feasible to connect everyone without relying on relays. Then you can just reserve relay usage for players trying to connect directly to friends.
Signal Server Skeleton
So what should a signaling server do, here a few things to consider for a simple server that uses lobby codes to connect players:
- Tests - check if client has ipv6 and the NAT type. UPnP can be tested locally. For NAT types you can test for symmetric NAT first, make the player’s client send packets to 2 servers from the same local port and if the public ports do not match they are behind a symmetric NAT. To test for Address Restricted Cone, make the player’s client hole punch a port with your server and then try to communicate with them using another port on your server - if they receive the server packets they are behind ARC.
- Register Clients - temporarily record the information that can be used to connect two clients in RAM. IPs and other capabilities.
- Register a session - create a session with a random code. Then let other
clients connect to the session. Once all the players are ready to start, or if
the session is full in a 2 player game - start sending the information required
for the clients to connect. When testing different connection types I recommend
testing in this order:
- If both clients have IPv6, just use hole punching with IPv6.
- If both clients are not behind a symmetric NAT, just use hole punching.
- If one player is behind a symmetric NAT and the other is behind APC use the relevant method explained earlier.
- Check if either client has an open port: if they have - tell them to host and send the relevant IP port information to the other client.
- Check if either client has UPnP port open: same technique as 1
- Use a relay
- If you are using a relay your signalling server should tell the relay server which IP:PlayerID pairs to connect. You are using PlayerID instead of a port since the symmetric NAT player port would be different when communicating with the relay server - hence the relay server should find the correct port via a message from the symmetric NAT player.
Troubleshooting
UDP packets can be lost
Don’t just send a single packet and assume it would arrive at its destination. Send a lot of packets and wait for confirmation.
Sending and receiving packets with gdscript
You can use the godotengine documentation to find how you can send and receive packets via gdscript. It is currently only in the latest documentation and not the stable version, as I recently made a PR with the examples there:
https://docs.godotengine.org/en/latest/classes/class_packetpeerudp.html
Relays with Rust
Below is a very simple relay server written in Rust. You give it 2 port numbers as command line arguments, and it will relay messages between them. There is no IP checking as it was intended only for local testing. It shouldn’t be difficult to add it.
use std::{env, net::UdpSocket};
const RELAY_SERVER_PORT: &u16 = &34291;
fn main() -> ! {
let relay_socket: UdpSocket =
UdpSocket::bind(format!("0.0.0.0:{}", RELAY_SERVER_PORT)).expect("Failed to bind port.");
relay_socket
.set_nonblocking(true)
.expect("Failed to make socket non-blocking.");
let args: Vec<String> = env::args().collect();
let port1: u16 = args[1].parse().expect("Provide 2 ports to connect via a relay.");
let port2: u16 = args[2].parse().expect("Provide 2 ports to connect via a relay.");
let mut buffer = [0; 65506];
loop {
let Ok((size, src)) = relay_socket.recv_from(&mut buffer) else {
continue;
}; // receive the packet
if src.port() == port1 {
let _ = relay_socket.send_to(&buffer[..size], (src.ip(), port2));
println!("Send to: {}", port2);
continue;
}
if src.port() == port2 {
let _ = relay_socket.send_to(&buffer[..size], (src.ip(), port1));
println!("Send to: {}", port1);
continue;
}
}
}
-
STUN - Simple Traversal of User Datagram Protocol (UDP) Through Network Address Translators (NATs) - March 2003; Network Working Group; 3489: https://www.rfc-editor.org/rfc/rfc3489#section-5 ↩︎
-
NAT TRAVERSAL IN PEER-TO-PEER ARCHITECTURE; TECHNICAL REPORT SCE-12-04 DEPARTMENT OF SYSTEMS AND COMPUTER ENGINEERING CARLETON UNIVERSITY; Marc-André Poulin, Lucas Rioux Maldague, Alexandre Daigle, François Gagnon: https://repository.library.carleton.ca/downloads/4j03d071p ↩︎