Hive Developers logo

Hive Developer Portal

Making a Decentralized Game on Hive

We will develop a decentralized game by the end of this series.

The following is a series written by @mahdiyari that describes how to develop a decentralized game on the Hive blockchain. It was originally released part by part, written over the course of about two months and originally posted on his blog.

Sections


Part 1

We will develop a decentralized game by the end of this series. The goal is to make learning development on Hive blockchain easier by using a simple game as the training project.

Target audience

Of course, it’s not for everybody. I will try to keep it simple as possible for people with less experience with development. But some degree of understanding code is necessary to learn something. I will explain anything related to the Hive blockchain that we use.

You can find the links for the final result of this part at the end of this post.

Why “tic-tac-toe”?

It’s a simple multiplayer game and it covers most of the topics needed for a bigger decentralized game or application. Most people are already familiar with this game and it’s easier to code and its rules are simple.

Development

We will use Javascript for the game because it’s simple enough so most people understand easily. The front-end will be pure HTML. Also, I think MySQL is a good fit as the database. The game needs a database to keep track of games and players. MySQL docker can be set up in a few minutes.

The decentralized game will work without depending on one central software. The game will talk only to the blockchain and there is no central database. It doesn’t need a private entity to hold players’ data. Anyone can run an interface for the game.

(We have a database but it’s not a central private database and it can be synced through blockchain. Anyone should be able to run an instance of the game and the game will get the same exact database by reading data from the blockchain. It’s like hivemind, the database that holds and serves most of the data on Hive.)

Tools I’m using

I didn’t plan anything beforehand so I don’t know how many posts it will take. I will list the things that come to my mind right now.

Planning

The above list may or may not change. Anyway, let’s start with the front-end and add a login method.


Part 1: Front-end - Login method

I think the HTML part doesn’t need any explanation. Page title, description, bootstrap navbar, and a login link. Added css/style.css and js/app.js too. When the user clicks on the “login” link, a modal with the login form will appear. It will then fire login() function on submit.

index.html:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta name="description" content="A decentralized game on hive blockchain" />
  <title>Tic-Tac-Toe on Hive blockchain</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/css/bootstrap.min.css" rel="stylesheet"
    integrity="sha384-BmbxuPwQa2lc/FVzBcNJ7UAyJxM6wuqIj61tLrc4wSX0szH/Ev+nYRRuWlolflfl" crossorigin="anonymous" />
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/js/bootstrap.bundle.min.js"
    integrity="sha384-b5kHyXgcpbZJO/tY9Ul7kGkf1S0CWuKcCD38l8YkeH8z8QjE0GmW1gYU5S9FOnJ0"
    crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/hive-tx/dist/hive-tx.min.js"></script>
  <link rel="stylesheet" href="css/style.css" />
</head>

<body>
  <nav class="navbar navbar-expand navbar-dark bg-dark">
    <div class="container-fluid">
      <a class="navbar-brand" href="#">Tic-Tac-Toe</a>
      <ul class="navbar-nav">
        <li class="nav-item">
          <a class="nav-link" href="#" data-bs-toggle="modal" data-bs-target="#login-modal" id="login-button">
            Login
          </a>
        <li class="nav-item dropdown" id="logout-menu" style="display: none;">
          <a class="nav-link dropdown-toggle" href="#" id="username-button" role="button" data-bs-toggle="dropdown"
            aria-expanded="false"></a>
          <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="username-button">
            <li><a class="dropdown-item" href="#" onclick="logout()">Logout</a></li>
          </ul>
        </li>
        </li>
      </ul>
    </div>
  </nav>
  <div class="modal fade" id="login-modal" tabindex="-1" aria-labelledby="login-modal-title" aria-hidden="true">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="login-modal-title">Login</h5>
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body">
          <form onsubmit="login(); return false">
            <div class="mb-3">
              <label for="username" class="form-label">Username:</label>
              <div class="input-group mb-3">
                <span class="input-group-text">@</span>
                <input type="text" class="form-control" placeholder="username" aria-label="username" id="username"
                  required>
              </div>
              <div class="form-text">Your Hive username. Lowercase.</div>
            </div>
            <div class="mb-3">
              <label for="posting-key" class="form-label">Posting key:</label>
              <input type="password" class="form-control" id="posting-key" placeholder="Private posting key" required>
              <div class="form-text">Your key will never leave your browser.</div>
            </div>
            <p id="login-error"></p>
            <button type="submit" class="btn btn-primary" id="login-form-btn">Login</button>
            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
          </form>
        </div>
      </div>
    </div>
  </div>
  <script src="js/app.js"></script>
</body>

</html>

Styles

css/style.css:

.navbar-nav {
  margin-right: 30px;
}
.navbar-brand {
  margin-left: 30px;
}
#login-error {
  color: #e31337;
  display: none;
}

Javascript

js/app.js

An object for user data.

const userData = {
  authorized: false,
  username: '',
  key: ''
}

Let’s define the login function. It will verify the posting key and username then keep the data in localStorage. We use the hive-tx library for converting keys.

const login = async () => {
  const loginModal = bootstrap.Modal.getInstance(
    document.getElementById('login-modal')
  )
  const loginButtonForm = document.getElementById('login-form-btn')
  loginButtonForm.setAttribute('disabled', 'true')
  const loginError = document.getElementById('login-error')
  loginError.style.display = 'none'
  const username = document.getElementById('username').value
  const key = document.getElementById('posting-key').value
  const validate = await validatePostingKey(username, key)
  if (validate.result === 0) {
    loginError.innerHTML = validate.error
    loginError.style.display = 'block'
    loginButtonForm.removeAttribute('disabled')
    return
  }
  userData.authorized = true
  userData.username = username
  userData.key = key
  window.localStorage.setItem('userData', JSON.stringify(userData))
  loginButtonForm.removeAttribute('disabled')
  updateState()
  loginModal.hide()
}

As you can see there are 2 more functions used inside the login function. The first one is validatePostingKey(). It’s a post for itself. Let me explain it. First, we make a call to the Hive RPC API node by using hive-tx library and get the account information including the public posting key. Then we convert the user’s private posting key to the public key and compare the two values. If the two values are equal then the user-provided private key is correct and we can authorize the user. There are also other ways to achieve the same result like signing a message with the private key and validating that signature with the public key.

const validatePostingKey = async (username, privateKey) => {
  const accounts = await hiveTx.call('condenser_api.get_accounts', [[username]])
  if (
    !accounts ||
    !accounts.result ||
    !Array.isArray(accounts.result) ||
    accounts.result.length < 1
  ) {
    return { result: 0, error: 'Network error or wrong username' }
  }
  try {
    const account = accounts.result[0]
    const publicWif = account.posting.key_auths[0][0] || ''
    const generatedPublicKey = hiveTx.PrivateKey.from(privateKey)
      .createPublic()
      .toString()

    if (generatedPublicKey !== publicWif) {
      return { result: 0, error: 'Wrong key' }
    }
    return { result: 1 }
  } catch (e) {
    return { result: 0, error: 'Wrong key or network error' }
  }
}

updateState() is used to update the HTML interface after user login and logout.

const updateState = () => {
  const loginButton = document.getElementById('login-button')
  const logoutMenu = document.getElementById('logout-menu')
  const usernameButton = document.getElementById('username-button')
  if (userData.authorized && userData.username && userData.key) {
    loginButton.style.display = 'none'
    logoutMenu.style.display = 'block'
    usernameButton.innerHTML = '@' + userData.username
  } else {
    loginButton.style.display = 'block'
    logoutMenu.style.display = 'none'
  }
}

And it’s time for the logout function.

const logout = () => {
  userData.authorized = false
  userData.username = ''
  userData.key = ''
  window.localStorage.removeItem('userData')
  updateState()
}

We need to check localStorage on the page reload and log in the user if the key is in the localStorage.

const checkState = () => {
  const localData = window.localStorage.getItem('userData')
  let data
  if (!localData) {
    return
  }
  try {
    data = JSON.parse(localData)
  } catch (e) {
    data = userData
  }
  if (data.authorized && data.username && data.key) {
    userData.authorized = true
    userData.username = data.username
    userData.key = data.key
    updateState()
  }
}
checkState()

Now we have a working login and logout system. It keeps user data in localStorage which stays on the browser only. What we have done so far is just the front-end. Our game needs a back-end server to provide the game data. We will stream blocks on the back-end and process game data then serve it through API. Our front-end will broadcast transactions which will update the back-end. In other words, the back-end is only serving the data it receives through the blockchain. We could stream the blocks on the client-side (browser) but it’s not efficient and it is just unnecessary bandwidth waste.


You can see the running app on https://tic-tac-toe.mahdiyari.info/ The final code is on GitLab https://gitlab.com/mahdiyari/decentralized-game-on-hive

In the next part, we will set up our back-end server and database. Now that I think about it, MySQL might be an overkill for this project. Anyway, let’s stick to it for now.


Part 2

In this part, we work on the back-end of the game. We use Nodejs for running our back-end codes. I assume you know how to create or run a Nodejs app. It’s not complicated and I will cover most of it here.

API server

api/server.js is the starting point of the API server. Let’s initialize it with expressjs and some libraries for API usage.

const express = require('express')
const bodyParser = require('body-parser')
const hpp = require('hpp')
const helmet = require('helmet')
const app = express()

// more info: www.npmjs.com/package/hpp
app.use(hpp())
app.use(helmet())

// support json encoded bodies and encoded bodies
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))

app.use(function (req, res, next) {
  const allowedOrigins = [
    'http://localhost',
    'https://tic-tac-toe.mahdiyari.info/'
  ]
  const origin = req.headers.origin
  if (allowedOrigins.includes(origin)) {
    res.header('Access-Control-Allow-Origin', origin)
  }
  res.header('Access-Control-Allow-Credentials', true)
  res.header(
    'Access-Control-Allow-Headers',
    'Origin, X-Requested-With, Content-Type, Accept, access_key'
  )
  next()
})

Note: multiple values are not allowed in the Access-Control-Allow-Origin header.


Don’t forget to install npm packages.

npm install express
npm install body-parser
npm install hpp
npm install helmet

hpp and helmet are for increased security and body-parser for parsing request bodies for json encoded bodies and encoded bodies.

I also added http://localhost and https://tic-tac-toe.mahdiyari.info/ to the Access-Control-Allow-Origin header. You can add another URL to receive API calls from or just put *. It basically limits the usage of the API server to the listed URLs.

Our API server will listen to 127.0.0.1:2021 by default. It does nothing at the moment. Let’s continue with the main application.


Main application

We will run 2 Nodejs apps. One is the API server and the other is the main application where streaming blocks and processing data is happening. The reason for splitting applications is to run the API server in cluster mode. With cluster mode, we can run one API server for each CPU core. It will help with load balancing and keep API running as fast as possible while serving many requests. The cluster mode is useful only on API servers and especially Expressjs apps.

We will need a helper to stream the blocks.

helpers/streamBlock.js:

const hiveTx = require('hive-tx')

const INTERVAL_TIME = 1000

const streamBlockNumber = async (cb) => {
  try {
    let lastBlock = 0
    setInterval(async () => {
      const gdgp = await hiveTx.call(
        'condenser_api.get_dynamic_global_properties'
      )
      if (
        gdgp &&
        gdgp.result &&
        gdgp.result.head_block_number &&
        !isNaN(gdgp.result.head_block_number)
      ) {
        if (gdgp.result.head_block_number > lastBlock) {
          lastBlock = gdgp.result.head_block_number
          cb(lastBlock)
        }
      }
    }, INTERVAL_TIME)
  } catch (e) {
    throw new Error(e)
  }
}

const streamBlockOperations = async (cb) => {
  try {
    streamBlockNumber(async (blockNumber) => {
      const result = await hiveTx.call('condenser_api.get_block', [
        blockNumber
      ])
      if (result.result) {
        const operations = result.result.transactions.map((transaction) => {
          return transaction.operations
        })
        if (operations.length > 0) {
          for (const operation of operations) {
            cb(operation)
          }
        }
      }
    })
  } catch (e) {
    throw new Error(e)
  }
}

module.exports = {
  streamBlockNumber,
  streamBlockOperations
}

install hive-tx: npm install hive-tx

We created 2 functions here. The first one streamBlockNumber makes a call to get dynamic_global_properties every INTERVAL_TIME which I set to 1000ms (1 second) then checks for newly produced block number. If the block number is increased, then it calls the callback function with the new block number. It’s a helpful function for getting newly generated block numbers.

We use the first function inside the second function streamBlockOperations to get newly generated blocks and get operations inside that block by using the condenser_api.get_block method.

streamBlockOperations will call the callback function with newly added operations to the blockchain (except virtual operations).

index.js:

const stream = require('./helpers/streamBlock')

try {
  stream.streamBlockOperations((ops) => {
    if (ops) {
      const op = ops[0]
      if (op && op[0] === 'custom_json' && op[1].id === 'tictactoe') {
        processData(op[1].json)
      }
    }
  })
} catch (e) {
  throw new Error(e)
}

This will stream newly added operations to the blockchain and send the JSON from custom_json operations with the id of tictactoe to the processData function.


We should define game mechanics and their corresponding custom_json.

Create a game

{
  app: 'tictactoe/0.0.1'
  action: 'create_game',
  id: 'Random generated id',
  starting_player: 'first or second'
}

Create a game and wait for the other player to join.


Request join a game

{
  app: 'tictactoe/0.0.1',
  action: 'request_join',
  id: 'Game id'
}

Request to join an open game which is created by someone else.


Accept join request

{
  app: 'tictactoe/0.0.1',
  action: 'accept_request',
  id: 'Game id',
  player: 'username'
}

Accept the pending join request from another player to your created game.


Play

{
  app: 'tictactoe/0.0.1',
  action: 'play',
  id: 'Game id',
  col: '1 to 3',
  row: '1 to 3'
}

Play or place an X/O on the board. col is the column and row is for the row of the placed X/O on the board.


Code implamantaion of the above in index.js:

const processData = (jsonData) => {
  try {
    if (!jsonData) {
      return
    }
    const data = JSON.parse(jsonData)
    if (!data || !data.action || !data.app) {
      return
    }
    if (data.action === 'create_game') {
      createGame(data)
    } else if (data.action === 'request_join') {
      requestJoin(data)
    } else if (data.action === 'accept_request') {
      acceptRequest(data)
    } else if (data.action === 'play') {
      play(data)
    }
  } catch (e) {
    // error might be on JSON.parse and wrong json format
    return null
  }
}

Let’s create a function for each game mechanic.

createGame:

const createGame = (data) => {
  if (!data || !data.id || !data.starting_player) {
    return
  }
  // validating
  if (
    data.id.length !== 20 ||
    (data.starting_player !== 'first' && data.starting_player !== 'second')
  ) {
    return
  }
  // Add game to database
  console.log('Create a game with id ' + data.id)
}

requestJoin:

const requestJoin = (data) => {
  if (!data || !data.id || !data.id.length !== 20) {
    return
  }
  // Check game id in database
  // Join game
  console.log('A player joined game id ' + data.id)
}

acceptRequest:

const acceptRequest = (data) => {
  if (!data || !data.id || !data.player || !data.id.length !== 20) {
    return
  }
  // Validate game in database
  // Accept the join request
  console.log('Accepted join request game id ' + data.id)
}

play:

const play = (data) => {
  if (
    !data ||
    !data.id ||
    !data.col ||
    !data.row ||
    !data.id.length !== 20 ||
    data.col < 1 ||
    data.col > 3 ||
    data.row < 1 ||
    data.row > 3
  ) {
    return
  }
  // Validate game in database
  // Validate the player round
  // Play game
  console.log('Played game id ' + data.id)
}

The above functions are not doing anything at the moment. We will complete those functions after setting up the database in the next part.

We can test our code by broadcasting custom_json operations. Let’s see if the streaming method and processing data works. We can run the app by node index.js https://hiveblocks.com/tx/44799e6a27c64e935f9072ecb576602330cb80b8

And here is the console.log() confirmation in our app:


Part 3

MySQL Setup

You can use apps like AMPPS which comes with MySQL and other tools like PHPMyAdmin (one of the best MySQL management apps) or install MySQL directly. I have AMMPS on windows and use MySQL docker on Linux.

MySQL docker installation:

docker pull mysql/mysql-server:latest

I create a folder /root/mysql-docker1 and put the MySQL config file there my.cnf and another folder data for holding the database files.

Running on port 127.0.0.1:3306:

docker run --name=mysql1 \
--mount type=bind,src=/root/mysql-docker1/my.cnf,dst=/etc/my.cnf \
--mount type=bind,src=/root/mysql-docker1/data,dst=/var/lib/mysql \
-p 127.0.0.1:3306:3306 -d mysql/mysql-server:latest

There are different ways to tune your MySQL server based on your hardware which I’m not going to talk about. The following config is for medium-range hardware (32GB ram).

my.cnf:

[mysqld]
skip_name_resolve
user=mysql
default_authentication_plugin = mysql_native_password

symbolic-links=0
character_set_server=utf8mb4
collation_server=utf8mb4_general_ci
innodb_max_dirty_pages_pct = 90
innodb_max_dirty_pages_pct_lwm = 10
innodb_flush_neighbors = 0
innodb_undo_log_truncate=off
max_connect_errors = 1000000

# InnoDB Settings
innodb_file_per_table
innodb_log_files_in_group       = 2
innodb_open_files               = 4000
default_storage_engine          = InnoDB
innodb_buffer_pool_instances    = 8     # Use 1 instance per 1GB of InnoDB pool size
innodb_buffer_pool_size         = 16G    # Use up to 70-80% of RAM
innodb_flush_method             = O_DIRECT_NO_FSYNC
innodb_log_buffer_size          = 64M
innodb_log_file_size            = 10G
innodb_stats_on_metadata        = 0

# tune
innodb_doublewrite= 1
innodb_thread_concurrency       = 0
innodb_flush_log_at_trx_commit  = 0
innodb_lru_scan_depth           = 2048
innodb_page_cleaners            = 4
join_buffer_size                = 256K
sort_buffer_size                = 256K
innodb_use_native_aio           = 1
innodb_stats_persistent         = 1

innodb_adaptive_flushing        = 1
innodb_read_io_threads          = 16
innodb_write_io_threads         = 16
innodb_io_capacity              = 1500
innodb_io_capacity_max          = 2500
innodb_purge_threads            = 4
innodb_adaptive_hash_index      = 0
max_prepared_stmt_count         = 1000000
innodb_monitor_enable           = '%'
performance_schema              = ON
key_buffer_size = 512M
# Connection Settings
max_connections                 = 2000   # UPD
back_log                        = 3000
interactive_timeout             = 180
wait_timeout                    = 10
table_open_cache                = 200000 # UPD
table_open_cache_instances      = 64
open_files_limit                = 100000 # UPD

Note: restart MySQL server after updating themy.cnf file.

MySQL password on AMMPS is mysql and on docker setup, I think you get the password from docker logs mysql1. Anyway, there are many documentations about MySQL already on the internet.

Create a database tictactoe. It’s easier with tools like PHPMyAdmin if you are using AMPPS. Or try HeidiSQL. Here is the SQL command for creating the database:

CREATE DATABASE `tictactoe`;

Development

Let’s create a config file for holding MySQL login information for our app. I will put this file as config.example.js in repository and you have to rename it manually.

config.js:

const config = {
  dbName: 'tictactoe',
  dbUser: 'root',
  dbPassword: 'password',
  dbHost: '127.0.0.1',
  dbPort: 3306
}

module.exports = config

I have a personal code for MySQL connection pooling. It simply makes a custom async function just like the original connect function of mysqljs library but for pooling connections.

helpers/mysql.js:

const mysql = require('mysql')
const config = require('../config')
const pool = mysql.createPool({
  connectionLimit: 5,
  host: config.dbHost,
  port: config.dbPort,
  user: config.dbUser,
  password: config.dbPassword,
  database: config.dbName,
  charset: 'utf8mb4'
})

// Rewriting MySQL query method as a promise
const con = {}
con.query = async (query, val) => {
  if (val) {
    const qu = await new Promise((resolve, reject) => {
      pool.query(query, val, (error, results) => {
        if (error) reject(new Error(error))
        resolve(results)
      })
    })
    return qu
  } else {
    const qu = await new Promise((resolve, reject) => {
      pool.query(query, (error, results) => {
        if (error) reject(new Error(error))
        resolve(results)
      })
    })
    return qu
  }
}

module.exports = con

It creates a pool of 5 connections which is more than enough for our game.

And of course:

npm install mysql

Initializing database

We make a function to create necessary tables if they don’t exist already.

helpers/initDatabase.js:

const mysql = require('./mysql')

/**
 * id, game_id, player1, player2, starting_player, status, winner
 */
const tableGames =
  'CREATE TABLE IF NOT EXISTS `tictactoe`.`games` ( `id` INT NOT NULL AUTO_INCREMENT , ' +
  '`game_id` TINYTEXT NOT NULL , ' +
  '`player1` TINYTEXT NOT NULL , `player2` TINYTEXT NULL DEFAULT NULL , ' +
  '`starting_player` TINYTEXT NOT NULL , `status` TINYTEXT NULL , ' +
  '`winner` TINYTEXT NULL DEFAULT NULL , PRIMARY KEY  (`id`)) ' +
  'ENGINE = InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;'

/**
 * id, game_id, player, col, row
 */
const tableMoves =
  'CREATE TABLE IF NOT EXISTS `tictactoe`.`moves` ( `id` INT NOT NULL AUTO_INCREMENT , ' +
  '`game_id` TINYTEXT NOT NULL , `player` TINYTEXT NOT NULL , ' +
  '`col` INT(1) NOT NULL , `row` INT(1) NOT NULL , ' +
  'PRIMARY KEY  (`id`)) ENGINE = InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;'

const tableRquests =
  'CREATE TABLE IF NOT EXISTS `tictactoe`.`requests` ( `id` INT NOT NULL AUTO_INCREMENT , ' +
  '`game_id` TINYTEXT NOT NULL , `player` TINYTEXT NOT NULL , `status` TINYTEXT NOT NULL , ' +
  'PRIMARY KEY  (`id`)) ENGINE = InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;'

const initDatabase = async () => {
  await mysql.query(tableGames)
  await mysql.query(tableMoves)
  await mysql.query(tableRquests)
}

module.exports = initDatabase

Created tables

games

moves

requests


Updating main application

Now we can complete the game methods in index.js:

createGame

const createGame = async (data, user) => {
  if (!data || !data.id || !data.starting_player) {
    return
  }
  // validating
  if (
    data.id.length !== 20 ||
    (data.starting_player !== 'first' && data.starting_player !== 'second')
  ) {
    return
  }
  // Check already existing games
  const duplicate = await mysql.query(
    'SELECT `id` FROM `games` WHERE `game_id`= ?',
    [data.id]
  )
  if (duplicate && Array.isArray(duplicate) && duplicate.length > 0) {
    return
  }
  // Add game to database
  await mysql.query(
    'INSERT INTO `games`(`game_id`, `player1`, `starting_player`, `status`) VALUES (?, ?, ?, ?)',
    [data.id, user, data.starting_player, 'waiting']
  )
}

requestJoin

const requestJoin = async (data, user) => {
  if (!data || !data.id || !data.id.length !== 20) {
    return
  }
  // Check game id in database
  const game = await mysql.query(
    'SELECT `player1` FROM `games` WHERE `game_id`= ? AND `status`= ?',
    [data.id, 'waiting']
  )
  if (!game || !Array.isArray(game) || game.length < 1) {
    return
  }
  // Players can not play with themselves
  if (game[0].player1 === user) {
    return
  }
  // Check already open requests
  const requests = await mysql.query(
    'SELECT `id` FROM `requests` WHERE `game_id`= ? AND (`player`= ? OR `status`= ?)',
    [data.id, user, 'accepted']
  )
  if (requests && Array.isArray(requests) && requests.length > 0) {
    return
  }
  // Request join game
  await mysql.query(
    'INSERT INTO `requests`(`game_id`, `player`, `status`) VALUES (?, ?, ?)',
    [data.id, user, 'waiting']
  )
}

acceptRequest

const acceptRequest = async (data, user) => {
  if (!data || !data.id || !data.player || !data.id.length !== 20) {
    return
  }
  // Validate game in database
  const game = await mysql.query(
    'SELECT `player1` FROM `games` WHERE `game_id`= ? AND `status`= ?',
    [data.id, 'waiting']
  )
  if (!game || !Array.isArray(game) || game.length < 1) {
    return
  }
  const requests = await mysql.query(
    'SELECT `id` FROM `requests` WHERE `game_id`= ? AND `player`= ? AND `status`= ?',
    [data.id, data.player, 'waiting']
  )
  if (!requests || !Array.isArray(requests) || requests.length < 1) {
    return
  }
  // Accept the join request and update game status
  await mysql.query(
    'UPDATE `games` SET `player2`=?,`status`=? WHERE `game_id`=?',
    [data.player, 'running', data.id]
  )
  await mysql.query(
    'UPDATE `requests` SET `status`=? WHERE `game_id`=? AND `player`=?',
    ['accepted', data.id, data.player]
  )
}

Some updates to processData:

const processData = (jsonData, postingAuths) => {
  try {
    if (!jsonData) {
      return
    }
    const data = JSON.parse(jsonData)
    if (!data || !data.action || !data.app) {
      return
    }
    if (
      !postingAuths ||
      !Array.isArray(postingAuths) ||
      postingAuths.length < 1
    ) {
      return
    }
    const user = postingAuths[0]
    if (data.action === 'create_game') {
      createGame(data, user)
    } else if (data.action === 'request_join') {
      requestJoin(data, user)
    } else if (data.action === 'accept_request') {
      acceptRequest(data, user)
    } else if (data.action === 'play') {
      play(data, user)
    }
  } catch (e) {
    // error might be on JSON.parse and wrong json format
    return null
  }
}

And streaming function:

try {
  stream.streamBlockOperations((ops) => {
    if (ops) {
      const op = ops[0]
      if (op && op[0] === 'custom_json' && op[1].id === 'tictactoe') {
        processData(op[1].json, op[1].required_posting_auths)
      }
    }
  })
} catch (e) {
  throw new Error(e)
}

I think it’s enough for this part. Let’s finish before I sleep on the keyboard.

We set up the MySQL server and made a script to create 3 tables. Our back-end is now processing data into the database and 3 main functions are working as expected. Creating a game, Requesting to join a game, and accepting the requests.

We have to create the front-end for these functions in the next part. I think the hard part is going to be the play function which holds the game rules.


Part 4

API

Let’s start by implementing the API for retrieving the games list. Our API is public so it doesn’t require user authentication. The API will just return the data synced from the blockchain. api/games.js:

const mysql = require('../helpers/mysql')
const express = require('express')
const router = express.Router()

// Return all games on route /games
router.get('/games', async (req, res) => {
  try {
    // Get games data from Database
    const games = await mysql.query(
      'SELECT `game_id`, `player1`, `player2`, `starting_player`, `status`, `winner` FROM `games`'
    )
    // Check for expected result
    if (!games || !Array.isArray(games) || games.length < 1) {
      // WE return id: 1 for success and id: 0 for errors
      return res.json({
        id: 1,
        games: []
      })
    }
    // We return `games` as we receive from MySQL but it's not a good practice
    // specially when you have critical data in the database
    // You can return data one by one like `games: [{game_id: games.game_id, ...}]`
    return res.json({
      id: 1,
      games
    })
  } catch (e) {
    // Return error for unexpected errors like connection drops
    res.json({
      id: 0,
      error: 'Unexpected error.'
    })
  }
})

module.exports = router

The comments are in the code itself for better understanding. Note: We use try{} catch{} wherever we can. It is always good to handle errors.

We can test our API at this point to detect possible errors in the code. Include the following code in api/server.js just above the port and host constants.

const games = require('./games')
app.use(games)

Run node api/server.js. We can see the console.log: Application started on 127.0.0.1:2021 Let’s open 127.0.0.1:2021/games in the browser.

The API works as expected.

But it’s not done yet. This API returns ALL the games without a specific order. We should implement pagination and define an order for our list.

Updated code api/games.js:

const mysql = require('../helpers/mysql')
const express = require('express')
const router = express.Router()

router.get('/games/:page', async (req, res) => {
  try {
    if (isNaN(req.params.page)) {
      res.json({
        id: 0,
        error: 'Expected number.'
      })
    }
    const page = Math.floor(req.params.page)
    if (page < 1) {
      res.json({
        id: 0,
        error: 'Expected > 0'
      })
    }
    const offset = (page - 1) * 10
    const games = await mysql.query(
      'SELECT `game_id`, `player1`, `player2`, `starting_player`, `status`, `winner` FROM `games`' +
        ' ORDER BY `id` DESC LIMIT 10 OFFSET ?',
      [offset]
    )
    if (!games || !Array.isArray(games) || games.length < 1) {
      return res.json({
        id: 1,
        games: []
      })
    }
    return res.json({
      id: 1,
      games
    })
  } catch (e) {
    res.json({
      id: 0,
      error: 'Unexpected error.'
    })
  }
})

module.exports = router

We used id to order our list and get the newly created games first. Each page returns up to 10 games. We can try 127.0.0.1:2021/games/1 for testing.


Let’s set another API for requests. The code is almost similar but we return only requests for the specific game_id.

api/requests.js:

const mysql = require('../helpers/mysql')
const express = require('express')
const router = express.Router()

router.get('/requests/:id', async (req, res) => {
  try {
    if (!req.params.id) {
      res.json({
        id: 0,
        error: 'Expected game_id.'
      })
    }
    // We are passing user input into the database
    // You should be careful in such cases
    // We use ? for parameters which escapes the characters
    const requests = await mysql.query(
      'SELECT `player`, `status` FROM `requests` WHERE `game_id`= ?',
      [req.params.id]
    )
    if (!requests || !Array.isArray(requests) || requests.length < 1) {
      return res.json({
        id: 1,
        requests: []
      })
    }
    return res.json({
      id: 1,
      requests
    })
  } catch (e) {
    res.json({
      id: 0,
      error: 'Unexpected error.'
    })
  }
})

module.exports = router

Note: :id in the above router represents a variable named id. So for example http://127.0.0.1:2021/requests/mygameidhere in this request, the id variable is mygameidhere which is accessible by req.params.id.


A similar code for the moves table. There wasn’t a better name in my mind for this table.

api/moves.js:

const mysql = require('../helpers/mysql')
const express = require('express')
const router = express.Router()

router.get('/moves/:id', async (req, res) => {
  try {
    if (!req.params.id) {
      res.json({
        id: 0,
        error: 'Expected game_id.'
      })
    }
    const moves = await mysql.query(
      'SELECT `player`, `col`, `row` FROM `moves` WHERE `game_id`= ?',
      [req.params.id]
    )
    if (!moves || !Array.isArray(moves) || moves.length < 1) {
      return res.json({
        id: 1,
        moves: []
      })
    }
    return res.json({
      id: 1,
      moves
    })
  } catch (e) {
    res.json({
      id: 0,
      error: 'Unexpected error.'
    })
  }
})

module.exports = router

Now our 3 APIs are ready to be implemented on the front-end.


Here is the updated api/server.js after including the APIs:

const express = require('express')
const bodyParser = require('body-parser')
const hpp = require('hpp')
const helmet = require('helmet')
const app = express()

// more info: www.npmjs.com/package/hpp
app.use(hpp())
app.use(helmet())

// support json encoded bodies and encoded bodies
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))

app.use(function (req, res, next) {
  res.header(
    'Access-Control-Allow-Origin',
    'http://localhost https://tic-tac-toe.mahdiyari.info/'
  )
  res.header('Access-Control-Allow-Credentials', true)
  res.header(
    'Access-Control-Allow-Headers',
    'Origin, X-Requested-With, Content-Type, Accept, access_key'
  )
  next()
})

// APIs
const games = require('./games')
const requests = require('./requests')
const moves = require('./moves')

app.use(games)
app.use(requests)
app.use(moves)

const port = process.env.PORT || 2021
const host = process.env.HOST || '127.0.0.1'
app.listen(port, host, () => {
  console.log(`Application started on ${host}:${port}`)
})

Front-end

I think using pure HTML is a mistake and I would prefer something like Angular for the web applications but that comes with its own learning process which can make this tutorial complex. So my recommendation is to learn something like Angular or Vue and live a happy life. Anyway, coding time.

I’m not going to drop index.html here. It doesn’t need much explanation and it’s long. You can see it on the GitLab repository. I will just add some references here used in app.js.

The table for listing the games and buttons for pagination.

index.html:

<div class="card-body">
  <h5 class="card-title" style="float: left;">Games list</h5>
  <span style="float: right;">Auto updating every 5s</span>
  <table class="table table-striped">
    <thead>
      <tr>
        <th>#</th>
        <th>Game ID</th>
        <th>Player 1</th>
        <th>Player 2</th>
        <th>Starting Player</th>
        <th>Status</th>
        <th>Winner</th>
        <th>Action</th>
      </tr>
    </thead>
    <tbody id="games-table-body">
    </tbody>
  </table>
  <nav aria-label="Page navigation example">
    <ul class="pagination justify-content-center">
      <li class="page-item disabled" id="prev-btn">
        <a class="page-link" onclick="prevGamesPage()">&laquo;</a>
      </li>
      <li class="page-item disabled">
        <a class="page-link" id="page-number" tabindex="-1"> 1 </a>
      </li>
      <li class="page-item" id="next-btn">
        <a class="page-link" onclick="nextGamesPage()">&raquo;</a>
      </li>
    </ul>
  </nav>
</div>

We have to fill the table above. So let’s implement some basic functions.

js/app.js:

const baseAPI = 'http://127.0.0.1:2021'
const APICall = async (api) => {
  return (await fetch(baseAPI + api)).json()
}

For ease of use, we define a function for GET calls using fetch and a variable for our API address.


const getGames = async (page = 1) => {
  const games = await APICall('/games/' + page)
  return games.games
}

This function basically gets the games from the API per page.


const fillGamesTable = (data) => {
  const tbody = document.getElementById('games-table-body')
  let temp = ''
  for (let i = 0; i < data.length; i++) {
    temp += `<tr>
    <td>${(gamesPage - 1) * 10 + i + 1}</td>
    <td>${data[i].game_id}</td>
    <td>${data[i].player1}</td>
    <td>${data[i].player2}</td>
    <td>${data[i].starting_player}</td>
    <td>${data[i].status}</td>
    <td>${data[i].winner}</td>
    <td></td>
    </tr>`
  }
  if (data.length < 1) {
    temp = 'No games.'
  }
  tbody.innerHTML = temp
}

fillGamesTable takes the result from getGames function and fills the HTML table with data using a for loop.


let gamesPage = 1
const loadTheGames = async () => {
  const games = await getGames(gamesPage)
  fillGamesTable(games)
  if (games.length < 10) {
    document.getElementById('next-btn').className = 'page-item disabled'
  } else {
    document.getElementById('next-btn').className = 'page-item'
  }
  if (gamesPage === 1) {
    document.getElementById('prev-btn').className = 'page-item disabled'
  } else {
    document.getElementById('prev-btn').className = 'page-item'
  }
  document.getElementById('page-number').innerHTML = ` ${gamesPage} `
}
loadTheGames()
setInterval(() => loadTheGames(), 5000)

With this function, we call the two previously defined functions to do their job and update the pagination buttons and the page number every time we update the table data. Also, every 5 seconds, it gets new data from API and updates the table with new data so users don’t have to reload the page for new data.


const nextGamesPage = () => {
  gamesPage++
  loadTheGames()
}

const prevGamesPage = () => {
  gamesPage--
  loadTheGames()
}

And two functions for changing pages. Simple as that.


The final result with dummy data looks like this on the browser:


Part 5

Games are fun and most people like to play different kinds of games. I’m not sure about the game we are building. Whether it be fun or not, the purpose of this project is the tutorial. To have a step-by-step guide that developers can use as a reference in building apps on Hive.


Building the transactions

We will build the back-end for the front-end that we built in the previous post. All goes into js/app.js.

const random = (length = 20) => {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
  let str = ''
  for (let i = 0; i < length; i++) {
    str += chars.charAt(Math.floor(Math.random() * chars.length))
  }
  return str
}

A simple function to generate a random string. We will use the random string for the game_id.


const createGame = async () => {
  const button = document.getElementById('create-game-btn')
  button.setAttribute('disabled', 'true')
  const errorOutput = document.getElementById('create-game-error')
  const successOutput = document.getElementById('create-game-success')
  errorOutput.innerHTML = ''
  successOutput.innerHTML = ''
  try {
    const game = {
      app: 'tictactoe/0.0.1',
      action: 'create_game',
      id: random(20),
      starting_player: document.getElementById('starting-player').value
    }
    const operations = [
      [
        'custom_json',
        {
          required_auths: [],
          required_posting_auths: [userData.username],
          id: 'tictactoe',
          json: JSON.stringify(game)
        }
      ]
    ]
    const tx = new hiveTx.Transaction()
    await tx.create(operations)
    const privateKey = hiveTx.PrivateKey.from(userData.key)
    tx.sign(privateKey)
    const result = await tx.broadcast()
    if (result && result.result && result.result.block_num) {
      successOutput.innerHTML =
        'Success! <a href="link to game">Click to see</a>'
    } else {
      errorOutput.innerHTML =
        'Error! Check console for details. Press Ctrl+Shift+J'
      console.error(result)
    }
  } catch (e) {
    errorOutput.innerHTML =
      'Error! Check console for details. Press Ctrl+Shift+J'
    console.error(e)
  }
  button.removeAttribute('disabled')
}

We create the transaction by using the hive-tx library then sign and broadcast it. We put the game link in the success message and show it to the user.

Now users can create the game and see the list of games. We create the game.html page for users to play the game.


Game page

We can add the game board, moves history, and the game stats like the winner, player1, and player2. I think we can make this page accessible by the game_id, like /game.html?id=game_id_here. Let’s create the easier parts first.

game.html: We use the head from index.html and the same navbar code.

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="description" content="A decentralized game on hive blockchain" />
  <title>Tic-Tac-Toe on Hive blockchain</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/css/bootstrap.min.css" rel="stylesheet"
    integrity="sha384-BmbxuPwQa2lc/FVzBcNJ7UAyJxM6wuqIj61tLrc4wSX0szH/Ev+nYRRuWlolflfl" crossorigin="anonymous" />
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/js/bootstrap.bundle.min.js"
    integrity="sha384-b5kHyXgcpbZJO/tY9Ul7kGkf1S0CWuKcCD38l8YkeH8z8QjE0GmW1gYU5S9FOnJ0"
    crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/hive-tx/dist/hive-tx.min.js"></script>
  <link rel="stylesheet" href="css/style.css" />
</head>

<body>
  <nav class="navbar navbar-expand navbar-dark bg-dark">
    <div class="container-fluid">
      <a class="navbar-brand" href="#">Tic-Tac-Toe</a>
      <ul class="navbar-nav">
        <li class="nav-item">
          <a class="nav-link" href="#" data-bs-toggle="modal" data-bs-target="#login-modal" id="login-button">
            Login
          </a>
        <li class="nav-item dropdown" id="logout-menu" style="display: none;">
          <a class="nav-link dropdown-toggle" href="#" id="username-button" role="button" data-bs-toggle="dropdown"
            aria-expanded="false"></a>
          <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="username-button">
            <li><a class="dropdown-item" href="#" onclick="logout()">Logout</a></li>
          </ul>
        </li>
        </li>
      </ul>
    </div>
  </nav>
  <!-- Login Modal -->
  <div class="modal fade" id="login-modal" tabindex="-1" aria-labelledby="login-modal-title" aria-hidden="true">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="login-modal-title">Login</h5>
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body">
          <form onsubmit="login(); return false">
            <div class="mb-3">
              <label for="username" class="form-label">Username:</label>
              <div class="input-group mb-3">
                <span class="input-group-text">@</span>
                <input type="text" class="form-control" placeholder="username" aria-label="username" id="username"
                  required>
              </div>
              <div class="form-text">Your Hive username. Lowercase.</div>
            </div>
            <div class="mb-3">
              <label for="posting-key" class="form-label">Posting key:</label>
              <input type="password" class="form-control" id="posting-key" placeholder="Private posting key" required>
              <div class="form-text">Your key will never leave your browser.</div>
            </div>
            <p id="login-error"></p>
            <button type="submit" class="btn btn-primary" id="login-form-btn">Login</button>
            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
          </form>
        </div>
      </div>
    </div>
  </div>
  <!-- Modal end -->

  <script src="js/app.js"></script>
</body>

</html>

Since we use app.js here too, we have to modify 2 lines in app.js:

// Run the script only in homepage
if (!window.location.pathname.match(/game.html$/)) {
  loadTheGames()
  setInterval(() => loadTheGames(), 5000)
}

In the above code, we can define which scripts to run on the homepage and which run on the game page.

We need an API for retrieving the game details by game_id. Let’s set that up in the back-end.

api/game.js:

const mysql = require('../helpers/mysql')
const express = require('express')
const router = express.Router()

router.get('/game/:id', async (req, res) => {
  try {
    const id = req.params.id
    if (!id || id.length !== 20 || !id.match(/^[a-zA-Z0-9]+$/)) {
      return res.json({
        id: 0,
        error: 'Wrong id.'
      })
    }
    const game = await mysql.query(
      'SELECT `game_id`, `player1`, `player2`, `starting_player`, `status`, `winner` FROM `games`' +
        'WHERE `game_id`=?',
      [id]
    )
    if (!game || !Array.isArray(game) || game.length < 1) {
      return res.json({
        id: 1,
        game: []
      })
    }
    return res.json({
      id: 1,
      game
    })
  } catch (e) {
    return res.json({
      id: 0,
      error: 'Unexpected error.'
    })
  }
})

module.exports = router

The above code is similar to the other APIs we set up. Nothing new here. Now we can show the game details on the game.html page.

const getGameDetails = async (id) => {
  const data = await APICall('/game/' + id)
  if (data && data.id === 0) {
    document.getElementById('details-error').innerHTML = data.error
  } else if (data && data.id === 1) {
    const game = data.game[0]
    document.getElementById('game-details').innerHTML = `<tr>
    <td>${game.player1}</td>
    <td>${game.player2}</td>
    <td>${game.starting_player}</td>
    <td>${game.status}</td>
    <td>${game.winner}</td>
    </tr>`
    if (game.player1 === userData.username) {
      document.getElementById('req-message-1').style.display = 'block'
      document.getElementById('req-message-2').style.display = 'none'
    }
  }
}

And running the above function:

const queryString = window.location.search
const urlParams = new URLSearchParams(queryString)

// Run the script only in homepage
if (!window.location.pathname.match(/game.html$/)) {
  loadTheGames()
  setInterval(() => loadTheGames(), 5000)
} else {
  // Run the script only in game page
  if (urlParams.has('id')) {
    getGameDetails(urlParams.get('id'))
  }
}

We get the id from the page URL and use it in the API call. Then display the data in an HTML table.

<table class="table">
  <thead>
    <tr>
      <th>Player1</th>
      <th>Player2</th>
      <th>Starting player</th>
      <th>Status</th>
      <th>Winner</th>
    </tr>
  </thead>
  <tbody id="game-details">
  </tbody>
</table>

We can get the join requests from API and show them beside the game so player1 can accept one of the coming requests and start the game. A simple HTML table:

<table class="table">
  <thead>
    <tr>
      <th>Player</th>
      <th>Status</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody id="request-list"></tbody>
</table>

And the API call:

const getRequests = async (id, creator = false) => {
  const data = await APICall('/requests/' + id)
  if (data && data.id === 0) {
    document.getElementById('requests-error').innerHTML = data.error
  } else if (data && data.id === 1) {
    let temp = ''
    for (let i = 0; i < data.requests.length; i++) {
      const request = data.requests[i]
      temp += `<tr>
        <td>${request.player}</td>
        <td>${request.status}</td>`
      if (creator) {
        // Add an Accept button if the visitor is player1 (creator)
        temp += `<td>
          <button class="btn btn-primary" onclick="acceptRequest(${id}, ${request.player})">
            Accept
          </button>
        </td>`
      } else {
        temp += '<td>---</td>'
      }
      temp += '</tr>'
    }
    if (data.requests.length < 1) {
      temp = 'None'
    }
    document.getElementById('request-list').innerHTML = temp
  }
}

We can call the getRequests function inside the getGameDetails function because we can know when the user (visitor) is the creator of the game aka player1. Then show them an Accept button based on that so the player1 can accept the request.

const getGameDetails = async (id) => {
  ... // skipped unchanged lines
    if (game.player1 === userData.username) {
      document.getElementById('req-message-1').style.display = 'block'
      document.getElementById('req-message-2').style.display = 'none'
      getRequests(id, true)
    } else {
      getRequests(id, false)
    }
  }
}

Also, let’s make both functions run with an interval to auto-update the data.

// Run the script only in homepage
if (!window.location.pathname.match(/game.html$/)) {
  loadTheGames()
  setInterval(() => loadTheGames(), 5000)
} else {
  // Run the script only in game page
  if (urlParams.has('id')) {
    getGameDetails(urlParams.get('id'))
    setInterval(() => getGameDetails(urlParams.get('id')), 5000)
  }
}

We added the accept button so let’s add its function and transaction too.

const acceptRequest = async (id, player) => {
  const success = document.getElementById('requests-success')
  const error = document.getElementById('requests-error')
  if (!userData.username) {
    return
  }
  try {
    const accept = {
      app: 'tictactoe/0.0.1',
      action: 'accept_request',
      id,
      player
    }
    const operations = [
      [
        'custom_json',
        {
          required_auths: [],
          required_posting_auths: [userData.username],
          id: 'tictactoe',
          json: JSON.stringify(accept)
        }
      ]
    ]
    const tx = new hiveTx.Transaction()
    await tx.create(operations)
    const privateKey = hiveTx.PrivateKey.from(userData.key)
    tx.sign(privateKey)
    const result = await tx.broadcast()
    if (result && result.result && result.result.block_num) {
      success.innerHTML = 'Success! Game started.'
    } else {
      error.innerHTML = 'Error! Check console for details. Press Ctrl+Shift+J'
      console.error(result)
    }
  } catch (e) {
    error.innerHTML = 'Error! Check console for details. Press Ctrl+Shift+J'
    console.error(e)
  }
}

Now let’s add a button for other users to join the game.

<button id="join-btn" class="btn btn-primary" onclick="joinGame()">Join the game</button>

And build the transaction for it:

const joinGame = async (gameId) => {
  const success = document.getElementById('join-success')
  const error = document.getElementById('join-error')
  if (!urlParams.has('id')) {
    return
  }
  const id = urlParams.get('id')
  try {
    const joinReq = {
      app: 'tictactoe/0.0.1',
      action: 'request_join',
      id
    }
    const operations = [
      [
        'custom_json',
        {
          required_auths: [],
          required_posting_auths: [userData.username],
          id: 'tictactoe',
          json: JSON.stringify(joinReq)
        }
      ]
    ]
    const tx = new hiveTx.Transaction()
    await tx.create(operations)
    const privateKey = hiveTx.PrivateKey.from(userData.key)
    tx.sign(privateKey)
    const result = await tx.broadcast()
    if (result && result.result && result.result.block_num) {
      success.innerHTML = 'Success! Your request submitted.'
    } else {
      error.innerHTML = 'Error! Check console for details. Press Ctrl+Shift+J'
      console.error(result)
    }
  } catch (e) {
    error.innerHTML = 'Error! Check console for details. Press Ctrl+Shift+J'
    console.error(e)
  }
}

Next part

We finally finished most of the functions needed for starting the game. I think the only remaining challenge is the gameplay. We can use Canvas for the front-end graphical gameplay. I already built a project with Canvas but there is nothing easy about coding. It’s still a challenge.

Let me list the remaining tasks:

We are getting to the end of this project and I think we can finish it in the next post and finally play this boring game at least once.

Making tutorials and coding at the same time is really hard and tiring. I might continue writing this kind of series for different projects if there is enough interest but the target audience is too small. So let me know in the comments what you think.

I wouldn’t mind building an interesting app or game which can be some kind of tutorial too.

TBH after writing the above line I wanted to remove it. Let’s see what happens.


Part 6

First of all, thank you for following me and making these posts available for a bigger audience by sharing them.

This post took too long but finally, it’s done. I think I could make 5 posts with the content of just this yet-to-be-written post. I will make it short as possible. I was going to make a few excuses for taking it too long but TBH I could deliver it maybe 2 weeks earlier if I worked on it. Lazy me is for the blame.

The game itself is not important for me, the important part was writing these posts to show how easy it is to make true decentralized applications and games. Of course, it’s not for everybody. But now we have something to share with other developers who want to start a game or app. It’s not perfect but it’s something.

Anyway, it’s online and it’s fully functional. You can try it with a friend on https://tic-tac-toe.mahdiyari.info/. Create a game and the other user can join it. Then the game should start. There is no time limit on the game.

Link to the GitLab repository and the previous posts are at the end of the post. Maybe you can try running your instance of the game for the experiment! It will start syncing blocks and become identical to the official website.

Now moving to the development side of things. I won’t drop all the codes here because it’s massive. All the codes are almost 2,000 lines. I will only explain the parts that seem necessary. ***

Development

We can use pm2 to launch our application. So let’s make a config file for it.

ecosystem.config.js

module.exports = {
  apps: [
    {
      name: 'app-tictactoe',
      script: 'index.js',
      instances: 1,
      max_memory_restart: '1G',
      exec_mode: 'fork'
    },
    {
      name: 'api-tictactoe',
      script: 'api/server.js',
      instances: 2,
      max_memory_restart: '1G',
      exec_mode: 'cluster'
    }
  ]
}

This will launch the API in a cluster mode with 2 instances for better handling of the traffic. You can increase the number of instances up to the number of your CPU threads. ONLY the API. We can’t run the main application in a cluster mode and it’s not necessary at all. We set both scripts to use a maximum of 1GB RAM but in reality, both will use less than 40mb. It’s just to be safe.

I added the necessary scripts in the package.json for running the application.

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "pm2 start ecosystem.config.js",
    "stop": "pm2 delete ecosystem.config.js",
    "restart": "pm2 restart ecosystem.config.js",
    "reload": "pm2 reload ecosystem.config.js",
    "logs": "pm2 logs"
  }

For example, for starting the app I can type npm run start and npm run logs for logs and so on. It will use the installed pm2 with the app itself.


For the ability to sync blocks in a row one by one, we use a queue. I call this implementation rapidQueue. It’s in pure JS and is faster than the other methods. You can check it out on GitLab.

I set the genesis block of our game to 53,886,076 just because it was the head block at the time of launch. Every time a fresh instance of the game is installed, it will go through all the blocks one by one from the genesis block until the current head block and continue listening for the future blocks.

So here it is our syncing method:

index.js

const start = async () => {
  try {
    await initDatabase()
    stream.streamBlockNumber(async blockNum => {
      if (!blockNum) {
        return
      }
      if (firstRun) {
        firstRun = false
        await queueOldBlocks(blockNum)
      }
      queue.push(blockNum)
    })
    processQueue()
  } catch (e) {
    throw new Error(e)
  }
}

First, initializes database then puts into the queue not synced blocks, then puts the newly generated blocks into the back of the queue. Then call another function that will check the queue at an interval.

The code below is responsible for putting not synced blocks into the queue.

const queueOldBlocks = async nowBlock => {
  let oldestBlock
  const latestBlock = await mysql.query(
    'SELECT `block_number` FROM `lastblock` WHERE `id`=1'
  )
  if (latestBlock[0].block_number === 0) {
    oldestBlock = genesisBlock
  } else {
    oldestBlock = latestBlock[0].block_number
  }
  if (oldestBlock < nowBlock) {
    for (let i = oldestBlock; i < nowBlock; i++) {
      queue.push(i)
    }
  }
}

The following code is the part that takes block numbers one by one out of the queue. It runs every 5ms to check if the current block is processed or not. If yes, goes to the next block in the queue. queueIndex is the counter of the currently running processBlock()s. Which will be a maximum of 1.

const intervalTime = 5
const maxI = 1
let queueIndex = 0
const processQueue = () => {
  setInterval(() => {
    const L = queue.length()
    if (queueIndex < maxI && L > 0) {
      const n = maxI - queueIndex > L ? L : maxI - queueIndex
      for (let k = 0; k < n; k++) {
        const blockNum = queue.shift()
        processBlock(blockNum)
      }
    }
  }, intervalTime)
}

You can see the counter in action:

const processBlock = async blockNum => {
  if (!blockNum) {
    return
  }
  queueIndex++
  try {
    const operations = await stream.getOperations(blockNum)
    if (operations && operations.length > 0) {
      for (const ops of operations) {
        for (const op of ops) {
          if (op && op[0] === 'custom_json' && op[1].id === 'tictactoe') {
            await processData(op[1].json, op[1].required_posting_auths)
          }
        }
      }
    }
    await updateLastblock(blockNum)
    totalSyncedBlocks++
  } catch (e) {}
  queueIndex--
}

The heart of the game is the play function. It determines the finished games and winners. It is triggered when a player plays a move.

const play = async (data, user) => {
  if (
    !data ||
    !data.id ||
    !data.col ||
    !data.row ||
    isNaN(data.col) ||
    isNaN(data.row) ||
    data.id.length !== 20 ||
    data.col < 1 ||
    data.col > 3 ||
    data.row < 1 ||
    data.row > 3
  ) {
    return
  }
  // Validate game in database
  const game = await mysql.query(
    'SELECT `player1`, `player2`, `starting_player` FROM `games` WHERE `game_id`= ? AND `status`= ? AND (player1=? OR player2=?)',
    [data.id, 'running', user, user]
  )
  if (!game || !Array.isArray(game) || game.length < 1) {
    return
  }
  // Validate the player round
  let round = ''
  const computedMoves = new Array(9)
  const moves = await mysql.query(
    'SELECT `player`, `col`, `row` FROM `moves` WHERE `game_id`= ? ORDER BY `id` ASC',
    [data.id]
  )
  if (!moves || !Array.isArray(moves) || moves.length < 1) {
    round = game[0].starting_player
  } else {
    if (moves[moves.length - 1].player === game[0].player1) {
      round = 'second'
    } else {
      round = 'first'
    }
  }
  if (moves.length > 8) {
    return
  }
  if (round === 'first' && game[0].player2 === user) {
    return
  }
  if (round === 'second' && game[0].player1 === user) {
    return
  }
  // Play game and check winner
  await mysql.query(
    'INSERT INTO `moves`(`game_id`, `player`, `col`, `row`) VALUES (?,?,?,?)',
    [data.id, user, data.col, data.row]
  )
  moves.push({ player: user, col: data.col, row: data.row })
  for (let i = 0; i < moves.length; i++) {
    const move = moves[i]
    let mark
    if (move.player === game[0].player1) {
      mark = 'x'
    } else if (move.player === game[0].player2) {
      mark = 'o'
    } else {
      continue
    }
    if (move.row === 1) {
      computedMoves[move.col - 1] = mark
    } else if (move.row === 2) {
      computedMoves[move.col + 2] = mark
    } else if (move.row === 3) {
      computedMoves[move.col + 5] = mark
    }
  }
  checkWinner(computedMoves, data.id)
}

The play function validates the data, then places the played move, then checks for the game status for a possible winner or draw.

The checkWinner function:

const checkWinner = async (computedMoves, id) => {
  if (checkWinningMark(computedMoves, 'x')) {
    await mysql.query(
      'UPDATE `games` SET `status`=?, `winner`=? WHERE `game_id`=?',
      ['finished', 'player1', id]
    )
  } else if (checkWinningMark(computedMoves, 'o')) {
    await mysql.query(
      'UPDATE `games` SET `status`=?, `winner`=? WHERE `game_id`=?',
      ['finished', 'player2', id]
    )
  } else {
    for (let i = 0; i < 9; i++) {
      if (!computedMoves[i]) {
        return
      }
    }
    await mysql.query(
      'UPDATE `games` SET `status`=?, `winner`=? WHERE `game_id`=?',
      ['finished', 'none', id]
    )
  }
}

It checks for the winning of player1 or “X” and then player2 or “O” then checks for the filled board for a draw. Then the database is updated. checkWinningMark() is not interesting. It just checks each row and col for a winning pattern for the provided mark. X or O.


At the end of the file, we call the start() method to start the application. Then inform the user current state of sync.

start()
console.log('Tic Tac Toe Application')
console.log(
  'Starting application... It is highly recommended to use a local node for syncing blocks otherwise it might take too long.'
)
const interval = setInterval(() => {
  if (queue.length() < 2) {
    clearInterval(interval)
    console.log('Sync completed. Application is running.')
  } else {
    console.log('Syncing blocks... Total synced blocks: ' + totalSyncedBlocks)
  }
}, 5000)

With a local node, it syncs ~70 blocks per second (depends on many things). It can be boosted in certain ways but it’s not necessary for this project. 70 blocks/s is still impressive enough. It all depends on the latency of the connected RPC node because the syncing is done by selecting blocks in a row one by one and waiting for the one to finish before going to the next one.


Client-side

There are many things on the client-side. I’m not going near front-end codes. You can always check the full code on the GitLab repository.

Our game board is designed with Canvas. I don’t think it’s necessary to talk about those codes.

Here is the function for broadcasting a move.

js/app.js

const submitMove = async () => {
  if (!userData.authorized) {
    document.getElementById('login-button').click()
    return
  }
  if (!userMove || userMove.length < 2) {
    return
  }
  if (!urlParams.has('id')) {
    return
  }
  const error = document.getElementById('submit-move-error')
  error.innerHTML = ''
  loading(true)
  const id = urlParams.get('id')
  try {
    const play = {
      app: 'tictactoe/0.0.1',
      action: 'play',
      id,
      col: userMove[0],
      row: userMove[1]
    }
    const operations = [
      [
        'custom_json',
        {
          required_auths: [],
          required_posting_auths: [userData.username],
          id: 'tictactoe',
          json: JSON.stringify(play)
        }
      ]
    ]
    const tx = new hiveTx.Transaction()
    await tx.create(operations)
    const privateKey = hiveTx.PrivateKey.from(userData.key)
    tx.sign(privateKey)
    const result = await tx.broadcastNoResult()
    if (result && result.result && result.result.tx_id) {
      setTimeout(() => getGameDetails(urlParams.get('id')), 1500)
      oldRound = round
      round = round === 'first' ? 'second' : 'first'
      toggleMoveInteractions('player-waiting')
    } else {
      error.innerHTML = 'Error! Check console for details. Press Ctrl+Shift+J'
      console.error(result)
    }
  } catch (e) {
    error.innerHTML = 'Error! Check console for details. Press Ctrl+Shift+J'
    console.error(e)
  }
  loading(false)
}

It creates a transaction with custom_json and uses .broadcastNoResult() method of hiveTx library for the fastest possible transaction broadcast. This method doesn’t return the result of the transaction (but it returns the offline generated tx_id so it’s possible to check for the status of the transaction later but we don’t) so it may as well fail for many reasons but we assume everything is fine. In this way, the user experience is better and smoother. Because the action goes faster.


On the game page, the application gets the game details every 5s from the API and updates the user interface. Then calls the following function to update the board with new moves and also detects the round of play.

const computeMoves = () => {
  if (!movesData || !gameData) {
    setTimeout(() => computeMoves(), 100)
    return
  }
  clearMoves()
  for (let i = 0; i < movesData.length; i++) {
    const move = movesData[i]
    let mark
    if (move.player === gameData.player1) {
      mark = 'x'
    } else if (move.player === gameData.player2) {
      mark = 'o'
    } else {
      continue
    }
    placeMark(move.col, move.row, mark)
  }
  placeMark(userMove[0], userMove[1], userMove[2])
  if (movesData.length < 1) {
    round = gameData.starting_player
  } else if (movesData[movesData.length - 1].player === gameData.player1) {
    round = 'second'
  } else {
    round = 'first'
  }
}

There are many other front-end and back-end stuff which I skip the explanation because it’s not a post about teaching development in any sense. I just explained the things I thought might be necessary for running the Hive applications.

All the codes used in this project are original. Except the rapidQueue method (MIT license).

The final project:

Original posts: