Continuing
familiarity with the programming language Go (golang). Last time we looked at the basic constructs of the language. In this article I want to show the use of gorutin and channels. And, of course, to demonstrate all of this on a real application, in this case a multiplayer game. We will not consider the whole game, but only that part of the backend, which is responsible for the network interaction between players through the WebSoket.
Turn-based game 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 the first work with WebSoket, so do not judge strictly. If you have comments and reasonable criticism, I will listen with pleasure.
The algorithm is as follows. Players connect to the game room (room). Upon receipt of a new move from the player, the room is notified of this (via the channel) and will trigger a special method “update game state” on all players registered in the room. It's pretty simple.
')
Schematically, this can be represented as:
Communication with the player takes place through the “connection” layer object (in Fig. PConn1, pConn2), which expands the Player type (by embedding it in itself) and adds methods for communication.
By the way, I will sometimes use the word “object” as a designation of a certain entity, and not in the sense of the object OOP (since they differ slightly in go).
Consider the project structure:
/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.
In the package / game / is 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.
A 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 the * Player pointer). To connect players is the function PairPlayers. Further, here are some of the features needed to control the game. Here they do nothing, just 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 the victory to the enemy.
UPD: It later turned out that having only one Player structure for the game is not very convenient. It is better to make the structure of the Game to which to connect players. 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] }
This entry point into the program. The main () function starts the server and registers two handlers: the homeHandler for the main page, which only displays the home.html template and the more interesting wsHandler, which establishes the WebSocket connection and registers the player.
For WebSocket, we use a package from the Gorilla Toolkit ("github.com/gorilla/websocket"). In the beginning we create a new connection (ws). Next, we get the player name from the URL parameter. Then, look for a free room (with one player). If there is no room, then create it. After that, we create a player and a connection object for the player (pConn). We transfer our web socket, player and room to the connection. More precisely, we transfer pointers to these objects. And the last step is connecting our connection to the room. This is done by sending our object to the channel join room.
Gorutiny and channels
A small educational program about Gorutin and channels. Gorutinas are something like threads, they are executed in parallel. It is enough to put the go operator before calling the function and the program will not wait until the function is completed, but will immediately go on to the next instruction. Gorutin are very lightweight, not demanding of memory. Communication with gorutiny occurs through the channels - a special type of data. Channels are like pipe in Unix. You can think of channels as a pipe: we put something at one end, we get something from the other. Channel type can be any. For example, you can create a string channel and send messages to it. It is even possible to create a channel of channels. We need to go deeper.
A small example. You can run here
http://play.golang.org/p/QUc458nBJY
Imagine that you want to send the same request to multiple servers and get an answer from someone who responds faster. And do not want to wait for the rest. You can do it like this:
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 gorutinakh, we start requests to servers. The operation is not blocking, so after the line with the go operator the program goes 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 execution of the request may take different times, 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 you send to the channel (if there are free places), you do not have to wait until someone receives the data. In this case, this could not be done, because the program ends anyway. But, if it was, for example, a website that runs all the time, and the channel would not be buffered, two of the three Gorutin would hang, waiting for reception at the other end.
So back to our sheep.
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 }
What is the interlayer connection? This is a playerConn object that contains pointers: to the web socket, to the player, and to the room. In the case of a player, we wrote just * game.Player. This means that we “embed” 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 gorutin (go operator), i.e. in parallel (not blocking manner) and in an infinite loop, listens to a web socket for messages. When received, it is passed to the player in the Command method (make a move). And then he sends a signal to the room to “update the state of the game for all players.” When an error occurs (for example, breaking a web socket), the gorutin exits the loop, sends a “give up” signal to the room’s channel, closes the web socket and ends.
With 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
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 to control. Channels can have a different type, this is something that can be sent to a channel and received from it. For example, the updateAll channel contains a boolean value and serves only to indicate whether the game state should be updated. It doesn’t matter to us that it is transmitted to us, we only react to its activation. True, it is considered good practice to use an empty struct {} structure in this case. But a specific connection is transmitted to the join channel (more precisely, a pointer to it). We save it in our room in playerConns as the key of the map structure.
When creating a new room with NewRoom (), we initialize the channels and run the run () method in the mountain (go room.run ()). It performs an infinite loop that listens to several channels at the same time and, upon receiving a message in any of them, performs certain actions. Listening to several channels is implemented using the select-case construct. In this case, the operation is blocking. Those. we will wait until there is a message from any channel, then we move on to the next iteration of the cycle and wait again. But, if the default: section had the select: construct, then the operation would be non-blocking, and if there were no messages, the default block would be executed, and then exit from the select. In this case, it is meaningless, but there is such a possibility.
If the join channel works, we register the connection (player) in the room. If the second player connects, we “pair” the players and remove the room from the free list. When leave is triggered, we delete the connection, and execute the “surrender” method of the player. And if there are no len (r.playerConns) == 0 players left in the room, then we generally close the room by exiting the loop (goto Exit). Yes, the go language has a goto instruction. But do not worry, it is used very rarely, and only to exit from structures of type for or select. For example, to exit a nested loop. In this case, if you set a break, it will interrupt the select construct, not the for loop.
And finally, when the updateAll channel triggers (the transmitted value is not important to us, so we do not save it anywhere: case <-r.updateAll), all players registered in the room are called “update game state”.
That's the whole network part. In a real project, it became a bit more complicated. The channels responsible for the chat and the timer were added, as well as a certain structure of the request-response (on the basis of JSON).
Having such a backend, it is quite simple to make clients on different devices. I decided to make a client on HTML5 for cross-platform. Although, in iOS, the game constantly crashes. It can be seen that websocket support is not fully implemented.
Thanks for attention. Program on Go, it's fun.
References: