Making multiplayer games on Go and WebSocket
- Tutorial
We continue our acquaintance with the Go programming language (golang). Last time we looked at the basic language constructs. In this article I want to show the use of goroutines and channels. And, of course, to demonstrate all this on a real application, in this case a multiplayer game. We will not consider the entire game, but only that part of the backend that is responsible for the network interaction between the players through WebSoket.
The game is turn-based, for two players. However, the techniques described below can be used to create other games, from poker to strategies.
By the way, this is my first game and first work with WebSockets, so do not judge strictly. If you have any comments and reasonable criticism, I will listen with pleasure.
The algorithm is as follows. Players connect to the game room (room). When a new move arrives from the player, the room is notified of this (via the channel) and calls the special method “update the game state” on all players registered in the room. Everything is pretty simple.
Schematically, this can be represented as follows:
Communication with the player takes place through the “connection” layer object (in Figure pConn1, pConn2), which extends the Player type (by embedding it into itself) and adds methods for communication.
By the way, I will sometimes use the word “object” as a designation of some entity, and not in the sense of OOP of an object (because in go they are slightly different).
Consider the structure of the project:
/wsgame/
/game/
game.go
/templates/
/utils/
utils.go
main.go
conn.go
room.go
In the root files (main package), our network interaction is implemented.
The / game / package contains the game engine itself. We will not consider it, here I will give only a few methods, in the form of mocks, which are needed to control the game.
The game
/game/game.go
package game
import (
"log"
)
type Player struct {
Name string
Enemy *Player
}
func NewPlayer(name string) *Player {
player := &Player{Name: name}
return player
}
func PairPlayers(p1 *Player, p2 *Player) {
p1.Enemy, p2.Enemy = p2, p1
}
func (p *Player) Command(command string) {
log.Print("Command: '", command, "' received by player: ", p.Name)
}
func (p *Player) GetState() string {
return "Game state for Player: " + p.Name
}
func (p *Player) GiveUp() {
log.Print("Player gave up: ", p.Name)
}
The player (Player) has an enemy, the same player (in our structure this is a * Player pointer). To connect players, use the PairPlayers function. Further, here are some of the functions needed to control the game. Here they do nothing, only display a message in the console. Command - send a command (make a move); GetState - get the current state of the game for this player; GiveUp - surrender and assign victory to the opponent.
UPD: Subsequently, it turned out that having only one Player structure for a game is not very convenient. It is better to make a Game structure to which players are connected. But that's another story.
Main
main.go
package main
import (
"github.com/alehano/wsgame/game"
"github.com/gorilla/websocket"
"html/template"
"log"
"net/http"
"net/url"
)
const (
ADDR string = ":8080"
)
func homeHandler(c http.ResponseWriter, r *http.Request) {
var homeTempl = template.Must(template.ParseFiles("templates/home.html"))
data := struct {
Host string
RoomsCount int
}{r.Host, roomsCount}
homeTempl.Execute(c, data)
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
ws, err := websocket.Upgrade(w, r, nil, 1024, 1024)
if _, ok := err.(websocket.HandshakeError); ok {
http.Error(w, "Not a websocket handshake", 400)
return
} else if err != nil {
return
}
playerName := "Player"
params, _ := url.ParseQuery(r.URL.RawQuery)
if len(params["name"]) > 0 {
playerName = params["name"][0]
}
// Get or create a room
var room *room
if len(freeRooms) > 0 {
for _, r := range freeRooms {
room = r
break
}
} else {
room = NewRoom("")
}
// Create Player and Conn
player := game.NewPlayer(playerName)
pConn := NewPlayerConn(ws, player, room)
// Join Player to room
room.join <- pConn
log.Printf("Player: %s has joined to room: %s", pConn.Name, room.name)
}
func main() {
http.HandleFunc("/", homeHandler)
http.HandleFunc("/ws", wsHandler)
http.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, r.URL.Path[1:])
})
if err := http.ListenAndServe(ADDR, nil); err != nil {
log.Fatal("ListenAndServe:", err)
}
}
This is the entry point to the program. The main () function starts the server and registers two handlers: homeHandler for the main page, which only displays the home.html template and the more interesting wsHandler, which establishes a WebSocket connection and registers the player.
For WebSocket, we use the package from the Gorilla Toolkit ("github.com/gorilla/websocket"). First we create a new connection (ws). Next, we get the player’s name from the URL parameter. Then, look for a free room (with one player). If there is no room, then create it. After that, create the player and the connection object for the player (pConn). We transfer our web socket, player and room to the connection. More precisely, we pass pointers to these objects. And the last step is to connect our connection to the room. This is done by sending our object to the join channel of the room.
Goroutines and channels
A small educational program about goroutines and channels. Goroutines are something like threads; they run in parallel. It is enough to put the go statement before the function call and the program will not wait until the function is completed, but will immediately proceed to the next instruction. Gorutins are very lightweight, not demanding on memory. Communication with goroutines occurs through channels - a special data type. Pipes are similar to pipe on Unix. You can imagine the channels as a pipe: we put something at one end, we get from the other. The type of channel can be any. For example, you can create a string channel and send messages to it. It’s even possible to create a channel feed. We need to go deeper.
A small example. You can run it here http://play.golang.org/p/QUc458nBJY
Imagine that you want to send the same request to several servers and get a response from the one who answers faster. And do not want to wait for the rest. You can do it this way:
package main
import "fmt"
func getDataFromServer(resultCh chan string, serverName string) {
resultCh <- "Data from server: " + serverName
}
func main() {
res := make(chan string, 3)
go getDataFromServer(res, "Server1")
go getDataFromServer(res, "Server2")
go getDataFromServer(res, "Server3")
data := <- res
fmt.Println(data)
}
We create a res channel where we will receive a response. And then, in separate goroutines, we launch requests to servers. The operation is not blocking, therefore, after a line with the go operator, the program moves to the next line. Dalle, the program is blocked on the line
data := <- res
, waiting for a response from the channel. As soon as the answer is received, we display it on the screen and the program ends. In this synthetic example, the response from Server1 will be returned. But in life, when the request may take a different time, the response from the fastest server will be returned.UPD: The number 3 in the creation of the channel indicates that the channel is buffered, size 3. This means that when sending to the channel (if there is free space), you do not need to wait until someone receives the data. In this case, this could not be done, because the program ends anyway. But, if it were, for example, a website that works constantly, and the channel would not be buffered, two of the three goroutines would freeze, waiting for reception at the other end.
So, back to our rams.
Compound
conn.go
package main
import (
"github.com/alehano/wsgame/game"
"github.com/gorilla/websocket"
)
type playerConn struct {
ws *websocket.Conn
*game.Player
room *room
}
// Receive msg from ws in goroutine
func (pc *playerConn) receiver() {
for {
_, command, err := pc.ws.ReadMessage()
if err != nil {
break
}
// execute a command
pc.Command(string(command))
// update all conn
pc.room.updateAll <- true
}
pc.room.leave <- pc
pc.ws.Close()
}
func (pc *playerConn) sendState() {
go func() {
msg := pc.GetState()
err := pc.ws.WriteMessage(websocket.TextMessage, []byte(msg))
if err != nil {
pc.room.leave <- pc
pc.ws.Close()
}
}()
}
func NewPlayerConn(ws *websocket.Conn, player *game.Player, room *room) *playerConn {
pc := &playerConn{ws, player, room}
go pc.receiver()
return pc
}
What is an interlayer? This is a playerConn object that contains pointers: to a web socket, to a player, and to a room. In the case of the player, we just wrote * game.Player. This means that we “embed” the Player and can call its methods directly on playerConn. Something like inheritance. When creating a new connection (NewPlayerConn), the receiver method is launched in a separate goroutine (go statement), i.e. in parallel (non-blocking manner) and in an endless loop listens to the web socket for messages. Upon receipt thereof, it is passed to the player in the Command method (make a move). And then it sends a signal to the room to "update the state of the game for all players." If an error occurs (for example, a web socket break), goroutin goes out of the loop, sends a “surrender” signal to the room channel, closes the web socket and ends.
Using the sendState () method, we send the current state of the game to this player.
Room
room.go
package main
import (
"github.com/alehano/wsgame/game"
"github.com/alehano/wsgame/utils"
"log"
)
var allRooms = make(map[string]*room)
var freeRooms = make(map[string]*room)
var roomsCount int
type room struct {
name string
// Registered connections.
playerConns map[*playerConn]bool
// Update state for all conn.
updateAll chan bool
// Register requests from the connections.
join chan *playerConn
// Unregister requests from connections.
leave chan *playerConn
}
// Run the room in goroutine
func (r *room) run() {
for {
select {
case c := <-r.join:
r.playerConns[c] = true
r.updateAllPlayers()
// if room is full - delete from freeRooms
if len(r.playerConns) == 2 {
delete(freeRooms, r.name)
// pair players
var p []*game.Player
for k, _ := range r.playerConns {
p = append(p, k.Player)
}
game.PairPlayers(p[0], p[1])
}
case c := <-r.leave:
c.GiveUp()
r.updateAllPlayers()
delete(r.playerConns, c)
if len(r.playerConns) == 0 {
goto Exit
}
case <-r.updateAll:
r.updateAllPlayers()
}
}
Exit:
// delete room
delete(allRooms, r.name)
delete(freeRooms, r.name)
roomsCount -= 1
log.Print("Room closed:", r.name)
}
func (r *room) updateAllPlayers() {
for c := range r.playerConns {
c.sendState()
}
}
func NewRoom(name string) *room {
if name == "" {
name = utils.RandString(16)
}
room := &room{
name: name,
playerConns: make(map[*playerConn]bool),
updateAll: make(chan bool),
join: make(chan *playerConn),
leave: make(chan *playerConn),
}
allRooms[name] = room
freeRooms[name] = room
// run room
go room.run()
roomsCount += 1
return room
}
The last part is the room. We create several global variables: allRooms - a list of all created rooms, freeRooms - rooms with one player (in theory, there should not be more than one), roomsCount - a counter of working rooms.
The room object contains the name of the room, playerConns - a list of connected connections (players) and several channels for control. Channels can have different types, this is something that can be sent to and received from a channel. For example, the updateAll channel contains a Boolean value and serves only to inform whether the game state needs to be updated. It doesn’t matter to us what is transmitted to it, we only react to its operation. True, it is considered good practice to use an empty struct {} in this case. But a specific connection (or rather a pointer to it) is transferred to the join channel. We save it in our room in playerConns as the key to the map structure.
When creating a new room using NewRoom (), we initialize the channels and run the run () method in goroutine (go room.run ()). It performs an infinite loop that listens to several channels simultaneously and, when a message is received in any of them, performs certain actions. Listening to multiple channels is implemented using the select-case construct. In this case, the operation is blocking. Those. we will wait until a message arrives from any channel, then go to the next iteration of the loop and wait again. But, if there were a default: section in the select construct, then the operation would be non-blocking and, in the absence of messages, the default block would be executed, and then exit from select. In this case, it is pointless, but there is such an opportunity.
If the join channel is triggered, we register this connection (of the player) in the room. If the second player connects, we “pair” the players and remove the room from the list of free ones. When leave is triggered, delete the connection, and perform the “surrender” method with the player. And if there are no players left in the room len (r.playerConns) == 0, then generally close the room by exiting the loop (goto Exit). Yes, go has a goto statement. But do not be alarmed, it is used extremely rarely, and only in order to exit structures like for or select. For example, to exit a nested loop. In this case, if you set break, it will abort the select construct, not the for loop.
And finally, when the updateAll channel is triggered (the transmitted value is not important to us, therefore we do not save it anywhere: case <-r.updateAll), the method "update the game state" is called for all players registered in the room.
That's the whole network part. In a real project, it got a little more complicated. Added channels responsible for chat and timer, as well as some kind of request-response structure (based on JSON).
Having such a backend is quite simple to make clients on different devices. I decided to make an HTML5 client for cross-platform. Although, in iOS, the game constantly crashes. It can be seen that websocket support is not fully implemented.
Thanks for attention. Programming in Go is fun.
References:
- Files on GitHub and demo games: https://github.com/alehano/wsgame