Crafting the Enemy

Local Multiplayer might be fun if you really have someone to play with, but let’s face it: paper is still a better medium for this game.

Let’s build a script that can match the ability to play of human players and sometimes make a suboptimal move to not force a draw each game. To make the script easier to handle, I chose to hard-code the human player as the one playing Xs while the script will playing Os and will be described here as Player O or the NPC (non-player character).

Just be warned: the logic is not perfect, it’s not optimal, but it does a decent job at giving the player a challenge.

Notation

This page contains board samples that also represent potential moves done by the NPC – in several cases there are more than one good moves that can be done and it’s good to have a basic understanding on how to read the board in this case.

Making the game easier by rolling a D10 dice

Tic-Tac-Toe is a solved game that can always end with a draw when both players play perfectly, but let’s face it, it’s not fun playing knowing you will never win.

To push the luck a bit in favor of the player, we generate a random number that will be used to sometimes skip the most optimal moves.

set @chance = Random(1,10)

NPC Logic

Let’s dive into the two objectives a player can have:

#Winning

The main priority of the NPC is simple: win the game.

Let’s consider the following situation, where the NPC (Player O) has to select the next move:

Selecting the 3rd cell in the top row will make this player victorious, but we need to make the script be able to identify it.

Let’s use code to identify the opportunities for moves:

set @opportunities = ReplaceList(@verification, "E", "1", "2", "3", "4", "5", "6", "7", "8", "9")
set @opportunitiesForO = ReplaceList(@opportunities, "WIN", "OOE", "OEO", "EOO")
set @opportunitiesForX = ReplaceList(@opportunities, "WIN", "XXE", "XEX", "EXX")

What’s happening here?

  • The board in this state is represented as the following string: @fields = 'OO34X6X89'
  • The @verification string for this looks like this:
    OO3^4X6^X89^O4X^OX8^369^OX9^XX3^
  • We replace each number contained in this string with the letter E (symbolizing empty cells) using the ReplaceList function:
    OOE^EXE^XEE^OEX^OXE^EEE^OXE^XXE^
  • Player O is close to winning if this string contains any of the following substrings: OOEOEOEOO) and if this is the case, we replace this with the substring WIN (this is stored in the @opportunitiesForO variable for Player O)
  • The variable string @opportunitiesForX is used in the same way to see if Player X is about to win the game

Let’s try to seize the victory:

/* NPC Logic: START */
/* Grab the win opportuniny*/

IF
    IndexOf(@opportunitiesForO, "WIN") > 0
        THEN
            set @winPosition = IndexOf(@opportunitiesForO, "WIN")

            set @emptyPositionInWin = IndexOf(Substring(@opportunities, @winPosition, 3), "E")
            set @fieldPosition = Add(@winPosition, Add(@emptyPositionInWin, -1))
            set @fieldIndex = Substring(@verification, @fieldPosition, 1)
            set @fields = Replace(@fields, @fieldIndex, "O")

If we learn that the NPC can win in their next move, we must only determine where exactly the winning O should be placed and learn that we we do the following:

  • @winPosition → we determine at which place in the @opportunitiesForO the WIN string occurs
  • @emptyPositionInWin → using the win position from above, we extract the a 3 letter substring from the @opportunities string (it’s a step before replacing the winning line with WIN) and find which of the three characters contains the E character representing the empty cell
  • @fieldPosition → here we add the numbers from the above steps to get the index from the @verification string in the next step
  • @fieldIndex → using the number from above, we retrieve the number hidden at the @fieldPosition in the @verification string
            set @state = "Os Win!"
            set @stateClass = "o"
            set @gameOver = true
            set @firstPlayer = "X"

Blocking Xs from winning

If you can’t win immediately, you need to concentrate on blocking the other player from winning in their next move. In similar way as above, we detect if Player X has an option to win in their next move.

/* Block Xs from winning */
ELSEIF
    IndexOf(@opportunitiesForX, "WIN") > 0
        THEN
            set @winPosition = IndexOf(@opportunitiesForX, "WIN")
                                
            set @emptyPositionInWin = IndexOf(Substring(@opportunities, @winPosition, 3), "E")
            set @fieldPosition = Add(@winPosition, Add(@emptyPositionInWin, -1))
            set @fieldIndex = Substring(@verification, @fieldPosition, 1)
            set @fields = Replace(@fields, @fieldIndex, "O")

Making the (sometimes sub-optimal) first move

Player X isn’t always the one that’s starting the game. Knowing this we need to have a separate case for the NPC making the first move.

There are three distinct opening that we can make:

  • Take any of the corner cells
  • Take the middle cell
  • Take any of the edge cells
/* First Move Logic */
ELSEIF 
    @fields == "123456789"
    AND
    @firstPlayer == "O"
        THEN 
            /* Don't be a perfect opponent */
            IF @chance <= 6
                THEN 
                    set @fields = Replace(@fields, Add(Multiply(Random(1,4),2), -1), "O") /* Select a corner */
            ELSEIF
                @chance >= 7 AND @chance <= 9
                THEN 
                    set @fields = Replace(@fields, "5", "O") /* Select the middle*/
            ELSEIF
                @chance == 10
                THEN 
                    set @fields = Replace(@fields, Multiply(Random(1,4),2), "O") /* Select an edge*/
            ENDIF

As you can see, ff the NPC starts with the first move, it considers the @chance variable to make the decision on how to start.

  • values from 1 to 6 (60% chance) – select a corner
  • values from 7 to 9 (30% chance) – play the middle
  • value equal to 10 (10% chance) – grab an edge cell
    Select a corner cell

    Randomly selecting a corner

    • Corner cells have the following values: 1379
    • Let’s concatenate this into one string 1379
    • We can now generate a random number (Random(1,4)) to get a random number from a range including numbers from the first to the last (4th) position in the string
    • Now we can combine this into Substring('1379', Random(1,4), 1) which will represent a random value of the four possible corner cells
    Select the middle
    Select an edge cell

    Technically, the middle of an edge cell (not the entire edge):

    Randomly selecting an edge

    • Edge cells have the following values: 2468
    • We can see those are even number which are multipliers of 2
    • This means that if we got a random number from the range of 1 to 4 (Random(1,4)) and multiply it by 2 (Multiply(Random(1,4),2)), we will always generate one of our even which represent edge cells

Second move logic

Sometimes the NPC, will start second and needs to be reactive to what the human player is doing.

After a corner was taken

The NPC will take the middle position to block possible forks.

/* Second move */
ELSEIF
    /* if X has played a corner field first */
    @fields == "X23456789"
    OR
    @fields == "12X456789"
    OR
    @fields == "123456X89"
    OR
    @fields == "12345678X"
        THEN
            set @fields = Replace(@fields, "5", "O")
After the middle was taken

The NPC will take a random corner:

ELSEIF
    /* if X has played the center field first */
    @fields == "1234X6789"
        THEN
            set @fields = Replace(@fields, Add(Multiply(Random(1,4),2), -1), "O")
/* Fourth Move: Counter play for corner forks :D */

This sums up the coded reactions to the first move done by the player.


Blocking Forking

One other situation we need to account for is preventing players from building forks – the occur when a player has two or more next move that would allow them to win.

The most common type of this is the corner fork where one player controls 3 corners which would give them 3 opportunities to win:

We need to prevent this situation from happening, so if Player X has to two corners taken and Player O took the middle, in the next move it needs to take an edge. Why? Because it creates a line of two O’s and Player X needs to respond to this by blocking Os from winning.

There are two possible boards arrangements for this type of forking and here you can see the responses:

Corner forks

The middle edge cells will be selected at random:

The code accounting for both forks:

ELSEIF
    (@fields == "X234O678X"
    OR
    @fields == "12X4O6X89")
    AND
    @chance >= 9
        THEN
            
            set @fields = Replace(@fields, Multiply(Random(1,4),2), "O")

There’s no code fighting a different type of fork, so consider this a hint on how to beat the NPC 😉


Fallback move

If the situation we’re dealing with is not like any of the cases we processed above, we just simply have NPC select a random free field.

ELSE
    set @tempFields = ReplaceList(@fields, "", "X", "O")
    set @newField = Substring(@tempFields, Random(1, Length(@tempFields)), 1)
    set @fields = Replace(@fields, @newField, "O")
ENDIF

With this out of the way, we have a NPC player and we can see the full code next.