DEV Community

Cover image for Adventure: Building with NATS Jetstream KV Store -Part 5
Richard
Richard

Posted on • Edited on

Adventure: Building with NATS Jetstream KV Store -Part 5

Welcome!

Welcome to Part 5 of our series on the NATS JetStream KV Store! This time, we're going to build a little something - nothing too big just yet, but we're making progress. Stay tuned, because we'll get there!

Where did we leave off?

We did a bunch of CLI commands but let's try to put them to some actual use.

We're going to make Tic Tac Toe in the bash shell!


Terminal Tic Tac Toe

Say that five times fast… actually, it's not that hard. I just did it - did you? You did. Admit it, you totally did.

Alright, enough fun - let's move on! Time to get our setup ready.

First things first let's start fresh again.

# Start NATS Jetstream if you haven't already.
$  ./nats-server -js

# Delete all your buckets!
$  nats kv del <bucket>

# Create the bucket named tictactoe
$  nats kv add tictactoe
Enter fullscreen mode Exit fullscreen mode

It's time to design the main game loop for our Tic-Tac-Toe shell game. While you can simply copy the code, I'll also walk you through how it works. We'll implement five key functions:

Main

The main game loop.

Initialize Board

Resets the game board to its initial state.

Display Board

Displays the current state of the board.

Make Move

Handles moves for players X and O.

Check Winner

Determines if there's a winner. (Pretty self-explanatory!)

Let's get started!


Main Loop

Let's go over the main game loop.

# Main game loop
main() {
  # Displays the main menu and handles user input.
  echo "Welcome to Tic-Tac-Toe with NATS JetStream KV!"
  echo "1. Initialize/Reset Board"
  echo "2. Play Game"
  echo "3. Exit"
  echo "-------------------"

  while :; do
    # Infinite loop to keep showing the menu until the user exits.

    echo -n "Choose an option: "
    read choice
    # Reads the user's menu choice.

    case $choice in
      1)
        initialize_board
        ;;
      2)
        while :; do
          echo "Player $current_player's turn."
          echo "Enter your move (e.g., A1) or press 3 to display the board."
          echo -n "Your input: "
          read cell

          # Handle special commands
          if [[ $cell == "3" ]]; then
            # If the user enters "3", display the board.
            display_board
            continue
          fi

          # Make the move if input is not a special command
          if make_move $cell; then
            if check_winner; then
              # Check if there is a winner and respond if there is.
              current_player="X" # Reset the current player
              break              # Exit to the main menu
            fi
          fi
        done
        ;;
      3)
        echo "Goodbye!"
        exit 0
        ;;
      *)
        echo "Invalid option. Try again."
        ;;
    esac
  done
}
Enter fullscreen mode Exit fullscreen mode

This process is fairly straightforward. Here's how it works:

We start with an outer loop that continues until the user makes a choice between options 1–3:

  1. Clear the board for a new game.
  2. Start the game.
  3. Quit the program.

Once the game starts, we enter an inner loop, which serves as the main game loop. Here's what happens:
The loop pauses at the read command, waiting for the user to provide input.

If the input is the special character 3, it displays the board.
Otherwise, the input is passed to make_move. If the input is valid, it checks for a winner.

Depending on the input and the result the loop either restarts to process more moves, or it exits if a winner is detected.


Initialize the Board

Now we get to use some good ol' fashioned NATS Jetstream KV Store!

# KV bucket name
BUCKET="tictactoe"

# Resets the Tic-Tac-Toe board by initializing all cells in the bucket.
initialize_board() {
  echo "Initializing board..."

  for cell in A1 A2 A3 B1 B2 B3 C1 C2 C3; do
    # Sets each cell in the KV store to an empty space (" ").
    nats kv put $BUCKET $cell " " >/dev/null 2>&1

  done
  echo "Board initialized."
  # Displays the newly initialized board.
  display_board
}
Enter fullscreen mode Exit fullscreen mode

Alright, buckle up - this is where the magic happens, and it's simpler than you think!

We're going to loop through every cell of our Tic-Tac-Toe board and use the nats kv put command to stash an empty space for each one. Think of it like setting up a fresh board where every cell is just waiting for someone to make a move.

Each cell gets its own key-value pair in the bucket, like key=A1, Value=" ". Boom, instant empty board!

  • >/dev/null: This sneaky bit shushes the success messages from the nats kv put command. No bragging allowed here!
  • 2>&1: And this? It's like saying, "Oh, you messed up? Cool, but we're not telling anyone." Any errors get tossed into the void too.

Once the board is all set up, we'll proudly display it like a freshly baked cake.


Display Board

Oh, this one's a piece of cake! 🍰

Here's the deal: we've got a super simple function called display_board() that does exactly what it says-it shows off our Tic-Tac-Toe board in all its glory.

What's happening under the hood? We're just grabbing the values from JetStream using the nats kv get command for each cell (A1, A2, A3, and so on). Then, we're stitching them together in a way that looks like a proper Tic-Tac-Toe board.

Recognize the pattern? Yep, it's just echoing the values back with some fancy lines (--+---+--) to make it look legit. We use the raw flag to retrieve only the value of the Key from the KV Store - since NATS will give you more data (such as the revision number or time of the event etc) because it's awesome like that.

We don't need the extra data however; just the value.

# Function to display the board
display_board() {
  echo "Current Board:"
  echo "$(nats kv get $BUCKET A1 --raw 2>/dev/null) | $(nats kv get $BUCKET A2 --raw 2>/dev/null) | $(nats kv get $BUCKET A3 --raw 2>/dev/null)"
  echo "--+---+--"
  echo "$(nats kv get $BUCKET B1 --raw 2>/dev/null) | $(nats kv get $BUCKET B2 --raw 2>/dev/null) | $(nats kv get $BUCKET B3 --raw 2>/dev/null)"
  echo "--+---+--"
  echo "$(nats kv get $BUCKET C1 --raw 2>/dev/null) | $(nats kv get $BUCKET C2 --raw 2>/dev/null) | $(nats kv get $BUCKET C3 --raw 2>/dev/null)"
}
Enter fullscreen mode Exit fullscreen mode

Easy enough. What's next?


Make Move

Ok now we're getting to the game logic.

current_player="X"

# Function to make a move
make_move() {
  local cell=$1
  # The cell the player wants to make a move on that was entered.

  # Validate the cell
  if ! [[ $cell =~ ^[ABC][123]$ ]]; then
    # Checks if the input matches a valid cell format (e.g., A1, B2, C3).
    echo "Invalid cell! Please enter a valid cell (e.g., A1, B2, C3)."
    return 1
  fi

  # Check if the cell is already occupied
  local value=$(nats kv get $BUCKET $cell --raw 2>/dev/null)
  # Retrieves the current value of the cell.
  if [[ $value == "X" || $value == "O" ]]; then
    # If the cell is already occupied, show an error.
    echo "Cell $cell is already occupied. Choose another cell."
    return 1
  fi

  # Make the move
  nats kv put $BUCKET $cell $current_player
  # Updates the cell in the KV store with the current player's symbol.
  echo "Player $current_player moved to $cell."

  # Switch to the other player
  if [[ $current_player == "X" ]]; then
    # If the current player is X, switch to O.
    current_player="O"
  else
    # Otherwise, switch to X.
    current_player="X"
  fi

  display_board
  # Show the updated board after the move.
}
Enter fullscreen mode Exit fullscreen mode

Alright, this isn't too complex - it's mostly just JetStream functionality. Here's what happens step by step in make_move:

  • We take the cell argument passed to the function.
  • We validate it using a regex to ensure it's a valid board cell (A1 to C3).
  • We check if the cell is already occupied (i.e., it contains X or O).
  • If valid and unoccupied, we update the cell's key in the bucket with the current player's symbol (X or O).
  • Finally, we switch to the next player and display the updated board.

Check Winner

The only semi-complicated function…. but not really.

# Function to check for a winner
check_winner() {
  local board=()
  # Creates an array to store the current state of the board.
  for cell in A1 A2 A3 B1 B2 B3 C1 C2 C3; do
    board+=("$(nats kv get $BUCKET $cell --raw 2>/dev/null)")
    # Populates the board array with the values of each cell.
    # board=("X" "O" " " " " "X" "O" "O" " " "X") etc
  done

  # Winning combinations
  local win_patterns=(
    "0 1 2" "3 4 5" "6 7 8" # Rows
    "0 3 6" "1 4 7" "2 5 8" # Columns
    "0 4 8" "2 4 6"         # Diagonals
  )

  # We constructed the board AND it will look like this:
  # board=("X" "O" " " " " "X" "O" "O" " " "X")

  for pattern in "${win_patterns[@]}"; do
    # Iterates through each winning pattern.
    local i1=$(echo $pattern | cut -d' ' -f1)
    local i2=$(echo $pattern | cut -d' ' -f2)
    local i3=$(echo $pattern | cut -d' ' -f3)

    # local i1=$(echo $pattern | cut -d' ' -f1)
    # Just uses a delimeter of " " and grabs from a string.
    # In our case it's a pattern like "0 1 2"

    # local i1=$(echo $pattern | cut -d' ' -f1) echoes the pattern
    # and pipes it to cut which cuts using the delimeter ' '
    # so we get 0, 1, 2 and then -f1 grabs the first field. So 0.
    # Then we put it in local variable i1.

    # Check if the three cells match and are not empty (not " ")
    if [[ ${board[$i1]} == "${board[$i2]}" && ${board[$i2]} == "${board[$i3]}" && ${board[$i1]} != " " ]]; then

      # For "0 1 2" iimagine. Or "0 4 8
      # If all three cells match and are not empty, a player wins.

      echo "🎉 Player ${board[$i1]} wins! 🎉"
      initialize_board
      # Resets the board for the next game.
      echo "Returning to the main menu..."
      return 0
    fi
  done

  # Check for a tie (no cells contain " ")
  if [[ ! " ${board[*]} " =~ " " ]]; then
    # If no cells are empty, the game is a tie.
    display_board
    echo "It's a tie! 🤝"
    initialize_board
    echo "Returning to the main menu..."
    return 0
  fi

  return 1
  # If no winner or tie is found, the game continues.
}
Enter fullscreen mode Exit fullscreen mode

First we reconstruct the board locally - we iterate over the cell list and get the values from the NATS KV Store accordingly.

Then we set up the win conditions. We iterate over those win conditions and then check them against our local board. If there's a winner we show a celebration message and reset the board!

We then check for a tie and then reset the board if there is one.
If none of the above occurs the game continues.


The Full Game

Go ahead and just copy this.

#!/bin/bash
# Indicates that this script should be run using the Bash shell interpreter.

# KV bucket name
BUCKET="tictactoe"
# Defines the name of the NATS Key-Value bucket used for storing the game state.

current_player="X"
# Tracks the current player, starting with "X".

# Resets the Tic-Tac-Toe board by initializing all cells in the bucket.
initialize_board() {
  echo "Initializing board..."

  for cell in A1 A2 A3 B1 B2 B3 C1 C2 C3; do
    nats kv put $BUCKET $cell " " >/dev/null 2>&1
    # Sets each cell in the KV store to an empty space (" ").
    # 1. >/dev/null: Suppresses the normal success message that 
    # the nats kv put command might generate.
    # 2. 2>&1: Ensures that any errors produced (e.g., if the nats command fails) 
    # are also discarded.
  done
  echo "Board initialized."
  display_board
  # Displays the newly initialized board.
}

# Function to display the board
display_board() {
  echo "Current Board:"
  echo "$(nats kv get $BUCKET A1 --raw 2>/dev/null) | $(nats kv get $BUCKET A2 --raw 2>/dev/null) | $(nats kv get $BUCKET A3 --raw 2>/dev/null)"
  echo "--+---+--"
  echo "$(nats kv get $BUCKET B1 --raw 2>/dev/null) | $(nats kv get $BUCKET B2 --raw 2>/dev/null) | $(nats kv get $BUCKET B3 --raw 2>/dev/null)"
  echo "--+---+--"
  echo "$(nats kv get $BUCKET C1 --raw 2>/dev/null) | $(nats kv get $BUCKET C2 --raw 2>/dev/null) | $(nats kv get $BUCKET C3 --raw 2>/dev/null)"
}

# Function to make a move
make_move() {
  local cell=$1
  # The cell the player wants to make a move on that was entered.

  # Validate the cell
  if ! [[ $cell =~ ^[ABC][123]$ ]]; then
    # Checks if the input matches a valid cell format (e.g., A1, B2, C3).
    echo "Invalid cell! Please enter a valid cell (e.g., A1, B2, C3)."
    return 1
  fi

  # Check if the cell is already occupied
  local value=$(nats kv get $BUCKET $cell --raw 2>/dev/null)
  # Retrieves the current value of the cell.
  if [[ $value == "X" || $value == "O" ]]; then
    # If the cell is already occupied, show an error.
    echo "Cell $cell is already occupied. Choose another cell."
    return 1
  fi

  # Make the move
  nats kv put $BUCKET $cell $current_player
  # Updates the cell in the KV store with the current player's symbol.
  echo "Player $current_player moved to $cell."

  # Switch to the other player
  if [[ $current_player == "X" ]]; then
    # If the current player is X, switch to O.
    current_player="O"
  else
    # Otherwise, switch to X.
    current_player="X"
  fi

  display_board
  # Show the updated board after the move.
}

# Function to check for a winner
check_winner() {
  local board=()
  # Creates an array to store the current state of the board.
  for cell in A1 A2 A3 B1 B2 B3 C1 C2 C3; do
    board+=("$(nats kv get $BUCKET $cell --raw 2>/dev/null)")
    # Populates the board array with the values of each cell.
    # board=("X" "O" " " " " "X" "O" "O" " " "X") etc
  done

  # Winning combinations
  local win_patterns=(
    "0 1 2" "3 4 5" "6 7 8" # Rows
    "0 3 6" "1 4 7" "2 5 8" # Columns
    "0 4 8" "2 4 6"         # Diagonals
  )

  # We constructed the board AND it will look like this:
  # board=("X" "O" " " " " "X" "O" "O" " " "X")

  for pattern in "${win_patterns[@]}"; do
    # Iterates through each winning pattern.
    local i1=$(echo $pattern | cut -d' ' -f1)
    local i2=$(echo $pattern | cut -d' ' -f2)
    local i3=$(echo $pattern | cut -d' ' -f3)

    # local i1=$(echo $pattern | cut -d' ' -f1)
    # Just uses a delimeter of " " and grabs from a string.
    # In our case it's a pattern like "0 1 2"

    # local i1=$(echo $pattern | cut -d' ' -f1) echoes the pattern
    # and pipes it to cut which cuts using the delimeter ' '
    # so we get 0, 1, 2 and then -f1 grabs the first field. So 0.
    # Then we put it in local variable i1.

    # Check if the three cells match and are not empty (not " ")
    if [[ ${board[$i1]} == "${board[$i2]}" && ${board[$i2]} == "${board[$i3]}" && ${board[$i1]} != " " ]]; then

      # For "0 1 2" iimagine. Or "0 4 8
      # If all three cells match and are not empty, a player wins.

      echo "🎉 Player ${board[$i1]} wins! 🎉"
      initialize_board
      # Resets the board for the next game.
      echo "Returning to the main menu..."
      return 0
    fi
  done

  # Check for a tie (no cells contain " ")
  if [[ ! " ${board[*]} " =~ " " ]]; then
    # If no cells are empty, the game is a tie.
    display_board
    echo "It's a tie! 🤝"
    initialize_board
    echo "Returning to the main menu..."
    return 0
  fi

  return 1
  # If no winner or tie is found, the game continues.
}

# Main game loop
main() {
  # Displays the main menu and handles user input.
  echo "Welcome to Tic-Tac-Toe with NATS JetStream KV!"
  echo "1. Initialize/Reset Board"
  echo "2. Play Game"
  echo "3. Exit"
  echo "-------------------"

  while :; do
    # Infinite loop to keep showing the menu until the user exits.
    echo -n "Choose an option: "
    read choice
    # Reads the user's menu choice.

    case $choice in
      1)
        initialize_board
        ;;
      2)
        while :; do
          echo "Player $current_player's turn."
          echo "Enter your move (e.g., A1) or press 3 to display the board."
          echo -n "Your input: "
          read cell

          # Handle special commands
          if [[ $cell == "3" ]]; then
            # If the user enters "3", display the board.
            display_board
            continue
          fi

          # Make the move if input is not a special command
          if make_move $cell; then
            if check_winner; then
              # Check if there is a winner and respond if there is.
              current_player="X" # Reset the current player
              break              # Exit to the main menu
            fi
          fi
        done
        ;;
      3)
        echo "Goodbye!"
        exit 0
        ;;
      *)
        echo "Invalid option. Try again."
        ;;
    esac
  done
}

# Run the main function
main
# Starts the Tic-Tac-Toe game.
Enter fullscreen mode Exit fullscreen mode

Create a file and call it whatever you want - I used tictactoe.sh.

Then chmod +x tictactoe.sh to make it executable.


To Play

Before we finally jump into the game, let me show you one more JetStream command I intentionally held back earlier. After all, this is about JetStream, right?

If you haven't started the NATS Server, do it.

./nats-server -js
Enter fullscreen mode Exit fullscreen mode

Open two terminal windows. In the first one, run:

$ nats kv watch tictactoe
Enter fullscreen mode Exit fullscreen mode

Then, in the second window, start the game by running:

./tictactoe.sh
Enter fullscreen mode Exit fullscreen mode

Now take a look - you're actively watching the stream for the tictactoe Bucket! Pretty awesome, right? You can see all the updates happening in real-time!

./tictactoe.sh 

Welcome to Tic-Tac-Toe with NATS JetStream KV!
1. Initialize/Reset Board
2. Play Game
3. Exit
-------------------
Choose an option: 1
Initializing board...
Board initialized.
Current Board:
  |   |  
--+---+--
  |   |  
--+---+--
  |   |  
Choose an option: 2
Player X's turn.
Enter your move (e.g., A1) or press 3 to display the board.
Your input: A1
X
Player X moved to A1.
Current Board:
X |   |  
--+---+--
  |   |  
--+---+--
  |   |  
Player O's turn.
Enter your move (e.g., A1) or press 3 to display the board.
Your input: B2
O
Player O moved to B2.
Current Board:
X |   |  
--+---+--
  | O |  
--+---+--
  |   |  
Player X's turn.
Enter your move (e.g., A1) or press 3 to display the board.
Your input: C1
X
Player X moved to C1.
Current Board:
X |   |  
--+---+--
  | O |  
--+---+--
X |   |  
Player O's turn.
Enter your move (e.g., A1) or press 3 to display the board.
Your input: F3
Invalid cell! Please enter a valid cell (e.g., A1, B2, C3).
Player O's turn.
Enter your move (e.g., A1) or press 3 to display the board.
Your input: A2
O
Player O moved to A2.
Current Board:
X | O |  
--+---+--
  | O |  
--+---+--
X |   |  
Player X's turn.
Enter your move (e.g., A1) or press 3 to display the board.
Your input: B1
X
Player X moved to B1.
Current Board:
X | O |  
--+---+--
X | O |  
--+---+--
X |   |  
🎉 Player X wins! 🎉
Initializing board...
Board initialized.
Current Board:
  |   |  
--+---+--
  |   |  
--+---+--
  |   |  
Returning to the main menu...
Choose an option: 

This is what you will see in the terminal watching the KV Store.
nats kv watch tictactoe
[2025-01-20 01:23:51] PUT tictactoe > A1:  
[2025-01-20 01:23:51] PUT tictactoe > A2:  
[2025-01-20 01:23:51] PUT tictactoe > A3:  
[2025-01-20 01:23:51] PUT tictactoe > B1:  
[2025-01-20 01:23:51] PUT tictactoe > B2:  
[2025-01-20 01:23:51] PUT tictactoe > B3:  
[2025-01-20 01:23:51] PUT tictactoe > C1:  
[2025-01-20 01:23:51] PUT tictactoe > C2:  
[2025-01-20 01:23:51] PUT tictactoe > C3:  
[2025-01-20 01:24:12] PUT tictactoe > A1: X
[2025-01-20 01:24:14] PUT tictactoe > B2: O
[2025-01-20 01:24:19] PUT tictactoe > C1: X
[2025-01-20 01:24:29] PUT tictactoe > A2: O
[2025-01-20 01:24:34] PUT tictactoe > B1: X
[2025-01-20 01:24:34] PUT tictactoe > A1:  
[2025-01-20 01:24:34] PUT tictactoe > A2:  
[2025-01-20 01:24:34] PUT tictactoe > A3:  
[2025-01-20 01:24:34] PUT tictactoe > B1:  
[2025-01-20 01:24:34] PUT tictactoe > B2:  
[2025-01-20 01:24:34] PUT tictactoe > B3:  
[2025-01-20 01:24:34] PUT tictactoe > C1:  
[2025-01-20 01:24:34] PUT tictactoe > C2:  
[2025-01-20 01:24:34] PUT tictactoe > C3:
Enter fullscreen mode Exit fullscreen mode

Another thing you can do is make a few moves and then close the terminals. If you restart the game you will see it will have maintained the state from where you left off.

$ ./tictactoe.sh 

Welcome to Tic-Tac-Toe with NATS JetStream KV!
1. Initialize/Reset Board
2. Play Game
3. Exit
-------------------
Choose an option: 1
Initializing board...
Board initialized.
Current Board:
  |   |  
--+---+--
  |   |  
--+---+--
  |   |  
Choose an option: 2 
Player X's turn.
Enter your move (e.g., A1) or press 3 to display the board.
Your input: A1
X
Player X moved to A1.
Current Board:
X |   |  
--+---+--
  |   |  
--+---+--
  |   |  
Player O's turn.
Enter your move (e.g., A1) or press 3 to display the board.
Your input: B3
O
Player O moved to B3.
Current Board:
X |   |  
--+---+--
  |   | O
--+---+--
  |   |  
Player X's turn.
Enter your move (e.g., A1) or press 3 to display the board.
Your input: C2
X
Player X moved to C2.
Current Board:
X |   |  
--+---+--
  |   | O
--+---+--
  | X |  
Player O's turn.
Enter your move (e.g., A1) or press 3 to display the board.
Your input: ^C
Enter fullscreen mode Exit fullscreen mode

We quit the game close Jetstream and closed the terminals.

Let's restart it all and start the game.

$ ./tictactoe.sh 

Welcome to Tic-Tac-Toe with NATS JetStream KV!
1. Initialize/Reset Board
2. Play Game
3. Exit
-------------------
Choose an option: 2
Player X's turn.
Enter your move (e.g., A1) or press 3 to display the board.
Your input: 3
Current Board:
X |   |  
--+---+--
  |   | O
--+---+--
  | X |  
Player X's turn.
Enter your move (e.g., A1) or press 3 to display the board.
Your input:
Enter fullscreen mode Exit fullscreen mode

See? State is preserved!


So yeah all of that is pretty cool. I want to apologize for not showing you the watch command earlier because it's rad. Don't worry though you'll be using it again differently soon. We're going to do some actual development now in Part 5!
See ya there!

Top comments (0)