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.")
```
1 Answer 1
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
andavailableMoves
should begame_state
andavailable_moves
, etc.- surround binary operators with one space. eg)
j+1
should be writtenj + 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: ...
- wrong:
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
- A game-state management module
- 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 - 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.
-
\$\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– Reinderien2021年06月13日 03:37:24 +00:00Commented Jun 13, 2021 at 3:37