Project 1 - Connect 4

In this project, we will implement a Connect 4 game with a rather basic graphical interface. To achieve this, we will use the fundamental objects of Python.

import copy

import solutions

Game rules

The goal of Connect 4 is to align a sequence of 4 pieces of the same color on a grid with 6 rows and 7 columns. Each player has 21 pieces of one color (usually yellow or red by convention). The two players take turns placing a piece in the column of their choice, and the piece slides down to the lowest possible position in that column, after which it is the opponent’s turn to play. The winner is the player who first aligns (horizontally, vertically, or diagonally) at least four consecutive pieces of their color. If, when all the grid cells are filled, neither player has achieved such an alignment, the game is declared a draw.

To simplify the code for this project, we will assume that winning alignments can only be horizontal or vertical. Diagonals will not be considered (but they are an interesting exercise to go further!).

Project plan

We will break down the construction of the game into different parts:

  • initialization of the grid

  • representation of the grid

  • game function

  • detection of a victory (horizontal)

  • detection of a victory (vertical)

  • end of game

Initialization of the grid

The objective of this part is to initialize a Python object that represents a Connect 4 grid. The choice we will make is to represent the grid as a list of lists. It will be a 6x7 matrix: we will therefore have a list of 6 elements (which will represent the rows of the grid), each of which will be a list containing 7 elements (which will represent the pieces).

Each element of the grid will be represented by a string, which can take three values:

  • ’ ’ : if it is an empty cell

  • ‘R’ : if it is a red piece.

  • ‘Y’ : if it is a yellow piece.

In the grid initialization function, each element will therefore be initialized as a string containing a space.

Note: Make sure that the rows are independent objects, in other words, modifying one of the lists does not affect the others.

Expected result

grid_solution = solutions.initialize_grid()
grid_solution
print(f'Number of rows: {len(grid_solution)}')
print(f'Number of columns: {len(grid_solution[0])}')

Your turn!

def initialize_grid():
    # Your code here
    return grid
# Checking the result
grid = initialize_grid()
grid
# Checking the result
print(f'Number of rows: {len(grid)}')
print(f'Number of columns: {len(grid[0])}')

Representation of the grid

Our grid is initialized, but its display is quite basic. The idea of this part is to provide a more visual representation of the game during a game.

To do this, we will create a function that takes the previously initialized grid as input and returns its representation (via the print function). The columns will be separated by the | character (vertical bar).

Hint: A possible solution involves two concepts we have seen in previous exercises: string concatenation and the join function, which “joins” the elements of a list by separating them with a certain character. Here is a reminder of an example using these two concepts:

l = ["a", "b", "c", "d", "e"]
l_join = "START " + ", ".join(l) + " END"
print(l_join)

Expected result

solutions.display_grid(grid_solution)

Your turn!

def display_grid():
    # Your code here
# Checking the result
display_grid(grid)

Game function

Now that we can represent our grid, let’s focus on the core of Connect 4: the game. The objective of this part is to code a make_move function that will modify the grid when a player takes their turn.

This function takes as input:

  • the grid

  • the column chosen by the player

  • the color of the piece (‘R’ for the red piece, and ‘Y’ for the yellow piece)

and returns the updated grid after the player’s turn.

If the chosen column is already full, return an error message.

Note: In Python, numbering starts at 0. The first column is therefore column 0 from the indexing point of view.

Optional: Return an error message if a player tries to play in a nonexistent column or with an unauthorized color.

Expected result

grid_solution = solutions.initialize_grid()  # Initialization
grid_solution = solutions.make_move(grid=grid_solution, column_to_play=2, disc_color="R")  # 1st turn
grid_solution = solutions.make_move(grid=grid_solution, column_to_play=5, disc_color="Y")  # 2nd turn
grid_solution = solutions.make_move(grid=grid_solution, column_to_play=2, disc_color="R")  # 3rd turn
solutions.display_grid(grid_solution)

Your turn!

def make_move(grid, column_to_play, disc_color):
    new_grid = copy.deepcopy(grid)  # Avoid modifying the initial grid
    # Your code here
    return new_grid
# Checking the result
grid = initialize_grid()  # Initialization
grid = make_move(grid=grid, column_to_play=2, disc_color="R")  # 1st turn
grid = make_move(grid=grid, column_to_play=5, disc_color="Y")  # 2nd turn
grid = make_move(grid=grid, column_to_play=2, disc_color="R")  # 3rd turn
display_grid(grid)

Detection of a victory (horizontal)

Now that it is possible to actually play Connect 4, we need to detect a victory to end the current game. To do this, we will simplify the problem by breaking it down as much as possible.

First, we focus on detecting a horizontal victory. To do this, we will use two functions:

  • a check_row_victory function that takes a Connect 4 row as input (i.e., a list of size 7) and returns True if 4 consecutive pieces of the same color are found on the row, and False otherwise

  • a check_horizontal_victory function that takes a complete grid as input and returns True if any row in the grid meets the previous condition, and False otherwise

Expected result

# Detection of a (horizontal) victory on a row
row1 = [" ", "R", "R", "R", "Y", "Y", " "]
row2 = [" ", "R", "R", "R", "R", "Y", " "]

print(solutions.check_row_victory(row1))  # Returns False
print()  # New line
print(solutions.check_row_victory(row2))  # Returns True
# Detection of a (horizontal) victory on a grid
grid_solution = solutions.initialize_grid()  # Initialization
print(solutions.check_horizontal_victory(grid_solution))  # Returns False
print()  # New line

grid_solution = solutions.make_move(grid=grid_solution, column_to_play=2, disc_color="R")
grid_solution = solutions.make_move(grid=grid_solution, column_to_play=3, disc_color="R")
grid_solution = solutions.make_move(grid=grid_solution, column_to_play=4, disc_color="R")
grid_solution = solutions.make_move(grid=grid_solution, column_to_play=5, disc_color="R")
solutions.display_grid(grid_solution)
print()  # New line

print(solutions.check_horizontal_victory(grid_solution))  # Returns True

Your turn!

def check_row_victory(row):
    # Your code here
# Checking the result
row1 = [" ", "R", "R", "R", "Y", "R", " "]
row2 = [" ", "R", "R", "R", "R", "Y", " "]

print(check_row_victory(row1))  # Returns False
print(check_row_victory(row2))  # Returns True
def check_horizontal_victory(grid):
    # Your code here
# Checking the result
grid = initialize_grid()  # Initialization
print(check_horizontal_victory(grid))  # Returns False

grid = make_move(grid=grid, column_to_play=2, disc_color="R")
grid = make_move(grid=grid, column_to_play=3, disc_color="R")
grid = make_move(grid=grid, column_to_play=4, disc_color="R")
grid = make_move(grid=grid, column_to_play=5, disc_color="R")
display_grid(grid)
print(check_horizontal_victory(grid))  # Returns True

Detection of a victory (vertical)

Now, we focus on detecting a vertical victory. Compared to the previous situation, the difficulty is that we cannot directly loop over the columns. We will therefore build a check_vertical_victory function that, for each column:

  • retrieves the elements of the column in a list

  • applies the check_row_victory function to this list to check for 4 consecutive pieces of the same color in the considered column

Expected result

# Detection of a (vertical) victory on a grid
grid_solution = solutions.initialize_grid()  # Initialization
print(solutions.check_vertical_victory(grid_solution))  # Returns False
print()  # New line

grid_solution = solutions.make_move(grid=grid_solution, column_to_play=2, disc_color="Y")
grid_solution = solutions.make_move(grid=grid_solution, column_to_play=2, disc_color="Y")
grid_solution = solutions.make_move(grid=grid_solution, column_to_play=2, disc_color="Y")
grid_solution = solutions.make_move(grid=grid_solution, column_to_play=2, disc_color="Y")
solutions.display_grid(grid_solution)
print()  # New line

print(solutions.check_vertical_victory(grid_solution))  # Returns True

Your turn!

def check_vertical_victory(grid):
    # Your code here
# Checking the result
grid = initialize_grid()  # Initialization
print(check_vertical_victory(grid))  # Returns False
print()  # New line

grid = make_move(grid=grid, column_to_play=2, disc_color="Y")
grid = make_move(grid=grid, column_to_play=2, disc_color="Y")
grid = make_move(grid=grid, column_to_play=2, disc_color="Y")
grid = make_move(grid=grid, column_to_play=2, disc_color="Y")
display_grid(grid)
print()  # New line

print(check_vertical_victory(grid))  # Returns True

End of game

In our simplified version of Connect 4, we can now declare the end of the game: as soon as a horizontal or vertical victory is detected.

We will first create a check_victory function that takes the grid as input and returns True if a horizontal or vertical victory is detected, and False otherwise.

Ideally, we would not want to manually test after each move if the game is over to limit code duplication. We will therefore create a make_move_and_check_victory function that:

  • takes the same inputs as the make_move function

  • calls the make_move function to make the move

  • tests after the move if a victory is detected via the check_victory function. If a victory is detected, the function prints “END OF GAME”.

Expected result

grid_solution = solutions.initialize_grid()  # Initialization
print("Turn 1")
grid_solution = solutions.make_move_and_check_victory(grid=grid_solution, column_to_play=2, disc_color="Y")
print("Turn 2")
grid_solution = solutions.make_move_and_check_victory(grid=grid_solution, column_to_play=2, disc_color="Y")
print("Turn 3")
grid_solution = solutions.make_move_and_check_victory(grid=grid_solution, column_to_play=2, disc_color="Y")
print("Turn 4")
grid_solution = solutions.make_move_and_check_victory(grid=grid_solution, column_to_play=2, disc_color="Y")

Your turn!

def check_victory(grid):
    # Your code here
def make_move_and_check_victory(grid, column_to_play, disc_color):
    grid = copy.deepcopy(grid)
    # Your code here
    return grid
# Checking the result
grid = initialize_grid()  # Initialization
print("Turn 1")
grid = make_move_and_check_victory(grid=grid, column_to_play=2, disc_color="Y")
print("Turn 2")
grid = make_move_and_check_victory(grid=grid, column_to_play=2, disc_color="Y")
print("Turn 3")
grid = make_move_and_check_victory(grid=grid, column_to_play=2, disc_color="Y")
print("Turn 4")
grid = make_move_and_check_victory(grid=grid, column_to_play=2, disc_color="Y")