Making a Decentralized Game on Hive - Last part

desktop-pixabay.jpg

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.


town-sign-1158387_640.jpg

It's 2:30 AM and I'm not sure if I missed something. I hope you accept my attempt at making this tutorial. I have another project in mind so make sure to follow me.

Thank you again. Your votes, reblogs, and witness votes are much appreciated. I hope you like what I do.

images source: pixabay.com
All the codes used in this project are original. Except the rapidQueue method (MIT license).


The final project:
https://tic-tac-toe.mahdiyari.info/
https://gitlab.com/mahdiyari/decentralized-game-on-hive


Previous posts:
part1
part2
part3
part4
part5

10 Comments