After my friends and I released v1.0 of our GMTK Game Jam game, we returned to the project we had been working on previously. Aptly named “Godot 3D Shooter Project”, it’s a 3D multiplayer first person shooter game. We’re still working on the game design, but as of now we have a working prototype. Basically, we can move around and shoot each other, and really what more does one need in an FPS?
In any case, the current method of making a game is a bit convoluted. Someone has to host a game on their local machine, then anyone else who wants to join has to enter their IP and port number, and then they can join. But wait, the host also has to set up port forwarding on their router so that other people can actually connect. If we were ever to release this game, that would be a terrible experience to force onto our users. So, for a long time I’ve been thinking about how to make the process of creating and joining games more seamless.
The Master Server Idea ๐
In my mind, the ideal way to create and join games is through something I call the master server. A diagram of the flow is shown below. When someone wants to create a game, they send a request to the master server, and the server is responsible for creating the actual Godot process, and telling the user where that process is hosted at. Likewise when someone wants to join a game, they ask the master server for a list of the available games, and then join one of them.
Thankfully, Terry had already gone through the work of creating a dedicated server mode for our game. When you use the
command line option --dedicated
, the process will not be able to control a player. So that piece of the puzzle was
already accounted for. Everything else, however, had to be created from scratch.
Attempt 1: Connecting Directly with TCP ๐
Pretty early on I decided to write the game manager portion in Python. All it had to do was listen to requests, store some game information, and spawn the Godot processes, and I am familiar with all of those things in Python. My first attempt for Godot to Python communication was raw TCP.
I set up an asyncio
server on Python, and a StreamPeerTCP
in Godot. Initial tests turned out promising: I could send
data from Godot to Python, and I could receive and print it out. Perfect! The problem came when trying to send a reply
back. After debugging for quite a while,
I found the issue. Turns out, the default asyncio.StreamReader.read()
method will only stop reading when there’s an
EOF
character, and Godot will only send one when the StreamPeerTCP
goes out of scope and gets deleted. That’s a bit
of a problem for back and forth communication.
So I decided I’d need to create my own protocol on top of TCP. I made each packet contain a 16-bit header: 4 bits for the protocol version (future-proofing is always good, folks), and the remaining 12 bits for the packet length. With that I could send any arbitrary bytes back and forth, and there weren’t any issues. There was a lot of code I needed to write to handle the status and decode the packets, but I got it working.
And then, right when I felt very proud of myself, I showed my teammates, who immediately asked me, “Why didn’t you just use HTTP requests?”
…
Reader, I facepalmed so hard you could hear it all the way over in China.
Attempt 2: Using HTTP Requests Like a Normal Personโข ๐
HTTP requests are great. They basically allow me to send arbitrary text, and have built-in libraries do all of the heavy
lifting. Godot has a built-in HTTPRequest
class, which can send any request I want. Likewise, in Python, I can set up
a subclass of BaseHTTPRequestHandler
to receive and respond to the requests sent by Godot. Since all communication is
initiated from the user, I don’t need to worry about receiving requests in Godot. There are really only three things
you can request:
- Tell the master server to start a new game. Pass in the server name, maximum number of players, etc.
- Ask the master server for a list of currently active games.
- Tell the master server that a server is exiting (due to no players connecting for a certain amount of time).
If I were lazy, I probably could have implemented all three with POST requests, having the server just return whatever information I need in JSON. However, in the spirit of making the request type accurate I went with GET requests for #2. I also added a custom UserAgent header to differentiate requests coming from games vs. browsers. This isn’t actually secure though: UserAgent headers can be easily spoofed, so you should never rely on them for security.
And after some work adding the menus, it works! Creating and joining games are now much more easier than they were before. Check out the short clip below for a demonstration:
And with that, we’re done. I have another write up I’d like to do about setting up “automated” deployment for the master server, you can expect that at some point, so stay tuned!