Developing TicTacToe

Published: November 04, 2020
Edited: December 08, 2020
Game.js

In our src folder we create a new file called Game.js and populate it with the following code; We wont be going to explain every line in detail but will highlight some parts:

import { Lightning } from "@lightningjs/sdk";
export default class Game extends Lightning.Component {
    static _template(){
        return {
            Game:{
                PlayerPosition:{
                    rect: true, w: 250, h: 250, color: 0x40ffffff,
                    x: 425, y: 125
                },
                Field:{
                    x: 400, y: 100,
                    children:[
                        {rect: true, w:1, h:5, y:300},
                        {rect: true, w:1, h:5, y:600},
                        {rect: true, h:1, w:5, x:300, y:0},
                        {rect: true, h:1, w:5, x:600, y:0}
                    ]
                },
                Markers:{
                    x: 400, y: 100
                },
                ScoreBoard:{ x: 100, y: 170,
                    Player:{
                        text:{text:'Player 0', fontSize:29, fontFace:'Pixel'}
                    },
                    Ai:{ y: 40,
                        text:{text:'Computer 0', fontSize:29, fontFace:'Pixel'}
                    }
                }
            },
            Notification:{
                x: 100, y:170, text:{fontSize:70, fontFace:'Pixel'}, alpha: 0
            }
        }
    }
}

We've added a Game component which acts as a wrapper for the Game board an score board so it will be easy to hide all the contents at once.

  1. PlayerPosition, this is a focus indicator of which tile the player currently is
  2. Field, the outlines of the game field
  3. Markers, the placed [ X ] / [ 0 ]
  4. ScoreBoard, the current score for player and computer
  5. Notification, the endgame notification (player wins, tie etc), in a real world app we probably would move the Notification handler to a different (higher) level so we multiple component can make use of it.

It's also possible to (instead instancing a component via type) populate the children within the template. This will populate the Field Component with 5 lines (rectangles) we also draw two 1px by 5px component and 2 components 5px by 1px components.

Field:{
    x: 400, y: 100,
    children:[
        {rect: true, w:1, h:5, y:300},
        {rect: true, w:1, h:5, y:600},
        {rect: true, h:1, w:5, x:300, y:0},
        {rect: true, h:1, w:5, x:600, y:0}
    ]
}

Let's start adding some logic, we start by adding a new lifecycle event called construct

_construct(){
    // current player tile index
    this._index = 0;
    
    // computer score
    this._aiScore = 0;
    
    // player score
    this._playerScore = 0;
} 

Next lifecycle event we add is active this will be called when a component visible property is true, alpha higher then 0 and positioned in the renderable screen.

_active(){
    this._reset();
    
    // we iterate over the outlines of the field and do a nice
    // transition of the width / height, so it looks like the 
    // lines are being drawn realtime.
    
    this.tag("Field").children.forEach((el, idx)=>{
        el.setSmooth(idx<2?"w":"h", 900, {duration:0.7, delay:idx*0.15})
    })
}

The setSmooth function creates a transition for a give property with the provided value: Look in the documentation to read more about smoothing.

We add the _reset() method which fills all available tiles with e for empty, render the tiles and change the state back to root state.

For the tile we use an array of 9 elements that we can use to all sorts of logic with (rendering / checking for winner / decide next move for the computer etc..)

_reset(){
    // reset tiles
    this._tiles = [
        'e','e','e','e','e','e','e','e','e'
    ];

    // force render
    this.render(this._tiles);

    // change back to rootstate
    this._setState("");
}

Next is our render method that accepts a set of tiles and draw some text based on the tile value.

e => empty / x => Player / 0 => computer

render(tiles){
    this.tag("Markers").children = tiles.map((el, idx)=>{
        return {
            x: idx%3*300 + 110,
            y: ~~(idx/3)*300 + 90,
            text:{text:el === "e"?'':`${el}`, fontSize:100},
        }
    });
}

Now that we have a good setup for rendering tiles and showing outlines on active we can proceed to implement remote control handling.

Since we're working with a 3x3 playfield we check (on remotecontrol up ) if the new index we want to focus on is larger or equal then zero, if we so we call the (to be implemented) setIndex() function.

_handleUp(){
    let idx = this._index;
    if(idx-3 >= 0){
        this._setIndex(idx-3);
    }
}

The logic for pressing down is mostly equal to the up but we check if the new index is not larger then the amount of available tiles.

_handleDown(){
    let idx = this._index;
    if(idx+3 <= this._tiles.length - 1){
        this._setIndex(idx+3);
    }
}

We don't want continues navigation so we check if we're on the most left tile of a column, if so we block navigation. Lets say we're on the second row, second column (which is tile index 4) and we press left, we check if the remainder is truthy 4%3 === 1 and call setIndex with the new index. If we're on the second row, first column (which is tile index 3) the remainder of 3%3 is 0 which so we don't match the condition and will not call setIndex().

_handleLeft(){
    let idx = this._index;
    if(idx%3){
        this._setIndex(idx - 1);
    }
}

The logic for pressing right is mostly the same but check if the index of the new tile where we're navigating to has a remainder.

_handleRight(){
    const newIndex = this._index + 1;
    if(newIndex%3){
        this._setIndex(newIndex);
    }
}

And the setIndex() function which does a transition of the PlayerPosition component to the new tile and stores the new index for future use.

_setIndex(idx){
    this.tag("PlayerPosition").patch({
        smooth:{
            x: idx%3*300 + 425,
            y: ~~(idx/3)*300 + 125
        }
    });
    this._index = idx;
}

If we run our game you can see that the outlines of the Field will be drawn and we can navigate over the game tiles. The next thing we need to do is the actual capturing of a tile by placing your marker on remote enter press.

On enter we first check if we're on an empty tile, if so we place our X marker and if the function's return value is true we set the Game component in a Computer state (which means it's the computers turn to play)

_handleEnter(){
    if(this._tiles[this._index] === "e"){
        if(this.place(this._index, "X")){
            this._setState("Computer");
        }
    }
}

The place() function will be called (as stated above) when a user presses ok or enter on the remote control:

  1. we update the tile value
  2. we render the new field
  3. We check if we have a winner (We will go over the Utils in a short moment)
  4. If we have a Winner we set the app to End state and Winner sub state
  5. and return false, so the _handleEnter logic will not go to Computer state
  6. If we don't have a winner we return true so the Game can go to Computer state
place(index, marker){
    this._tiles[index] = marker;
    this.render(this._tiles);

    const winner = Utils.getWinner(this._tiles);
    if(winner){
        this._setState("End.Winner",[{winner}]);
        return false;
    }

    return true;
}

In a real world game we would implement the logic of checking for a winner a changing to Computer state on a different level to make the app a bit more robust.

Next thing that we're going to do is model the statemachine. The first state that we're going to add is the Computer state which means it's the computers turn to play.

in the $enter() hook we

  1. We calculate the new position the computer can move to
  2. If the return value is -1 it means there are no possible moves left and we force the Game Component in a Tie state because we don't have a winner
  3. We create a random timeout to give a player a feeling that it's really playing against a human opponent.
  4. We hide the PlayerPosition indicator
  5. When the timeout expires we call place() with th 0 marker and go back to the root state `_setState("")

By adding _captureKey() we make that every keypress will be captured, but you can still perform some keyCode specific logic.

When we $exit() the Computer state we show the PlayerPosition indicator again, the player knows it is its turn to play.

static _states(){
    return [
        class Computer extends this {
            $enter(){
                const position = Utils.AI(this._tiles);
                if(position === -1){
                    this._setState("End.Tie");
                    return false;
                }

                setTimeout(()=>{
                    if(this.place(position,"0")){
                        this._setState("");
                    }
                }, ~~(Math.random()*1200)+200);

                this.tag("PlayerPosition").setSmooth("alpha",0);
            }

            // make sure we don't handle
            // any keypresses when the computer is playing
            _captureKey({keyCode){ }

            $exit(){
                this.tag("PlayerPosition").setSmooth("alpha",1);
            }
        }
    ]
}

Next state that we're adding is the End state with the sub state Winner and Tie. First we add some shared logic between the Winner and Tie state.

We wait for a user to press enter / ok in the End state and then we reset the Game (in reset() we also go back to root state) so this will make sure the $exit() hook will be called and that's where we show the complete Game component again and we hide the notification.

static _states(){
    return [
        class Computer extends this {
            // we hide the code for now
        },
        class End extends this{
            _handleEnter(){
                this._reset();
            }
            $exit(){
                this.patch({
                    Game:{
                        smooth:{alpha:1}
                    },
                    Notification: {
                        text:{text:''},
                        smooth:{alpha:0}
                    }
                });
            }
            static _states(){
                return [
                
                ]
            }
        }
    ]
}

We add a new _states object so we can start adding sub states.

When we $enter() the End.Winner state we

  1. Check if the winner is X so we increase to the player score
  2. If not, we increase the computer score
  3. Next we do a big patch of the template in which we hide the Game field, updated the text of the scoreboard, update the Notification text and show the Notification Component

When we $enter() the End.Tie state we

  1. Hide the Game field
  2. Update the Notification text
  3. And show the Notification Component
static _states(){
    return [
        class Computer extends this {
            // we hide the code for now
        },
        class End extends this{
            // we hide the code for now
            static _states(){
                return [
                   class Winner extends this {
                       $enter(args, {winner}){
                           if(winner === 'X'){
                               this._playerScore+=1;
                           }else{
                               this._aiScore+=1;
                           }
                           this.patch({
                               Game:{
                                   smooth:{alpha:0},
                                   ScoreBoard:{
                                       Player:{text:{text:`Player ${this._playerScore}`}},
                                       Ai:{text:{text:`Computer ${this._aiScore}`}},
                                   }
                               },
                               Notification: {
                                   text:{text:`${winner==='X'?`Player`:`Computer`} wins (press enter to continue)`},
                                   smooth:{alpha:1}
                               }
                           });
                       }
                   },
                   class Tie extends this {
                       $enter(){
                           this.patch({
                               Game: {
                                   smooth: {alpha: 0}
                               },
                               Notification: {
                                   text:{text:`Tie :( (press enter to try again)`},
                                   smooth:{alpha:1}
                               }
                           });
                       }
                   }
                ]
            }
        }
    ]
}

Now that we have modeled most of our game components it's time to start adding the the logic for the Computer controlled player.

GameUtils.js

Inside our src folder we add a lib folder and create a new file GameUtils.js and add the following function.

We test the current state of the game against a set of winning patterns by normalizing the actual pattern values and testing them against a provided regular expression.

const getMatchingPatterns = (regex, tiles) => {
    const patterns = [
        [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6],
        [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]
    ];
    return patterns.reduce((sets, pattern) => {
        const normalized = pattern.map((tileIndex) => {
            return tiles[tileIndex];
        }).join("");
        if (regex.test(normalized)) {
            sets.push(pattern);
        }
        return sets;
    }, []);
};

Next we add getFutureWinningIndex which checks if there is a potential upcoming winning move for itself (computer) or it's opponent (the player). We give priority to returning the index for the computer's winning move over blocking a potential win for the player.

const getFutureWinningIndex = (tiles) => {
    let index = -1;
    const player = /(ex{2}|x{2}e|xex)/i;
    const ai = /(e0{2}|0{2}e|0e0)/i;

    // since we're testing for ai we give prio to letting ourself win
    // instead of blocking the potential win for the player
    const set = [
        ...getMatchingPatterns(player, tiles),
        ...getMatchingPatterns(ai, tiles)
    ];

    if (set.length) {
        set.pop().forEach((tileIndex) => {
            if (tiles[tileIndex] === 'e') {
                index = tileIndex;
            }
        });
    }

    return index;
};

We finished all the logic for the Game and now it's time to test it (something we normally do during development ;) ) Run lng dev and you should be able to play a well deserved game of tic-tac-toe against an AI opponent.

For more information on the CLI please refer to Run, Test & Deploy.

If you want to take a look at all the raw files, please take a look at the app's Github Repository

PHP Code Snippets Powered By : XYZScripts.com