3
\$\begingroup\$

I developed a python Connect 4 game that only uses sys & numpy everything else is regular python! All I'm requesting is a stronger code. This is what I made:

import sys
import numpy

BOARD_SIZE_X = 7
BOARD_SIZE_Y = 6
SEARCH_DEPTH = 4

COMPUTER_PLAYER = 1
HUMAN_PLAYER = -1

#
# Method that runs the minimax algorithm and returns
# the move and score of each call.
#

def minimax(gameState, depth, player, opponent):
    availableMoves = BOARD_SIZE_X
    for i in range(0, BOARD_SIZE_X):
        if gameState[0][i] != 0:
            availableMoves -= 1

    if depth == 0 or availableMoves == 0:
        score = evaluateScore(gameState, player, opponent)
        return None, score

    bestScore = None
    bestMove = None

    for i in range(0, BOARD_SIZE_X):
        # If moves cannot be made on column, skip it
        if gameState[0][i] != 0:
            continue

        currentMove = [0, i]

        for j in range(0, BOARD_SIZE_Y - 1):
            if gameState[j + 1][i] != 0:
                gameState[j][i] = player
                currentMove[0] = j
                break
            elif j == BOARD_SIZE_Y - 2:
                gameState[j+1][i] = player
                currentMove[0] = j+1

        # Recursive minimax call, with reduced depth
        move, score = minimax(gameState, depth - 1, opponent, player)

        gameState[currentMove[0]][currentMove[1]] = 0

        if player == COMPUTER_PLAYER:
            if bestScore == None or score > bestScore:
                bestScore = score
                bestMove = currentMove
        else:
            if bestScore == None or score < bestScore:
                bestScore = score
                bestMove = currentMove

    return bestMove, bestScore

#
# Method that calculates the heuristic value of a given
# board state. The heuristic adds a point to a player
# for each empty slot that could grant a player victory.
#

def evaluateScore(gameState, player, opponent):
    # Return infinity if a player has won in the given board
    score = checkWin(gameState)

    if score == player:
        return float("inf")
    elif score == opponent:
        return float("-inf")
    else:
        score = 0

    for i in range(0, BOARD_SIZE_Y):
        for j in range(0, BOARD_SIZE_X):
            if gameState[i][j] == 0:
                score += scoreOfCoordinate(gameState, i, j, player, opponent)

    return score

#
# Method that evaluates if a given coordinate has a possible win
# for any player. Each coordinate evaluates if a possible win can be
# found vertically, horizontally or in both diagonals.
#

def scoreOfCoordinate(gameState, i, j, player, opponent):
    score = 0

    # Check vertical line
    score += scoreOfLine(
                     gameState=gameState,
                     i=i,
                     j=j,
                     rowIncrement=-1,
                     columnIncrement=0,
                     firstRowCondition=-1,
                     secondRowCondition=BOARD_SIZE_Y,
                     firstColumnCondition=None,
                     secondColumnCondition=None,
                     player=player,
                     opponent=opponent
                 )

    # Check horizontal line
    score += scoreOfLine(
                     gameState=gameState,
                     i=i,
                     j=j,
                     rowIncrement=0,
                     columnIncrement=-1,
                     firstRowCondition=None,
                     secondRowCondition=None,
                     firstColumnCondition=-1,
                     secondColumnCondition=BOARD_SIZE_X,
                     player=player,
                     opponent=opponent
                 )

    # Check diagonal /
    score += scoreOfLine(
                     gameState=gameState,
                     i=i,
                     j=j,
                     rowIncrement=-1,
                     columnIncrement=1,
                     firstRowCondition=-1,
                     secondRowCondition=BOARD_SIZE_Y,
                     firstColumnCondition=BOARD_SIZE_X,
                     secondColumnCondition=-1,
                     player=player,
                     opponent=opponent
                 )

    # Check diagonal \
    score += scoreOfLine(
                     gameState=gameState,
                     i=i,
                     j=j,
                     rowIncrement=-1,
                     columnIncrement=-1,
                     firstRowCondition=-1,
                     secondRowCondition=BOARD_SIZE_Y,
                     firstColumnCondition=-1,
                     secondColumnCondition=BOARD_SIZE_X,
                     player=player,
                     opponent=opponent
                 )

    return score

#
# Method that searches through a line (vertical, horizontal or
# diagonal) to get the heuristic value of the given coordinate.
#

def scoreOfLine(
    gameState,
    i,
    j,
    rowIncrement,
    columnIncrement,
    firstRowCondition,
    secondRowCondition,
    firstColumnCondition,
    secondColumnCondition,
    player,
    opponent
):
    score = 0
    currentInLine = 0
    valsInARow = 0
    valsInARowPrev = 0

    # Iterate in one side of the line until a move from another
    # player or an empty space is found
    row = i + rowIncrement
    column = j + columnIncrement
    firstLoop = True
    while (
        row != firstRowCondition and
        column != firstColumnCondition and
        gameState[row][column] != 0
    ):
        if firstLoop:
            currentInLine = gameState[row][column]
            firstLoop = False
        if currentInLine == gameState[row][column]:
            valsInARow += 1
        else:
            break
        row += rowIncrement
        column += columnIncrement

    # Iterate on second side of the line
    row = i - rowIncrement
    column = j - columnIncrement
    firstLoop = True
    while (
        row != secondRowCondition and
        column != secondColumnCondition and
        gameState[row][column] != 0
    ):
        if firstLoop:
            firstLoop = False

            # Verify if previous side of line guaranteed a win on the
            # coordinate, and if not, continue counting to see if the
            # given coordinate can complete a line from in between.
            if currentInLine != gameState[row][column]:
                if valsInARow == 3 and currentInLine == player:
                    score += 1
                elif valsInARow == 3 and currentInLine == opponent:
                    score -= 1
            else:
                valsInARowPrev = valsInARow

            valsInARow = 0
            currentInLine = gameState[row][column]

        if currentInLine == gameState[row][column]:
            valsInARow += 1
        else:
            break
        row -= rowIncrement
        column -= columnIncrement

    if valsInARow + valsInARowPrev >= 3 and currentInLine == player:
        score += 1
    elif valsInARow + valsInARowPrev >= 3 and currentInLine == opponent:
        score -= 1

    return score

#
# Method that executes the first call of the minimax method and
# returns the move to be executed by the computer. It also verifies
# if any immediate wins or loses are present.
#

def bestMove(gameState, player, opponent):
    for i in range(0, BOARD_SIZE_X):
        # If moves cannot be made on column, skip it
        if gameState[0][i] != 0:
            continue

        currentMove = [0, i]

        for j in range(0, BOARD_SIZE_Y - 1):
            if gameState[j + 1][i] != 0:
                gameState[j][i] = player
                currentMove[0] = j
                break
            elif j == BOARD_SIZE_Y - 2:
                gameState[j+1][i] = player
                currentMove[0] = j+1

        winner = checkWin(gameState)
        gameState[currentMove[0]][currentMove[1]] = 0

        if winner == COMPUTER_PLAYER:
            return currentMove[1]

    for i in range(0, BOARD_SIZE_X):
        # If moves cannot be made on column, skip it
        if gameState[0][i] != 0:
            continue

        currentMove = [0, i]

        for j in range(0, BOARD_SIZE_Y - 1):
            if gameState[j + 1][i] != 0:
                gameState[j][i] = opponent
                currentMove[0] = j
                break
            elif j == BOARD_SIZE_Y - 2:
                gameState[j+1][i] = opponent
                currentMove[0] = j+1

        winner = checkWin(gameState)
        gameState[currentMove[0]][currentMove[1]] = 0

        if winner == HUMAN_PLAYER:
            return currentMove[1]

    move, score = minimax(gameState, SEARCH_DEPTH, player, opponent)
    return move[1]

#
# Method that verifies if the current board is in a winning state
# for any player, returning infinity if that is the case.
#

def checkWin(gameState):
    current = 0
    currentCount = 0
    computer_wins = 0
    opponent_wins = 0

    # Check horizontal wins
    for i in range(0, BOARD_SIZE_Y):
        for j in range(0, BOARD_SIZE_X):
            if currentCount == 0:
                if gameState[i][j] != 0:
                    current = gameState[i][j]
                    currentCount += 1
            elif currentCount == 4:
                if current == COMPUTER_PLAYER:
                    computer_wins += 1
                else:
                    opponent_wins += 1
                currentCount = 0
                break
            elif gameState[i][j] != current:
                if gameState[i][j] != 0:
                    current = gameState[i][j]
                    currentCount = 1
                else:
                    current = 0
                    currentCount = 0
            else:
                currentCount += 1

        if currentCount == 4:
            if current == COMPUTER_PLAYER:
                computer_wins += 1
            else:
                opponent_wins += 1
        current = 0
        currentCount = 0

    # Check vertical wins
    for j in range(0, BOARD_SIZE_X):
        for i in range(0, BOARD_SIZE_Y):
            if currentCount == 0:
                if gameState[i][j] != 0:
                    current = gameState[i][j]
                    currentCount += 1
            elif currentCount == 4:
                if current == COMPUTER_PLAYER:
                    computer_wins += 1
                else:
                    opponent_wins += 1
                currentCount = 0
                break
            elif gameState[i][j] != current:
                if gameState[i][j] != 0:
                    current = gameState[i][j]
                    currentCount = 1
                else:
                    current = 0
                    currentCount = 0
            else:
                currentCount += 1

        if currentCount == 4:
            if current == COMPUTER_PLAYER:
                computer_wins += 1
            else:
                opponent_wins += 1
        current = 0
        currentCount = 0

    # Check diagonal wins
    np_matrix = numpy.array(gameState)
    diags = [np_matrix[::-1,:].diagonal(i) for i in range(-np_matrix.shape[0]+1,np_matrix.shape[1])]
    diags.extend(np_matrix.diagonal(i) for i in range(np_matrix.shape[1]-1,-np_matrix.shape[0],-1))
    diags_list = [n.tolist() for n in diags]

    for i in range(0, len(diags_list)):
        if len(diags_list[i]) >= 4:
            for j in range(0, len(diags_list[i])):
                if currentCount == 0:
                    if diags_list[i][j] != 0:
                        current = diags_list[i][j]
                        currentCount += 1
                elif currentCount == 4:
                    if current == COMPUTER_PLAYER:
                        computer_wins += 1
                    else:
                        opponent_wins += 1
                    currentCount = 0
                    break
                elif diags_list[i][j] != current:
                    if diags_list[i][j] != 0:
                        current = diags_list[i][j]
                        currentCount = 1
                    else:
                        current = 0
                        currentCount = 0
                else:
                    currentCount += 1

            if currentCount == 4:
                if current == COMPUTER_PLAYER:
                    computer_wins += 1
                else:
                    opponent_wins += 1
            current = 0
            currentCount = 0

    if opponent_wins > 0:
        return HUMAN_PLAYER
    elif computer_wins > 0:
        return COMPUTER_PLAYER
    else:
        return 0

#
# Function that prints the game board, representing the player
# as a O and the computer as an X
#

def printBoard(gameState):
    for i in range(1, BOARD_SIZE_X + 1):
        sys.stdout.write(" %d " % i)

    print("")
    print("_" * (BOARD_SIZE_X * 3))
    for i in range(0, BOARD_SIZE_Y):
        for j in range(0, BOARD_SIZE_X):

            if gameState[i][j] == 1:
                sys.stdout.write("|X|")
            elif gameState[i][j] == -1:
                sys.stdout.write("|O|")
            else:
                sys.stdout.write("|-|")

        print("")

    print("_" * (BOARD_SIZE_X * 3))
    print("")

#
# Method that provides the main flow of the game, prompting the user
# to make moves, and then allowing the computer to execute a move.
# After each turn, the method checks if the board is full or if a player
# has won.
#

def playGame():
    gameState = [[0 for col in range(BOARD_SIZE_X)] for row in range(BOARD_SIZE_Y)]
    moveHeights = [0] * BOARD_SIZE_X
    player = COMPUTER_PLAYER
    opponent = HUMAN_PLAYER
    winner = 0
    gameOver = False
    remainingColumns = BOARD_SIZE_X
    print("=========================")
    print("= WELCOME TO CONNECT 4! =")
    print("=========================\n")
    printBoard(gameState)

    while True:

        while True:
            try:
                move = int(input("What is your move? (Choose from 1 to %d): " % BOARD_SIZE_X))
            except ValueError:
                print("That wasn't a number! Try again.")
                continue
            if move < 1 or move > BOARD_SIZE_X:
                print("That is not a valid move. Try again.")
            elif moveHeights[move - 1] == BOARD_SIZE_Y:
                print("The chosen column is already full. Try again.")
            else:
                break

        moveHeights[move - 1] += 1
        gameState[BOARD_SIZE_Y - moveHeights[move - 1]][move - 1] = opponent
        printBoard(gameState)

        if moveHeights[move - 1] == BOARD_SIZE_Y:
            remainingColumns -= 1
        if remainingColumns == 0:
            gameOver = True
        if gameOver:
            break

        score = checkWin(gameState)
        if score == player:
            winner = player
            break
        elif score == opponent:
            winner = opponent
            break
        else:
            score = 0

        print("Now it's the computer's turn!")
        move = bestMove(gameState, player, opponent)
        if move == None:
            break

        moveHeights[move] += 1
        gameState[BOARD_SIZE_Y - moveHeights[move]][move] = player
        printBoard(gameState)

        if moveHeights[move] == BOARD_SIZE_Y:
            remainingColumns -= 1
        if remainingColumns == 0:
            gameOver = True
        if gameOver:
            break

        score = checkWin(gameState)
        if score == player:
            winner = player
            break
        elif score == opponent:
            winner = opponent
            break
        else:
            score = 0

    return winner

#
# Main execution of the game. Plays the game until the user
# wishes to stop.
#

if __name__ == "__main__":
    playing = True
    while playing:
        winner = playGame()
        if winner == COMPUTER_PLAYER:
            print("Sad! You lost!")
        elif winner == HUMAN_PLAYER:
            print("Congratulations! You won!")
        else:
            print("The board is full. This is a draw!")

        while True:
            try:
                option = input("Do you want to play again? (Y/N): ")
            except ValueError:
                print("Please input a correct value. Try again.")
                continue
            if option == 'Y' or option == 'y':
                break
            elif option == 'N' or option == 'n':
                playing = False
                break
            else:
                print("Please enter Y or N.")
```
\$\endgroup\$
0
\$\begingroup\$

PEP-8: the Style Guide for Python Code

PEP-8 defines many conventions that will assist in making Python programs more readable and maintainable by a wider audience. Things like:

  • snake_case should be used for functions, parameters and variables. gameState and availableMoves should be game_state and available_moves, etc.
  • surround binary operators with one space. eg) j+1 should be written j + 1. (Note that no white spaces should be used around the = token with keyword parameters, unless type-hints are used.)
  • All continuation lines should be indented:
    • wrong:
      while (
          row != secondRowCondition and
          column != secondColumnCondition and
          gameState[row][column] != 0
      ):
          if firstLoop:
              ...
      
    • right:
      while (row != secondRowCondition and
             column != secondColumnCondition and
             gameState[row][column] != 0):
          if first_loop:
              ...
      

Loop like a Native

See the loop like a native talk by Ned Batchelder.

Code like:

    for i in range(0, BOARD_SIZE_X):
        if gameState[0][i] != 0:
            availableMoves -= 1

can be rewritten more efficiently, without indexing as:

    for cell in gameState[0]:
        if cell != 0:
            availableMoves -= 1

If both the index and the contents of the list at that index is required, then enumerate() is preferred. Instead of this:

    for i in range(0, BOARD_SIZE_Y):
        for j in range(0, BOARD_SIZE_X):
            if gameState[i][j] == 0:
                score += scoreOfCoordinate(gameState, i, j, player, opponent)

use:

    for i, row in enumerate(gameState):
        for j, cell in row:
            if cell == 0:
                score += scoreOfCoordinate(gameState, i, j, player, opponent)

Don't mix indices

You are using i and j interchangeably. minimax() uses gameState[j][i] and evaluateScore() uses gameState[i][j]. This is very confusing. Use more descriptive index names, perhapsrow_idx and col_idx.

Magic Numbers

There are way too many magic numbers in the code. 0 and 1 are usually OK, such as when used as row/column offsets. I'm not certain what the 2 means in BOARD_SIZE_Y - 2, but I can guess. The 3 in valsInARow == 3 might deserve a name, but perhaps not.

The real grievous errors are:

            if gameState[i][j] == 1:
                sys.stdout.write("|X|")
            elif gameState[i][j] == -1:
                sys.stdout.write("|O|")

You define COMPUTER_PLAYER and HUMAN_PLAYER at the top of the file. Why aren't they used here?

Also, defining an EMPTY constant, and testing against it instead of 0, would clarify intent.

Over-parameterization

scoreOfCoordinate() takes way too many parameters. Several of the parameters are redundant.

When the row or column increment is 0, the first and second conditions are None. When the increment is -1, the first condition is -1 and the second is the board size in the corresponding direction. If the increment is +1, the conditions are reversed with the first being the board size and the second being -1.

You could remove the condition parameters, and inside the function set internal variables based on just the row & column increment parameters.

But this is probably still too complicated. The row and column values are confined to a simple, constant ranges. Simply test that!


    while (0 <= row < BOARD_SIZE_Y and 0 <= column < BOARD_SIZE_X and gameState[row][column] != 0):
        ...

or even:

VALID_ROWS = range(BOARD_SIZE_Y)
VALID_COLUMNS = range(BOARD_SIZE_X)
...

    while (row in VALID_ROWS and column in VALID_COLUMNS and gameState[row][column] != 0):
        ...

NumPy

I hate seeing numpy used for just getting the diagonals out of a matrix. It is overkill.

If you are going to require numpy, you may as well take use it. For example, 4 of the same values in a row could be written as:

    matrix = numpy.array(gameState)

    # Compare all elements with their neighbour in the adjacent column
    match = (matrix[:, :-1] != 0) & (matrix[:, :-1] == matrix[:, 1:])

    # four-in-a-row would be three adjacent matching elements
    four_in_a_row = (match[:, :-2] & match[:, 1:-1] & match[:, 2:]).any()

Checking for 4 of the same value in a column is just as easy. Of course, you could just rotate or transpose the matrix and reuse the above check

Checking diagonals is similar: matrix[:-1, :-1] == matrix[1:, 1:] does an element-wise comparison of adjacent elements in one diagonal direction, and matrix[1:, :-1] == matrix[:-1, 1:] would compare in the other direction.

Code Structure

You should break you code up into several modules

  1. A game-state management module
  2. A game UI module, which displays the board, and asks the user for input. This could be replaced with a TkInter version or a colorama ANSI console version without changing other parts of the code
  3. A computer player module, which could contain different strategies
    • random movement
    • minimax strategy

By breaking the code into separate modules, you can improve the understandability. For example, the you could ask the game-state for what the available moves are, if a particular move is valid, or if the game is in a "won" state. That code doesn't need to be duplicated in the minimax strategy code, which would make the code more focused and easier to read.

\$\endgroup\$
1
  • \$\begingroup\$ while ( is really not a violation of PEP8. See for example their # Hanging indents should add a level.; and also This PEP takes no explicit position on how (or whether) to further visually distinguish such conditional lines from the nested suite inside the if-statement. honestly I think OP's style is more legible in this case. \$\endgroup\$ – Reinderien 19 hours ago

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.