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"`
}
Creating the KV Store
nats kv add jokenpo
kv, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: "jokenpo",
})
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
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
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()),
})
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(""))
}
})
Testing the Game
Watch for changes in the game state in a terminal with Nats CLI.
nats kv watch jokenpo game-1
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
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}
Choose a new option for each player:
nats kv put jokenpo game-1.choice1 rock
nats kv put jokenpo game-1.choice2 paper
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}
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)