DEV Community

Cover image for Step-by-Step Guide to UDP Hole Punching in Godot Engine
Tahmid Hasan
Tahmid Hasan

Posted on

Step-by-Step Guide to UDP Hole Punching in Godot Engine

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
    }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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.
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)