Implementing Game Logic into your Plugin
Welcome once again to the fourth tutorial in our series explaining how to launch an HTML5 game in the Desktop Wallet by writing an ARK Core plugin and using Construct 3 for the client. In this part, we will implement turn-based game logic into our Connect 4 game by reading the smartbridge (also known as the vendorfield) messages of transactions sent to our generated game addresses to fill up the game board and keep track of whose turn it is.
Implementing Game Logic
Let’s go through the rules of the game so we implement our logic correctly(we are using the connect four game for example):Starting connect four game board
- There are 7 columns that can each hold 6 discs.
- Each player takes a turn to insert 1 disc into any of the columns that are not full.
- The first to place 4 of their discs in an unbroken line, either vertically, horizontally or diagonally wins and the game ends.
- If the board is full and nobody has placed 4 discs in a valid line, the game ends in a tie.
As there are 7 columns, our smartbridge message should be 1, 2, 3, 4, 5, 6 or 7 to represent each of the available columns. If player one sends a transaction with a smartbridge message of 3 then our board would be like this:
If player two also sends a transaction with 3 as the smartbridge message, their disc will be stacked on top of the previous one as it is in the same column:
If a chosen column is full, or a player sends a transaction out of turn, or a non-participating wallet sends a transaction to the game address, the move will be considered invalid and ignored.
Now that we’ve mapped out our game logic, it’s time to code it up. As usual, all of our Core plugin code lives in manager.ts so let’s not waste any time and open it up to begin! Most of our work on our Core plugin today will be on the generateState function that we started in the previous part of the tutorial series. We’re going to extend it to process messages in the smartbridge field of transactions to update the state of our game board according to the rules we just described.
We’re going to start by finding the following block that we wrote last time:
1for (const transaction of transactions) {2 if (transaction.senderId !== address && transaction.senderId !== players[1] && transaction.amount === wager) {3 players[2] = transaction.senderId;4 break;5 }6}
Replace it with this:
1let transactionCounter = 0;2for (const transaction of transactions) {3 if (transaction.senderId !== address && transaction.senderId !== players[1] && transaction.amount === wager) {4 players[2] = transaction.senderId;5 break;6 }7 transactionCounter++;8}
We do this so that we count the number of transactions that it took for us to match a valid wager, so we can disregard those transactions when we actually generate our game board. The reason for this is to stop cheaters from creating a new game with a wager and a valid smartbridge value in the same transaction that would trigger a move on the board too early.
Next up, find the following line that we wrote in the last tutorial:
1this.gameStates[address] = { players, wager };
Replace this line with the following code block beneath it:
1const board: Array<Array<number>> = [];2let turn: number = 0;3let outcome: string | number = "ongoing";
So, we’re declaring some new variables: board, outcome and turn. The board variable will contain an array holding the state of each column on the board. The outcome variable will be used to store who has won the game, if it is still ongoing, or if it is tied, and turn determines whose turn it is; we’re initializing it to zero until we know we have 2 valid participants (and then player 2 always starts since they were the last to join the game).
Now add the following code block:
1if (players[2]) {2 turn = 2;3 for (let i = 0; i < 7; i++) {4 board.push([]);5 }6 // We'll add more here7}8this.gameStates[address] = { board, outcome, players, turn, wager };
This means the rest of our board generation logic will only execute if we have two valid players, which sets the initial turn to belong to player 2 and initializes 7 empty columns on our board array. The last line means that our game client will receive information about the board state, the game outcome, the participating players, whose turn it is and the wager amount.
Next, we will add the following code block:
1const moves = transactions.slice(transactionCounter + 1).filter(transaction => !!transaction.vendorField);for (const move of moves) { 2 if (move.senderId === players[turn]) { 3 const column = parseInt(move.vendorField) — 1; 4 5 if (!isNaN(column) && column >= 0 && column <= 6) { 6 if (board[column].length < 6) { 7 outcome = this.makeMove(turn, board, column); 8 turn = turn === 2 ? 1 : 2; 9 }10 }11 }12 13 if (outcome !== "ongoing") {14 break;15 }16}
So, what does this block do? Remember our first code modification earlier, where we added a transaction counter. This starts by removing those early transactions from our array of moves so we only start validating moves that occurred after a valid opponent matched the game wager. We filter out any transactions that do not include a smartbridge message, since that is the mechanism we use to make a move on the board so any transactions without a message are invalid. Then we loop through all the remaining transactions, making sure the sender of the transaction matches the player whose turn it is.
Since JavaScript arrays begin from 0 rather than 1, we have to subtract 1 from the value of the smartbridge message. For instance, although the first column of our board is 1, and the last column is 7, they are represented internally in the array as columns 0 and 6 respectively. So we must check that the column value is a valid number within the range 0-6, and that the column is not already full, i.e. it has less than 6 discs in it. If we’re all good so far, our routine calls the makeMove function that we’re about to write, which updates the outcome variable. We then rotate the player’s turn, so if player 2 took a move, it’s now player 1’s turn or vice versa.
Lastly, we check the value of the outcome variable. If the value is no longer ongoing then it means we either have a winner or a tie, so we should stop processing any further transactions on this game address as the game is already over.
And with that, our generateState function is complete! The last thing to do before heading over to Construct 3 is to write our makeMove function. This is game-specific and includes the logic to check if four discs are in a line:
1private makeMove(turn: number, board: Array<Array<number>>, column: number) { 2 board[column].push(turn); 3 let full = true; 4 5 for (let i = 0; i < board.length; i++) { 6 for (let j = 0; j < 6; j++) { 7 const disc = board[i][j]; 8 if (disc) { 9 let won = false;10 if (board[i][j + 1] === disc && board[i][j + 2] === disc && board[i][j + 3] === disc) {11 board[i][j] = board[i][j + 1] = board[i][j + 2] = board[i][j + 3] = disc;12 won = true;13 }14 15 if (board[i + 1] && board[i + 2] && board[i + 3] && board[i + 1][j] === disc && board[i + 2][j] === disc && board[i + 3][j] === disc) {16 board[i][j] = board[i + 1][j] = board[i + 2][j] = board[i + 3][j] = disc;17 won = true;18 }19 20 if (board[i + 1] && board[i + 2] && board[i + 3] && board[i + 1][j + 1] === disc && board[i + 2][j + 2] === disc && board[i + 3][j + 3] === disc) {21 board[i][j] = board[i + 1][j + 1] = board[i + 2][j + 2] = board[i + 3][j + 3] = disc;22 won = true;23 }24 25 if (board[i + 1] && board[i + 2] && board[i + 3] && board[i + 1][j — 1] === disc && board[i + 2][j — 2] === disc && board[i + 3][j — 3] === disc) {26 board[i][j] = board[i + 1][j — 1] = board[i + 2][j — 2] = board[i + 3][j — 3] = disc;27 won = true;28 }29 30 if (won) {31 return disc;32 }33 } else {34 full = false;35 }36 }37 }38 39 if (full) {40 return "tie";41 }42 43 return "ongoing";44}
In a nutshell, this places our new disc in the board at the column the player has chosen, then checks all the columns for the various permutations that can trigger a win condition, i.e. a vertical, horizontal or diagonal line of 4 matching discs. If that occurs, it returns the number of the winning player (i.e. player 1 or 2). If not, it checks if there are still some free spots on the board. If not, the function returns a tie result. Otherwise, the game remains ongoing.
Once these steps have been completed, then we have officially finished with our game logic!
Now, save your work and run yarn build. It’s time to head over to Construct 3 to bring it all together.
Working with Construct 3
Go to our Event Sheet 1 and find our previously created On start of layout event. Right-click our existing event and Add script as we’ll be writing some code here to allow our lobby iframes (from the previous chapter) to interact with our game. Specifically, we’ll include a link to watch an ongoing game.
1if (!window.addedEventListener) {2 window.addEventListener("message", event => {3 if (event.data) {4 runtime.callFunction("IPC", event.data);5 }6 });7 8 window.addedEventListener = true;9}
You’ll notice that our code calls a function IPC, or inter-process communication. We need to create this function ourselves, but it will be very simple. It will just listen for a message containing the game address, store it in a variable and move us into a new game layout. But wait! Before we make our function, we must add our new game layout. Right-click Layouts in the Project menu and choose Add layout. Opt to also create an event sheet when asked. Then return to our Event sheet 1 as before.
At this point, it’s wise to rename our layouts and event sheets to avoid confusion. Right-click Layout 1 and rename it to Lobby Layout. Do the same for Event Sheet 1, calling it Lobby Event Sheet. Then rename Layout 2 to Game Layout and Event Sheet 2 to Game Event Sheet.
Let’s create that function. Right-click an empty area of the event sheet and choose Add function. Call it IPC and leave everything else as it is. Right-click our new function and choose Add parameter. Call it __Message and set its type to String. Click OK. For now, we’ll leave our function empty and come back to it in a few minutes.
To add our link to watch an ongoing game, we’ll edit the existing code in our ParseLobby function. Scroll through our event to locate that function, then find the following code blocks:
1for (const game of existingGames) {2 const wager = game.game.wager / 100000000;3 html += "<div>Game between " + game.game.players[1] + " and " + game.game.players[2] + " for " + wager + runtime.globalVars.Symbol + "</div>";4}5 6for (const game of ourGames) {7 const wager = game.game.wager / 100000000;8 html += "<div>Game between " + (game.game.players[1] === runtime.globalVars.ValidatedAddress ? "you" : game.game.players[1]) + " and " + (game.game.players[2] === runtime.globalVars.ValidatedAddress ? "you" : game.game.players[2]) + " for " + wager + runtime.globalVars.Symbol + "</div>";9}
We’re going to replace them with code blocks that also include a link to watch our game, which will work by sending a message from the iframe to our game, containing the address of the game we want to watch.
1for (const game of existingGames) {2 const wager = game.game.wager / 100000000;3 html += "<div>Game between " + game.game.players[1] + " and " + game.game.players[2] + " for " + wager + runtime.globalVars.Symbol + " (<a href='#' onclick='parent.postMessage(\"" + game.address + "\", \"*\"); return false'>Watch</a>)</div>";4}5 6for (const game of ourGames) {7 const wager = game.game.wager / 100000000;8 html += "<div>Game between " + (game.game.players[1] === runtime.globalVars.ValidatedAddress ? "you" : game.game.players[1]) + " and " + (game.game.players[2] === runtime.globalVars.ValidatedAddress ? "you" : game.game.players[2]) + " for " + wager + runtime.globalVars.Symbol + " (<a href='#' onclick='parent.postMessage(\"" + game.address + "\", \"*\"); return false'>Watch</a>)</div>";9}
Now, about that empty IPC function. As this will be processed by the game layout rather than the lobby, we’re going to add a new global variable in the Game Event Sheet instead. Switch to that, right-click the empty sheet and choose Add global variable. Call it GameAddress which should be a String. Add another global variable called TurnAddress which is also a String. Flip back to our Lobby Event Sheet and, in the IPC function, add a couple of actions. The first will be System > Set Value and choose to set the GameAddress variable to the value Message. Our second action will be System > Go to layout. Pick Game Layout from the list, click Done and we are indeed done with our lobby!
All the remaining work will take place in the Game Layout and Game Event Sheet. We’ll design our board and discs and add some text to indicate whose turn it is and the status of the game. To do this, head on over to our Game Layout.
Right-click our empty layout and choose Insert new object. We’ll be adding a Sprite this time. Call it Board and you’ll see the Animations Editor open. Choose the Fill tool and pick the color of your choice for the board and fill our sprite with that color. Close the Animations Editor and resize our board to 480x480 pixels.
Repeat the process, adding another Sprite. This time call it Disc and draw a white circle using the Ellipse tool. Call the animation name 0, and also set the Speed and Repeat Count to 0. Right-click the 0 in the Animations panel and choose Duplicate. Call the duplicated animation 1. Duplicate it again and call it 2. Choose 1 and fill the circle with a red color. Choose 2 and fill it with a yellow color. These are our game discs: player 1 will be red, player 2 will be yellow.
Close the Animations Editor and then click our new disc in the layout. Choose the Instance variables option from the Properties window and pick Add new instance variable. Call the name Column with a type of Number. Add another instance variable and call it Row, again with a type of Number. Resize our disc to 60x60 and place it in the bottom left corner of our board. Copy and paste our disc object to build a grid of 7 columns and 6 rows. Click each disc and set the Column and Row instance variable values correctly, so the bottom left disc is Column 0, Row 0, the next disc up is Column 0, Row 1, and so on, with the bottom right disc being Column 6, Row 0 and the top right disc being Column 6, Row 5. Remember how JavaScript arrays start from 0!
Almost done with the layout! We need to add a text box to indicate whose turn it is, or if the game is over. Let’s do that now. Right-click our layout and pick Insert new object. Add a Text object and call it StatusText. Drop it somewhere above the discs and set the color to white so it’s readable on the blue background. Increase the width of the text box so it covers the entire width of our board. The very last thing to do is to add a Mouse object so we can interact with mouse events: this is so we can send a game transaction with an encoded smartbridge message when the player clicks on a column to play.
We’re now done with our layout. Time to head over to the Game Event Sheet! Add a new empty function and call it ParseBoard. Then add a new event, WebSocket > On text message whose action is JSON > Parse. The JSON string to parse is WebSocket.MessageText. Now, add a sub-event within the Websocket -> On Text Message event. Choose JSON and then Has Key. Enter “games” then press Done. Click Add action for our newly created sub-event and drill down to System > Set value. We want to set our JSON object to the value of JSON.get(“Games”). Click Done. This is the same event that we also created in our lobby, but we also need it to fire while in the game too so our client updates when the game state changes. Add a further action to this sub-event: Functions > ParseBoard as this function will be responsible for updating the state of the board.
We also need to update the board as soon as the game layout opens, so let’s create another event. Go to System > On start of layout. The action should be Functions > ParseBoard.
Time to write our ParseBoard logic! We’re going to pick the game that we want by using the GameAddress that we saved earlier and display whose turn it is. If the address of the player whose turn it is matches our own validated address, we will print “You” instead of the address. So, let’s go!
1const games = JSON.parse(runtime.globalVars.JSON); 2const game = games[runtime.globalVars.GameAddress]; 3let text = ""; 4runtime.globalVars.TurnAddress = ""; 5 6if (game.outcome === "ongoing") { 7 text = `Current turn: ${game.players[game.turn]} (${game.turn === 1 ? "Red" : "Yellow"})`; 8 runtime.globalVars.TurnAddress = game.players[game.turn]; 9} else if (game.outcome === "tie") {10 text = "Game tied!";11} else {12 text = `Winner: ${game.players[game.outcome]}!`;13}14 15if (runtime.globalVars.ValidatedAddress) {16 text = text.replace(runtime.globalVars.ValidatedAddress, "You");17}18 19runtime.objects['StatusText'].getFirstInstance().text = text;
Hopefully, by now you can understand how this works. We extract our game from the list of games by using the GameAddress variable, and then check if the game is ongoing. If so, we show the address of whose turn it is, and the color they play (remember, player 1 is always red and player 2 is always yellow). If the outcome is tied or there’s a winner, we’ll display that information instead. Also, if the address matches our ValidatedAddress, meaning it’s us, we replace that address with “You”. We also store the address of the current player in our TurnAddress variable if the game is not over, otherwise, the value is cleared.
Let’s now expand our ParseBoard further to change the color of the discs to represent the current state of the board:
1const discs = runtime.objects['Disc'].getAllInstances();2for (const column in game.board) {3 let row = 0;4 for (const position of game.board[column]) {5 const disc = (discs.filter(disc => disc.instVars.Column === parseInt(column) && disc.instVars.Row === parseInt(row)))[0];6 disc.setAnimation(game.board[column][row].toString());7 row++;8 }9}
This iterates through all the columns in the board data received from the WebSocket and sets the color of the matching disc at each column and row.
Now what’s left is to allow the current player to send a transaction to the game address using the smartbridge value for the column we want to place our disc in. To do this, we’ll add another event. Choose Mouse > On object clicked. We want to act when the left button is clicked on a Disc object. But that by itself is not enough, we want to only execute this when it’s our turn and if the chosen column is not full. So right-click our new event and Add another condition. Choose Disc > Is Playing and enter “0”. This means our action will only trigger if the disc is white, i.e. it has not already been played (otherwise it would be 1 for red or 2 for yellow). Finally, we only want to trigger this when it’s our turn, so Add another condition again, and this time pick System > Compare variable. Choose ValidatedAddress as the variable, and TurnAddress as the value.
Our action for this new event is Browser > Go to URL. We want to use the ark URI scheme to send a transaction from our player’s wallet to the game address, with a nominal value of 1 arktoshi, with the smartbridge message of the column we’re placing our disc in. This can be achieved with the following URL in our Construct 3 action: “ark:” & GameAddress & “?amount=0.00000001&vendorField=” & (Disc.Column + 1) & “&wallet=” & ValidatedAddress
Next Steps
Congratulations, you have now created a playable blockchain game! But you’ll have probably noticed that the winner doesn’t receive a prize, and if there’s a tie the players don’t get their wager back. That’s the focus of the next part in our tutorial series, where we look into how game prizes are calculated, awarded and paid out.
If you become stuck at any point make sure to consult our documents on our Core Developer Docs. In addition, you can reach out via our contact form.
Check out previous posts in this series for reference here: