Gaming

Build a Multiplayer Game in Go with PubNub

9 min read Chandler Mayo on Aug 20, 2018

Playing a game by yourself is not nearly as fun as playing with your friends. For that reason, massively multiplayer online games (MMOs) are a huge revenue stream that continues to grow rapidly every year. In June 2018 consumers spent an estimated $9.1 billion digitally across all platforms [on MMOs], compared to $7.9 billion last June.

SuperData MMO MOBA Report 2016

The design of online games can range from simple text-based environments to incredibly complex virtual worlds. A few other examples of popular online multiplayer games types include first-person shooters (FPS), real-time strategy games (RTS), and multiplayer online battle arena games (MOBA).

PubNub is perfect for powering multiplayer games because it makes it easier to build a fast and scalable real-time game. In this tutorial, PubNub is used to manage the players in the game lobby and to transmit game data. The game will be built in Go using the PubNub Go SDK.

PubNub usage in Space Race

Getting Started

The source code for the finished game can be found here. This tutorial covers how to build a two player racing game from scratch using progress bars to represent the players and PubNub to transmit game data.

Space Race Demo

Before our players can play a game together there needs to be a way to organize the players and coordinate the game. There’s a significant amount of logic involved building a robust game lobby and there are a lot of factors to consider. Who is the host or leader and how is this determined? How many players are there? When does the game start? How do you prevent players from cheating? The more complex the game the more these questions need to be considered in order to create a positive experience for the players.

The lobby is kept simple for this game:

  1. The first player to enter a game lobby will become the host and then wait for a guest to join.
  2. The second player to enter a game lobby will become the guest and transmits their name.
  3. The host receives the guest name and then replies with the hostname.
  4. After the name exchange, the game starts.
  5. All of the information is exchanged using PubNub.

You’ll first need to sign up for a PubNub account. Once you sign up, you can get your unique PubNub keys from the PubNub Admin Dashboard.

PubNub Signup

Install the latest version of Go and setup your $GOPATH.

Download the dependencies used in this project.

go get github.com/pubnub/go
go get github.com/gosuri/uiprogress
go get github.com/nsf/termbox-go

Create a new directory and inside that directory create file main.go.

In main.go import the PubNub Go package and set the configuration. After PubNub is initialized, call the newLobby() function that will be used to create the game session.

Get your unique PubNub keys from the PubNub Admin Dashboard. Replace “pub-key” and “sub-key” with your keys.

You must enable the Channel Presence feature for your keys. Presence can be enabled per key in your Admin Dashboard.

package main
import (
   "fmt"
   pubnub "github.com/pubnub/go"
)
func main() {
   config := pubnub.NewConfig()
   config.SubscribeKey = "sub-key"
   config.PublishKey = "pub-key"
   pn := pubnub.NewPubNub(config)
   fmt.Println("Welcome to Space Race!")
   newLobby("", "", pn) // Create lobby for a new game.
}

Create file lobby.go in the same directory as your main.go file. Import the packages used for the lobby, including the PubNub Go SDK.

package main
import (
  "bufio"
  "fmt"
  "os"
  "strings"
  pubnub "github.com/pubnub/go"
)

Add function userInput() in lobby.go to ask the user what lobby they want to join and what their name is by reading from the standard input.

func userInput(lobby string, username string) (string, string) {
  var (
    newlobby    string
    newUsername string
    err         error
  )
  reader := bufio.NewReader(os.Stdin)
  if lobby == "" { // Ask for lobby name.
    fmt.Print("Enter Lobby Name: ")
  } else {
    fmt.Print("Enter Lobby Name (" + lobby + "): ")
  }
  newlobby, err = reader.ReadString('
') if err != nil { panic(err) } newlobby = strings.Replace(newlobby, "
", "", -1) // Convert CRLF to LF. if newlobby != "" { // Use last lobby name when the player does not provide a lobby. lobby = newlobby } if username == "" { // Ask for username. fmt.Print("Enter Your Name: ") } else { fmt.Print("Enter Your Name (" + username + "): ") } newUsername, err = reader.ReadString('
') if err != nil { panic(err) } newUsername = strings.Replace(newUsername, "
", "", -1) // Convert CRLF to LF. if newUsername != "" { // Use last username when the player does not provide a username. username = newUsername } if (lobby == "") || (username == "") { // The player must have a lobby and username. fmt.Println("You Must Provide a Lobby and Name! ") userInput(lobby, username) // Start over. } return lobby, username }

Add function hereNow() in lobby.go that makes a PubNub HereNow() call and returns the total occupancy for the channel. This will enable you to determine who will be the host in a lobby and block players from entering a game in progress. You must enable the channel presence feature for your keys. Presence can be enabled per key in your Admin Dashboard.

func hereNow(channel string, pn *pubnub.PubNub) int { // Return count of occupants for a channel.
  res, _, err := pn.HereNow().
    Channels([]string{channel}).
    Execute()
  if err != nil {
    panic(err)
  }
  return res.TotalOccupancy
}

Create function newLobby() and initialize variables to store the host name, guest name, and the host status. Create a map for the player data that will be published with PubNub and then get the values from the userInput() function. Add this function in lobby.go.

func newLobby(lobby string, username string, pn *pubnub.PubNub) {
  var (
    guestName string
    hostName  string
    isHost    bool
  )
  data := make(map[string]interface{})
  lobby, username = userInput(lobby, username)
}

Create a new listener for the game lobby, add the listener, and subscribe inside the newLobby() function. Make a Go channel so you can remove the listener, unsubscribe from the lobby channel, and start the game when the players are ready from outside the Goroutine that will receive messages from PubNub. “_lobby” is appended to the channel name for the lobby so you can get the presence of the players in the lobby and in the game separately.

lobbylistener := pubnub.NewListener()
  endLobby := make(chan bool)
  go func() {
    for {
      select {
      case status := <-lobbylistener.Status:
        switch status.Category {
        case pubnub.PNConnectedCategory:
        }
      case message := <-lobbylistener.Message:
      }
    }
  }()
  pn.AddListener(lobbylistener)
  pn.Subscribe().
    Channels([]string{lobby + "_lobby"}).
    Execute()
  <-endLobby // Remove the listener and unsubscribe from the channel used for the game lobby.
  pn.RemoveListener(lobbylistener)
  pn.Unsubscribe().
    Channels([]string{lobby + "_lobby"}).
    Execute()
  startGame(isHost, lobby, strings.Title(hostName), strings.Title(guestName), pn)

In the newLobby() function and inside pubnub.PNConnectedCategory: make two calls to the hereNow() function you just made. One call is for the occupants of the game channel and the other is for the occupants of lobby channel.

If there are players in the game channel or if the lobby is full, remove the listener, unsubscribe from the lobby channel, and start over and ask the user for a new lobby name.

If there are no players in the lobby, the player will become the host and will need to wait for a message from the guest to start the game.

If there is a player already in the lobby, the player becomes the guest and publishes a message to the lobby channel with their name to signal to the host that the game is ready to start.

game_occupants := hereNow(lobby, pn)
lobby_occupants := hereNow(lobby+"_lobby", pn)
if game_occupants > 0 || lobby_occupants > 2 {
  fmt.Println("Game already in progress! Please try another lobby.")
  fmt.Print("Game: ")
  fmt.Println(game_occupants)
  fmt.Print("Lobby: ")
  fmt.Println(lobby_occupants)
  pn.RemoveListener(lobbylistener)
  pn.Unsubscribe().
    Channels([]string{lobby + "_lobby"}).
    Execute()
  newLobby(lobby, username, pn) // Start over if the game is in progress.
  return
}
if lobby_occupants == 0 {
  isHost = true
  fmt.Println("Waiting for guest...")
} else if lobby_occupants == 1 { // Player will be guest. Send username to host.
  fmt.Println("Waiting for host...")
  data["guestName"] = username
  guestName = username
  pn.Publish().
    Channel(lobby + "_lobby").
    Message(data).
    Execute()
}

In the newLobby() function and inside case message := <-lobbylistener.Message: exchange username information and start the game.

If the message received was the guest name, send the hhostnameand then start the game.

If the message received was the hhostname start the game.

if msg, ok := message.Message.(map[string]interface{}); ok {
  if !isHost {
    if val, ok := msg["hostName"]; ok { // When the guest receives the host username then the game is ready to start.
      hostName = val.(string)
      endLobby <- true
      return
    }
  } else {
    if val, ok := msg["guestName"]; ok { // Receives the guest username then the host sends the host username and starts a game.
      guestName = val.(string)
      data["hostName"] = username
      hostName = username
      pn.Publish().
        Channel(lobby + "_lobby").
        Message(data).
        Execute()
      endLobby <- true
      return
    }
  }
}

Your lobby.go file should look like this.

Create a file named game.go in the same directory as your main.go and lobby.go file. Import the packages you will need, including the PubNub Go SDK, uiprogress, and Termbox.

package main
import (
  "fmt"
  "os"
  "time"
  pubnub "github.com/pubnub/go"
  "github.com/gosuri/uiprogress"
  term "github.com/nsf/termbox-go"
)

Create function countdown() in game.go that will print out messages to tell the players the game is about to start with a three second countdown.

func countdown(hostName string, guestName string) {
  fmt.Println("")
  fmt.Println("The game is about to start between " + hostName + " and " + guestName + "!")
  fmt.Println("Alternate pressing SPACE and the RIGHT ARROW KEY to race!")
  fmt.Println("")
  time.Sleep(750 * time.Millisecond)
  fmt.Print("3... ")
  time.Sleep(1 * time.Second)
  fmt.Print("2... ")
  time.Sleep(1 * time.Second)
  fmt.Print("1...  ")
  time.Sleep(1 * time.Second)
  fmt.Print("Race!!")
  fmt.Println("")
  time.Sleep(500 * time.Millisecond)
}

Create function startGame() in game.go and initialize variables for keypress state, player progress, and the winner name. Create a map for the game data that will be published with PubNub and then call the countdown() function.

func startGame(isHost bool, lobby string, hostName string, guestName string, pn *pubnub.PubNub) {
  var (
    spaced   bool
    progress int
    winner   string
  )
  data := make(map[string]interface{})
  countdown(hostName, guestName) // Countdown before starting game.
}

In the startGame() function and after calling the countdown() function, call uiprogress.Start() and draw the progress bars for the host and guest.

uiprogress.Start()
hostBar := uiprogress.AddBar(100).AppendCompleted()
hostBar.AppendFunc(func(b *uiprogress.Bar) string {
  if isHost {
    return hostName + " (you)"
  }
  return hostName + " (host)"
})
guestBar := uiprogress.AddBar(100).AppendCompleted()
guestBar.AppendFunc(func(b *uiprogress.Bar) string {
  if !isHost {
    return guestName + " (you)"
  }
  return guestName + " (guest)"
})

After adding the progress bars create a new listener for the game, add the listener, and subscribe. Set the host and guest progress bars with the received message values as the progress.

gamelistener := pubnub.NewListener()
  go func() {
    for {
      select {
      case message := <-gamelistener.Message:
        if msg, ok := message.Message.(map[string]interface{}); ok {
          if val, ok := msg["guestProgress"]; ok {
            guestBar.Set(int(val.(float64)))
          }
          if val, ok := msg["hostProgress"]; ok {
            hostBar.Set(int(val.(float64)))
          }
        }
      }
    }
  }()
  pn.AddListener(gamelistener)
  pn.Subscribe().
    Channels([]string{lobby}).
    Execute()

After the game listener, create a keypress listener to intercept key events and then send messages when the progress bar should advance.

If the current value for either progress bar equals that progress bar’s total, set the winner variable to that player’s name and break the keyPressListenerLoop.

Players can exit the game by pressing ESC and breaking the keyPressListenerLoop.

err := term.Init()
  if err != nil {
    panic(err)
  }
  defer term.Close()
keyPressListenerLoop:
  for {
    if hostBar.Current() == hostBar.Total { // Check for winner.
      winner = hostName
      break keyPressListenerLoop
    } else if guestBar.Current() == guestBar.Total {
      winner = guestName
      break keyPressListenerLoop
    }
    event := term.PollEvent()
    switch {
    case event.Key == term.KeyEsc:
      break keyPressListenerLoop
    case event.Key == term.KeySpace:
      if !spaced {
        progress = progress + 1
        if isHost {
          if hostBar.Current() < hostBar.Total {
            data["hostProgress"] = progress
            pn.Publish().
              Channel(lobby).
              Message(data).
              Execute()
          }
        } else {
          if guestBar.Current() < guestBar.Total {
            data["guestProgress"] = progress
            pn.Publish().
              Channel(lobby).
              Message(data).
              Execute()
          }
        }
        spaced = true
      }
    case event.Key == term.KeyArrowRight: // Prevents the player from using keyrepeat to cheat.
      spaced = false
    }
  }

After keyPressListenerLoop, call uiprogress.Stop(), call term.Close(), unsubscribe from the game channel, print the winner name, and then exit the game.

If a player exits the game by pressing ESC, publish a message to set the other players progress bar to be full so they win by forfeit.

uiprogress.Stop()
term.Close()
pn.Unsubscribe().
  Channels([]string{lobby}).
  Execute()
fmt.Println("")
if winner != "" {
  fmt.Println(winner + " won the game!")
} else {
  fmt.Println("You left the game.") // The other player wins if the player leaves the game.
  if isHost {
    data["guestProgress"] = guestBar.Total
  } else {
    data["hostProgress"] = hostBar.Total
  }
  pn.Publish().
    Channel(lobby).
    Message(data).
    Execute()
}
fmt.Println("")
fmt.Println("Thanks for playing!")
os.Exit(0)

Your game.go file should look like this.

Build and run the game.

go run *.go

OR

go build -o space-race
./space-race
Space Race Game

How To Play

  1. The first player to enter a lobby becomes the host. The second player to enter becomes the guest.
  2. Both players need to enter the same lobby name.
  3. When both players have joined a lobby the game will start after a 3 second delay (get your fingers ready).
  4. After the game starts, alternate pressing SPACE and the RIGHT ARROW KEY to advance your progress bar.
  5. The first player to 100% wins the game.
  6. You can leave the game while playing by pressing ESC. If you leave the game then the other player wins automatically.

Limitations

  • If the terminal window is too small, or if the font is too big, the progress bars won’t render correctly. Make the window bigger and then press command+k to refresh.
  • The lobby is kept simple by design so it can be used as a seed project. The lobby may not always be able to start a game in some edge cases. If you have problems starting a game try restarting the game for both players and use a new lobby name.
0