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.")
```
asked Jun 10, 2021 at 20:14
\$\endgroup\$

1 Answer 1

1
+50
\$\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.

answered Jun 11, 2021 at 23:07
\$\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\$ Commented Jun 13, 2021 at 3:37

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.