§02 Build a Go WebSocket Game Server for Your Godot 4 MMO
09 Nov 2024
Let’s send some packets! In the last post, we set up the foundation for our Godot 4 MMO, including the project structure, dependencies, and our first packets. Now, we’ll take the next step by creating a simple WebSocket server in Go. This server will handle connections and manage them through a WebSocket hub, setting the stage for real-time multiplayer communication.
For our goal of creating a cross-platform MMO, it is important to consider the technologies and architecture we will use. WebSockets are a great choice for us, as they are simple to use and well-supported across all platforms, including the web. The only downside is that they are not well-suited for very fast-pasted games, because the protocol relies on a TCP connection and will ensure that packets are delivered in order. Contrast this with UDP, which is used in most fast-paced games, and will drop packets if they are not delivered in time. However, you will find that even for a mildly fast-paced game like what we are building, and what most online RPG games are like, WebSockets are more than adequate. The only other viable choice for us would be WebRTC, but it is so complex that this series would be pretty much inaccessible to most people.
Hub and spoke architecture
The server will deal with two types: those implementing the ClientInterfacer (in our case, we will create a WebSocketClient type), and Hub. The client interfacer is a flexible type that standardizes how each client connects and communicates with the hub, enabling us to implement other client types in the future if needed. The server passes incoming connections to the hub, which in turn creates a new client interfacer. This is on a per-connection basis. A client interfacer acts as an intermediary between the Godot websocket connection and the hub. The hub maintains a set of registered clients and broadcasts messages to them.
The application runs one goroutine for the hub and two goroutines for each client interfacer.
A goroutine is basically a function that can effortlessly run in a lightweight thread, allowing your main code to flow uninterrupted. The goroutines can safely communicate with each other using channels: a way to synchronize data between goroutines without the need for locks or mutexes.
The hub has channels for registering and unregistering client interfacers, and broadcasting messages. A client interfacer has a channel of outbound messages, as well as two goroutines:
one for waiting and reading messages from the outbound messages channel and writing them to the websocket, and
another for waiting and reading messages from the websocket and processing them accordingly.
Here is a diagram showing two Godot clients connected to the server.
Creating the Hub and ClientInterfacer
Let’s get this set up!
Create a new folder called internal inside your server folder.
Inside internal, create another folder called server
Inside internal/server, create new file called hub.go and add the following:
/server/internal/server/hub.go
packageserverimport("log""net/http""server/pkg/packets")// A structure for the connected client to interface with the hubtypeClientInterfacerinterface{Id()uint64ProcessMessage(senderIduint64,messagepackets.Msg)// Sets the client's ID and anything else that needs to be initializedInitialize(iduint64)// Puts data from this client in the write pumpSocketSend(messagepackets.Msg)// Puts data from another client in the write pumpSocketSendAs(messagepackets.Msg,senderIduint64)// Forward message to another client for processingPassToPeer(messagepackets.Msg,peerIduint64)// Forward message to all other clients for processingBroadcast(messagepackets.Msg)// Pump data from the connected socket directly to the clientReadPump()// Pump data from the client directly to the connected socketWritePump()// Close the client's connections and cleanupClose(reasonstring)}// The hub is the central point of communication between all connected clientstypeHubstruct{Clientsmap[uint64]ClientInterfacer// Packets in this channel will be processed by all connected clients except the senderBroadcastChanchan*packets.Packet// Clients in this channel will be registered with the hubRegisterChanchanClientInterfacer// Clients in this channel will be unregistered with the hubUnregisterChanchanClientInterfacer}funcNewHub()*Hub{return&Hub{Clients:make(map[uint64]ClientInterfacer),BroadcastChan:make(chan*packets.Packet),RegisterChan:make(chanClientInterfacer),UnregisterChan:make(chanClientInterfacer),}}func(h*Hub)Run(){log.Println("Awaiting client registrations")for{select{caseclient:=<-h.RegisterChan:client.Initialize(uint64(len(h.Clients)))caseclient:=<-h.UnregisterChan:h.Clients[client.Id()]=nilcasepacket:=<-h.BroadcastChan:forid,client:=rangeh.Clients{ifid!=packet.SenderId{client.ProcessMessage(packet.SenderId,packet.Msg)}}}}}// Creates a client for the new connection and begins the concurrent read and write pumpsfunc(h*Hub)Serve(getNewClientfunc(*Hub,http.ResponseWriter,*http.Request)(ClientInterfacer,error),writerhttp.ResponseWriter,request*http.Request){log.Println("New client connected from",request.RemoteAddr)client,err:=getNewClient(h,writer,request)iferr!=nil{log.Printf("Error obtaining client for new connection: %v",err)return}h.RegisterChan<-clientgoclient.WritePump()goclient.ReadPump()}
The definitions and logic in this file are just direct translations of the architecture we discussed above. The Hub type maintains a map of connected clients, and has channels for registering and unregistering clients, as well as broadcasting messages. The ClientInterfacer interface defines the functions that a client must implement to be able to communicate with the hub.
The hub’s Run function is the main loop of the hub, where it listens for messages on the channels and processes them accordingly. The keen-eyed among you will notice that we are initializing each client with an ID equal to the length of the Clients map. This is a naive way to give each client a unique ID, but it has an enormous issue which we will have to address in a future post (since this post will be too long and arduous if we do it now). Try and think about what the issue might be, but for now it can be our little secret.
Creating the WebSocketClient
Before we can create our websockets implementation of the client interfacer, we need to install a package to help us work with websockets. We will be using the Gorilla WebSocket package, which is a popular package for working with websockets in Go. To install it, run the following command in your terminal:
$ cd server # If you're not already in the server directory$ go get github.com/gorilla/websocket
In case we ever want to create more implementations, we will create a clients folder inside our internal/server folder, and create a new file called websocket.go inside there. I am going to show a skeleton of this new file, and then run by the implementation of each function from the ClientInterfacer interface in the next steps.
Ok, first let’s look at the type definition itself for the WebSocketClient type. This will be a struct that contains the necessary fields for the websocket connection to keep its state. The implementation will depend on these fields.
A lot of this is self-explanatory, especially if you compare with the diagram at the beginning of this post. The hub field is a reference to the hub which created this client. The sendChan is a channel that holds packets to be sent to the client. We are also using the built-in log package to log messages to the console, since it can get tricky to keep track of what’s happening in the server without it.
This is a static function, not required by the interface, but makes it easy to create a new websocket client from an HTTP connection (which is what the main server will receive from each new Godot connection). We use the upgrader to upgrade the HTTP connection to a websocket connection. We then create a new WebSocketClient struct and return it. Note we are using a buffered channel for the sendChan. This means that the channel can hold up to 256 packets before it blocks. This is a good way to prevent the server from blocking if the client is slow to read packets.
These are all pretty straightforward, and I think the code speaks for itself. I will point out that our logger is now prefixed with the client’s ID, so we can easily see which client is doing what (invaluable when we have multiple clients connected). We don’t know what we want to do with incoming messages yet, so we leave ProcessMessage empty for now to satisfy the interface.
These functions are used to queue messages up to be sent to the client. We use a select statement to send the message to the channel, but if the channel is full, we drop the message and log a warning.
The difference between SocketSend and SocketSendAs is that SocketSendAs allows us to specify a sender ID. This is useful when we want to forward a message we received from another client, and the Godot client can know who it came from easily.
These functions are used to forward messages to other clients. PassToPeer forwards a message to a specific client, while Broadcast is just a convenience function to queue a message up to be passed to every client except the sender by the hub.
/server/internal/server/clients/websocket.go
func(c*WebSocketClient)ReadPump(){deferfunc(){c.logger.Println("Closing read pump")c.Close("read pump closed")}()for{_,data,err:=c.conn.ReadMessage()iferr!=nil{ifwebsocket.IsUnexpectedCloseError(err,websocket.CloseGoingAway,websocket.CloseAbnormalClosure){c.logger.Printf("error: %v",err)}break}packet:=&packets.Packet{}err=proto.Unmarshal(data,packet)iferr!=nil{c.logger.Printf("error unmarshalling data: %v",err)continue}// To allow the client to lazily not set the sender ID, we'll assume they want to send it as themselvesifpacket.SenderId==0{packet.SenderId=c.id}c.ProcessMessage(packet.SenderId,packet.Msg)}}
Here is one of two functions that directly interfaces with the websocket connection from the Godot client. It is responsible for reading messages from the websocket and processing them. We use the proto package to convert the raw bytes into a Packet struct (we saw this in the last post). We then call ProcessMessage with the sender ID and the message. Notice how we defer a closure of the client (we will see the code for this soon) so that we can clean up the connection if an error occurs or the loop breaks.
Here’s the other function that talks directly to Godot. It reads off packets we’ve queued in the send channel, converts them to bytes, and sends them down the wire. It is important to note that we are creating a binary message writer, since protobuf messages are binary. We also append a newline character to the end of every message to help prevent messages from “sticking” together.
Finally, we have the Close function we deferred in the ReadPump and WritePump functions. This function is responsible for cleaning up the client’s connection, and also unregistering the client from the hub (so that the hub may in turn remove it from its list of clients). We aren’t really doing anything meaningful with the reason string yet, but it’s there for future use.
Tying it all together
Now that we have our Hub and WebSocketClient types set up, all that’s left on the server side is to tie everything together in our main.go file we created in the last post.
/server/cmd/main.go
packagemainimport("flag""fmt""log""net/http""server/internal/server""server/internal/server/clients")var(port=flag.Int("port",8080,"Port to listen on"))funcmain(){flag.Parse()// Define the game hubhub:=server.NewHub()// Define handler for WebSocket connectionshttp.HandleFunc("/ws",func(whttp.ResponseWriter,r*http.Request){hub.Serve(clients.NewWebSocketClient,w,r)})// Start the servergohub.Run()addr:=fmt.Sprintf(":%d",*port)log.Printf("Starting server on %s",addr)err:=http.ListenAndServe(addr,nil)iferr!=nil{log.Fatalf("Failed to start server: %v",err)}}
This is all pretty in-line with the diagram we saw at the beginning of this post. The only thing to note is that this is a generic TCP server, but the handler we have defined for the /ws route will upgrade the connection to a websocket connection. This is where we will be sending our Godot clients.
We can now run the server by hitting F5 in VS Code, or running go run cmd/main.go in the terminal. If you see the following output in the debug console, then you’re good to go:
2024/11/09 12:00:58 Starting server on :8080
2024/11/09 12:00:58 Awaiting client registrations
This is a good place to stop for now. In the next post, we’ll integrate the Godot client with our server, allowing it to establish connections and send packets, bringing us one step closer to a functional multiplayer game. Stay tuned!
If you have any questions or feedback, I’d love to hear from you! Either drop a comment on the YouTube video or join the Discord to chat with me and other game devs following along.