Multiplayer without port forwarding
Why your friend cannot connect to your game, what a relay actually does about it, and the one line that hosts a Floptle game for 100 players free.
- What the router is actually doing
- Port forwarding works, and you cannot ship it
- The three ways out, honestly
- What a relay does
- Doing it in Floptle
- Testing it without a second machine
- What it costs
- Next
You got multiplayer working. Two windows on your own machine, both players moving, state staying in sync. You send a build to a friend, they click join, and it sits there until it times out.
Nothing in your code is wrong. The packets are being thrown away by a box in your friend's hallway, and by yours.
What the router is actually doing
Your machine does not have an address the internet can reach. It has a private
one, usually something like 192.168.1.42, and so does every other machine in
the building. The router owns the one public address, and it shares it out
using NAT.
NAT works by remembering. When your machine sends a packet out, the router writes down "this went out from 192.168.1.42 on port 51000", swaps in its own address, and when a reply comes back it looks up that note and passes the reply inward. The note is created by the outbound packet. That is the whole trick.
Now run it backwards. Your friend's game sends the first packet at your public address on port 7788. It reaches your router, which looks for a note saying where port 7788 goes, finds nothing, and drops it. There is no error to see and nothing to log. Your friend's screen says the connection timed out, because from their side that is all that happened.
Both ends have this. Whichever one of you tries to host, the other one's first packet arrives somewhere that has never heard of it.
There is a worse version. Some ISPs put whole neighbourhoods behind a second layer of NAT, called CGNAT, where the public address is not yours at all. You can configure your router perfectly and it will still not work, because the address a player would connect to belongs to the ISP and points at hundreds of homes. Mobile networks are almost always like this. If you have ever wondered why a game that worked at your desk failed on someone's phone tether, that is why.
Port forwarding works, and you cannot ship it
Port forwarding is telling the router to keep a permanent note: "anything arriving on UDP 7788 goes to 192.168.1.42". It genuinely works, and for testing on your own desk it is fine.
It is not something you can ask a player to do. To follow the instructions they would have to find the router's admin page, sign in with a password most people have never changed and cannot find, locate the right settings page out of a dozen vendor-specific layouts, know their machine's address on the local network, and know that the address can change the next time the machine reboots. Then they have to open a port on a home network, which is a real thing to ask of someone who wanted to play a game with a friend.
And on CGNAT none of it helps.
A game that requires this does not have multiplayer. It has multiplayer for people who are willing to read a support article.
The three ways out, honestly
| Approach | What the player does | When it fails |
|---|---|---|
| Port forwarding | Configures their router | CGNAT, and most players stop before they finish |
| Hole punching | Nothing | Symmetric NAT, some corporate and mobile networks |
| Relay | Nothing | The relay has to be running |
Hole punching deserves a fair hearing, because it is the clever answer. Both machines talk to a public helper server, learn what the outside sees of them, and then send packets at each other at the same moment so both routers write their notes at once. When it works, the traffic is direct, and direct is the lowest latency you can get.
The problem is that it does not always work. Symmetric NAT assigns a different outside port per destination, so the port your helper saw is not the port your friend needs, and there is nothing to aim at. In practice a meaningful share of connections fail, and the accepted answer to that failure is to fall back to a relay. So the honest cost of hole punching is that you build it and the relay, and you carry both forever.
What a relay does
A relay is a machine with a public address that both players can reach, and reaching it is something both of them can already do, because it is outbound. The host connects out to the relay. The joiner connects out to the relay. Both routers write their notes, because that is what outbound packets do. The relay then forwards bytes between the two connections.
The cost is one extra hop. Instead of you to your friend, it is you to the relay and the relay to your friend. If the relay sits in a region near both of you that is a small addition, and for most games it is well under what players notice. If your game is a fighting game where every frame counts, you should measure it rather than take anyone's word, including ours.
The benefit is that it always works. There is no NAT shape that defeats an outbound connection, because outbound is the direction the whole design is built to allow.
Doing it in Floptle
Hosting through a relay is the relay argument to net.host:
-- Anywhere; a lobby node is a good home.
function start(node)
net.host{ relay = "cloud" }
end
"cloud" means the nearest open region of the managed relay. You can pin one
with net.host{ relay = "cloud:us-east" }, and you can point at a relay you run
yourself with net.host{ relay = "relay.example.com:7788" }, which is the same
program, open source, cargo run -p floptle-relay on any box both machines can
reach.
The host gets a lobby code back. It is six characters, and the first one names the region, which is what lets a joiner turn a code into an address without asking anyone.
net.join("cloud://UABCDE")
Show the code on your own lobby screen rather than making players open an engine panel:
function update(node, dt)
local code = net.lobbyCode()
find("CodeLabel").text = code or "getting a code…"
end
Poll it, do not read it once. It is nil until the relay answers, which is one
round trip after net.host, and nil for good on a client.
And check the join actually happened. net.join does not block, and
net.role() will say "client" from the frame you call it whether or not that
lobby exists, so a lobby screen that trusts it congratulates a player on joining
nothing:
local state, why = net.joinState() -- "offline" "connecting" "joined" "refused"
if state == "refused" then
find("Error").text = why -- "no lobby QK7RM", in the relay's own words
end
Testing it without a second machine
net.host{} with neither a port nor a relay stands up an in-process harness:
one real client talking to one real server, over a link whose latency and loss
you control with sliders. Turn latency to about 200 ms and loss to 10% early and
often. A bug that only appears at 200 ms is a bug you want to find on your own
desk rather than in someone's Discord.
floptle run plays a project headlessly, with no window and no GPU, and it can
host a real session while it does. That is what makes a lobby testable in CI.
What it costs
Hosting through the managed relay needs your project connected to a game, which
puts a key in project.ron:
cloud: (game: "your-game", key: "fk_live_…")
The key ships inside every build, which is deliberate: it is the only way a player's copy of your game can host at all. It is not a password. It says whose plan a session counts against.
The free tier is 100 concurrent players across all your games, in every region, with no card. That is a real number rather than a trial, and for a jam game or a game you are showing to friends it is usually the whole requirement. Paid plans raise the ceiling when you need it.
Next
Make it multiplayer walks the tutorial from one machine to two machines playing together. Get a game key takes about a minute and does not need a card.
Written by Ty Johnston at Fopull LLC, a software studio in Knoxville, TN. We build custom software and ship our own: Floptle, Storage Sifter, and more.
Read next
-
Real-time multiplayer with Laravel Reverb
Running two live multiplayer games on Laravel Reverb: channel design for hidden information, the ShouldBroadcastNow trap, and the systemd setup.
-
Your AI assistant cannot open a game engine
An AI coding agent has a terminal and your files. A game engine keeps everything else behind a window. Here is how we closed that gap in Floptle.