DEV Community

Cover image for MultiPlayer Game with Nats KV Store And WebSocket [Part 1]
Ramón Berrutti
Ramón Berrutti

Posted on

MultiPlayer Game with Nats KV Store And WebSocket [Part 1]

What are you going to learn?

  • The power of Nats Key-Value Store.
  • Create a simple game using Nats. In this case, the Jokenpo game.
  • Nats Authorizations and Permissions.
  • Play the game through the browser connecting to Nats server using Websocket.

Jokenpo Game

Jokenpo, also known as Rock-Paper-Scissors, is a simple hand game between two players. The game has three possible moves: rock, paper, and scissors.

The rules are the follows:

  • Rock beats scissors: If one player chooses rock and the other chooses scissors, the player who chose rock wins.
  • Scissor beats paper: If one player chooses scissors and the other chooses paper, the player who chose scissor wins.
  • Paper beats rock: If one player chooses paper and the other chooses rock, the player who chose paper wins.

The game is typically played by counting to three and simultaneously revealing the chosen move.

The winner of each round is determined based on the rules mentioned above.


Game Architecture

A single Nats server with a small Golang application is used to handle the game logic.

In the future, Nats may allow us to run some Lua scripts inside the server.

There are two approaches to storing the game state:

  • Single KV store for all the games.
  • Single KV store for each game.

For this tutorial, a single KV store for all the games, so the game is simple enough.

Our model for the KV will consist of the following keys:

  • $KV.jokenpo.{game_id}: Store all the information about the game in JSON format.
  • $KV.jokenpo.{game_id}.choice1: Store the choice of player 1.
  • $KV.jokenpo.{game_id}.choice2: Store the choice of player 2.

In future versions of Nats, hashmaps will be allowed, and the game state could be stored in a single key without serializing the data as JSON.

Each KV store is a stream, and a consumer can be created to listen to the changes in the game state.

This is similar to the KV Watch option, but multiple applications can pull messages and acknowledge the message explicitly.

The choice1 and choice2 suffix keys store the players' choices, which are used to determine the game's winner.


Let's do it.

Initial structure for our game state.

// JokenpoGameState represents the game state.
type JokenpoGameState struct {
 Chose1 bool `json:"chose1"`
 Chose2 bool `json:"chose2"`

 Win1 int `json:"wins1"`
 Win2 int `json:"wins2"`
}
Enter fullscreen mode Exit fullscreen mode

Creating the KV Store

nats kv add jokenpo
Enter fullscreen mode Exit fullscreen mode
 kv, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
  Bucket:  "jokenpo",
 })
Enter fullscreen mode Exit fullscreen mode

For now, create the game using the Nats CLI

nats kv create jokenpo game-1 '{}'
echo -n "" | nats kv create jokenpo game-1.choice1
echo -n "" | nats kv create jokenpo game-1.choice2
Enter fullscreen mode Exit fullscreen mode

The pipe is needed here to create an empty value for the choice keys because the Nats CLI does not offer this option.

The durable consumer processes each player's choices:

  nats consumer add --filter "\$KV.jokenpo.*.*" --ack explicit --deliver all --pull --defaults  KV_jokenpo choices-consumer
Enter fullscreen mode Exit fullscreen mode
 con, err := js.CreateOrUpdateConsumer(ctx, fmt.Sprintf("KV_%s", kv.Bucket()), jetstream.ConsumerConfig{
  Durable:       "choices-consumer",
  DeliverPolicy: jetstream.DeliverAllPolicy,
  AckPolicy:     jetstream.AckExplicitPolicy,
  FilterSubject: fmt.Sprintf("$KV.%s.*.*", kv.Bucket()),
 })
Enter fullscreen mode Exit fullscreen mode

Finally and not less importantly, our consumer handler processes the player's choices:

  • Use the subject to get the game id and the player.
  • When a player makes a choice, update the game state and check if both players have chosen an option.
  • If both players choose an option, calculate the winner, update the game state by incrementing the winner's wins, and reset the choices.
sub, err := con.Consume(func(msg jetstream.Msg) {
  ack := true
  defer func() {
   if !ack {
    msg.Nak()
   } else {
    msg.Ack()
   }
  }()
  choice := string(msg.Data())

  // Ignore empty choices and invalid options.
  if choice == "" || (choice != "rock" && choice != "paper" && choice != "scissors") {
   return
  }

  // Get the original key:
  key := strings.TrimPrefix(msg.Subject(), subPrefix)

  // Grab the gameId and the player from the key.
  gameId, slotId := getGameIdAndPlayerSlot(key)
  if gameId == "" || slotId == "" {
   return
  }

  // Get the game state.
  entry, err := kv.Get(ctx, gameId)
  if err != nil {
   return
  }

  // Unmarshal the game state.
  var state JokenpoGameState
  if err := json.Unmarshal(entry.Value(), &state); err != nil {
   return
  }

  // Check if both players have chosen.
  if slotId == "choice1" {
   state.Chose1 = true
  } else {
   state.Chose2 = true
  }

  // Update the game state when a player has chosen.
  encodeState, err := json.Marshal(state)
  if err != nil {
   ack = false
   return
  }
  rev, err := kv.Update(ctx, gameId, encodeState, entry.Revision())
  if err != nil {
   ack = false
   return
  }

  // If both players have chosen, calculate the winner.
  if state.Chose1 && state.Chose2 {
   // Get the other player's choice.
   otherSlot := "choice1"
   if slotId == "choice1" {
    otherSlot = "choice2"
   }

   otherChoiceEntry, err := kv.Get(ctx, fmt.Sprintf("%s.%s", gameId, otherSlot))
   if err != nil {
    ack = false
    return
   }
   otherChoice := string(otherChoiceEntry.Value())
   if otherChoice == "" {
    ack = false
    return
   }

   // Calculate the winner.
   winner := ""
   switch {
   case choice == otherChoice:
    winner = "draw"
   case choice == "rock" && otherChoice == "scissors",
    choice == "scissors" && otherChoice == "paper",
    choice == "paper" && otherChoice == "rock":
    winner = slotId
   default:
    winner = otherSlot
   }

   // Update the game state with the winner.
   if winner == "choice1" {
    state.Win1++
   } else if winner == "choice2" {
    state.Win2++
   }

   state.Chose1 = false
   state.Chose2 = false

   encodeState, err := json.Marshal(state)
   if err != nil {
    ack = false
    return
   }

   if _, err := kv.Update(ctx, gameId, encodeState, rev); err != nil {
    ack = false
    return
   }

   // Reset the choices.
   _, _ = kv.Put(ctx, fmt.Sprintf("%s.choice1", gameId), []byte(""))
   _, _ = kv.Put(ctx, fmt.Sprintf("%s.choice2", gameId), []byte(""))
  }
 })
Enter fullscreen mode Exit fullscreen mode

Testing the Game

Watch for changes in the game state in a terminal with Nats CLI.

nats kv watch jokenpo game-1
Enter fullscreen mode Exit fullscreen mode

Choose an option for each player in another terminal:

nats kv put jokenpo game-1.choice1 rock
nats kv put jokenpo game-1.choice2 scissors
Enter fullscreen mode Exit fullscreen mode

The game state updates, and the winner of the game is player 1.

PUT jokenpo > game-1: {}
PUT jokenpo > game-1: {"chose1":true,"chose2":false,"wins1":0,"wins2":0}
PUT jokenpo > game-1: {"chose1":true,"chose2":true,"wins1":0,"wins2":0}
PUT jokenpo > game-1: {"chose1":false,"chose2":false,"wins1":1,"wins2":0}
Enter fullscreen mode Exit fullscreen mode

Choose a new option for each player:

nats kv put jokenpo game-1.choice1 rock
nats kv put jokenpo game-1.choice2 paper
Enter fullscreen mode Exit fullscreen mode

There are new updates for the game state, and the winner of the game is player 2.

PUT jokenpo > game-1: {"chose1":true,"chose2":false,"wins1":1,"wins2":0}
PUT jokenpo > game-1: {"chose1":true,"chose2":true,"wins1":1,"wins2":0}
PUT jokenpo > game-1: {"chose1":false,"chose2":false,"wins1":1,"wins2":1}
Enter fullscreen mode Exit fullscreen mode

In the following parts:

  • Create a simple web application to play the game using Websockets to connect to the Nats server because using the Nats CLI is not fun.
  • Prevent race conditions with the game state and the player's choices. HINT: Revisions.
  • Add some features such as Rounds, Chat Rooms, and more.

Contact Me

Any suggestions feel free to reach me on LinkedIn: https://www.linkedin.com/in/ramonberrutti/

Top comments (0)