Want to implement multiplayer for your Godot Game 100% cost free and not relying on any servers and plugins?
Then this is for you.
In this post we will go over a step by step process in implementing UDP hole punching in Godot to connect two peers in Godot and create a simple barebones chatting app with it.
So what is UDP hole punching?
UDP hole punching is basically a method where you make a STUN request to a stun server to get your Public IP and Port. Which you use to initiate the Hole Punching.
some things to be aware of
UDP hole punching doesn’t work 100% of cases (Example: for users on a symmetric NAT). In case it doesn’t work many developers implement relay servers. We won’t be going over relay servers in this post.
When testing, you usually can't perform UDP Hole Punch on the same device so, so either run two instances with one on your device and other on either a friends device or a VPS.
You can also use local host IP 127.0.0.1 and a random port to test.
(in this case make sure that the port is different on both instances)
Step 1: Make a stun request
The first step is getting your Public IP and Port by making a request to a STUN server.
There are many public STUN servers that you can use, for this example we'll be using "stun.l.google.com".
Now it's time to get coding!
Make a script for the stun request. I named it "StunRequest" for my case.
In this script we use Godot's PacketPeerUDP to make a Request to the Stun Server.
var udp : PacketPeerUDP = PacketPeerUDP.new()
We will use this UDP for all networking work like receiving and sending packets.
While it's not necessary, it's recommended to have a pre-defined port number that you bind to the UDP Socket, name it something like "local port" and give it a number between 0 – 65535, It's better to use dynamic port numbers ranging from 49152 – 65535.
Now it's time to make the STUN Request
Once you bind the port to the UDP, set the destination address of the UDP Socket to "stun.l.google.com" and the port to "19302" (that's the port used by the stun server).
You can also use other live STUN servers.
Now it's time to make the binding request, create a function for the request.
We will use udp.put_packet()
to make the stun request, but you can't just put any packet. You need to put a specific packet.
func initiate_stun_request() -> void:
udp = PacketPeerUDP.new()
if udp.is_bound():
print("Error, UDP is already bound!")
return
var bind_status = udp.bind(local_port, "0.0.0.0") #make sure to only use ipv4 address
if bind_status != OK:
print("Bind Failed: ", error_string(bind_status))
return
print("Bound to port at: ", local_port)
udp.set_dest_address(stunServer_ip,stunServer_port)
var request_package = stun_request_package()
var requestTransactionID = request_package.transactionID
var resquestMessage = request_package.requestMessage
udp.put_packet(resquestMessage)
print("Requesting Stun...")
var timeout = Time.get_ticks_msec() + 5000
while udp.get_available_packet_count() == 0:
if Time.get_ticks_msec() > timeout:
print("stun failed...")
return
else:
await get_tree().create_timer(0.1).timeout
print("response found!")
var responseMessage = udp.get_packet()
var responseType = responseMessage.decode_u16(0)
var responseTransactionID = responseMessage.slice(8,20)
if responseType != 0x0101 or responseTransactionID != requestTransactionID:
print("recieved invalid STUN binding response!")
return
var responseAddress = parse_stun_response(responseMessage.slice(24))
print("STUN was sucessful!")
print("Public IP Address: ", responseAddress.address)
print("Public port: ", responseAddress.port)
func stun_request_package() -> Dictionary:
var transactionID = PackedByteArray()
for n in 12:
transactionID.append(randi_range(0,255))
var buffer = PackedByteArray()
buffer.resize(20)
var message = StreamPeerBuffer.new()
message.data_array = buffer
message.big_endian = true
message.put_u64(0x0001000000000000)
message.put_data(transactionID)
return {
"requestMessage" : message.data_array,
"transactionID" : transactionID
}
func parse_stun_response(attributes: PackedByteArray) -> Dictionary:
var streamBuffer = StreamPeerBuffer.new()
streamBuffer.data_array = attributes
streamBuffer.big_endian = true
var address_type = streamBuffer.get_u16()
var discovered_port = streamBuffer.get_u16()
var discoverd_address = ""
if address_type == 0x01:
var address = []
for n in 4:
address.push_back(streamBuffer.get_u8())
discoverd_address = ".".join(address)
return {
"address" : discoverd_address,
"port" : discovered_port
}
This will print your Public IP and Port, which we will send over to your peer to initiate a connection.
STEP 2: Exchanging the IP and Port
Once we have the IP and Port we need to exchange it with our peers, for it
we need to display the IP and Port either in a normal way or Encrypted. It is better to Encrypt them using Godot's AESContext for better security.
Either store the IP that we get in the the previous step in a variable or pass it by connecting a signal.
Then Add a 'LineEdit' Node to the scene where we can put the IP and Port.
You can either add two LineEdit nodes one for the IP and Port or just put both in a single LineEdit node and separate them in script for better simplicity.
Now add a button for connecting which when pressed will call the signal for connecting to the peer.
STEP 3: Initiate the Hole Punch
Create another script for the connection, I called it 'UdpClient' for my case.
In it again create a variable for PacketPeerUDP and make sure to get the PacketPeerUDP that was used in the 'StunRequest' script.
var udp : PacketPeerUDP:
get():
return StunRequest.udp
In this case my StunRequest script was a singleton so I used 'StunRequst.udp' but you can reference it in any other way you prefer.
Also make a 'keep alive' timer variable that will be used to periodically ping the peer to ensure that the hole is kept open.
var keep_alive_timer : Timer = Timer.new()
Make a function for the connection, connect it to the button in the previous step make sure to pass the peer_ip and peer_port parameters in that you get in the previous step from the 'LineEdit' Node to the function.
func connect_to_peer(peer_ip : String, peer_port : int):
if !udp.is_bound():
print("error! udp is not bound")
return
udp.set_dest_address(peer_ip,peer_port)
print("set dest address at: ", peer_ip ,":",str(peer_port))
var connection := false
for i in range(50):
print("waiting for peer...")
print("attempt #", str(i))
udp.put_packet("ping".to_utf8_buffer())
await get_tree().create_timer(0.3).timeout
if udp.get_available_packet_count() > 0:
var packet = udp.get_packet()
var parsed_packet = packet.get_string_from_utf8()
if parsed_packet.begins_with("ping"):
udp.put_packet("ping".to_utf8_buffer())
#put one final ping
print("connected to peer!")
connection = true
keep_alive_timer.autostart = true
keep_alive_timer.timeout.connect(_on_alive_timer_timeout)
break
if !connection:
print("connection failed!")
return
func _on_alive_timer_timeout():
udp.put_packet("keep alive packet".to_utf8_buffer())
With this the function will send packets to the IP and Port as well as receive packets to establish the UDP connection. A 'keep alive packet' will be sent periodically to ensure the hole stays open.
STEP 4: Connect to Peer
Ensure the peer's NAT type is compatible with UDP Hole Punching. (Full Cone, Restricted Cone, Post-Restricted Cone)
Most home networks are on a Full Cone or Post-Restricted Cone so they should work fine, Mobile Networks usually use symmetric NAT so they will fail.
Exchange the IP and Port using Discord or other messaging platforms, and connect to each others IP and Port.
If everything was implemented correctly then the connection should establish and
Congratulations! You've successfully implemented a cost-free way to do multiplayer.
After you've connected with the peer, you can practically do anything like in normal P2P, but I advise Wrapping the connection over ENet so you can take advantage of Godot's Built-in multiplayer system.
Example of using this system using encrypted IP and Port to connect to peer.
By Tahmid Hasan
tahmid.games
tahmidhasan.dev@gmail.com
Top comments (0)