How to Build a Sudoku Game with Python

Learn how to build your own Sudoku game in Python using Pygame with this comprehensive tutorial. Covering installation, game logic, user interface, and a timer feature, this guide is perfect for enthusiasts looking to create a functional and extendable Sudoku puzzle game.
  · 16 min read · Updated jan 2024 · Game Development

Before we get started, have you tried our new Python Code Assistant? It's like having an expert coder at your fingertips. Check it out!

Sudoku, a classic number puzzle, has captivated the minds of puzzle enthusiasts for years. In this tutorial, we'll walk through the process of creating a Sudoku game using Python. By the end of this guide, you'll have a fully functional Sudoku game that you can play and even extend further.

Table of Contents

Installation and Setup

Let's start by making sure Pygame is installed on your computer; head to your terminal and install pygame module using pip.

$ pip install pygame

After that, create a directory for the game and create the following .py file inside it; settings.py, main.py, sudoku.py, cell.py, table.py, and clock.py.

Let's define our game variables and useful external functions in settings.py:

# setting.py
from itertools import islice

WIDTH, HEIGHT = 450, 450
N_CELLS = 9
CELL_SIZE = (WIDTH // N_CELLS, HEIGHT // N_CELLS)

# Convert 1D list to 2D list
def convert_list(lst, var_lst):
    it = iter(lst)
    return [list(islice(it, i)) for i in var_lst]

Next, let's create the main class of our game. This class will be responsible for calling the game and running the game loop:

# main.py
import pygame, sys
from settings import WIDTH, HEIGHT, CELL_SIZE
from table import Table

pygame.init()

screen = pygame.display.set_mode((WIDTH, HEIGHT + (CELL_SIZE[1] * 3)))
pygame.display.set_caption("Sudoku")

pygame.font.init()

class Main:
    def __init__(self, screen):
        self.screen = screen
        self.FPS = pygame.time.Clock()
        self.lives_font = pygame.font.SysFont("monospace", CELL_SIZE[0] // 2)
        self.message_font = pygame.font.SysFont('Bauhaus 93', (CELL_SIZE[0]))
        self.color = pygame.Color("darkgreen")

    def main(self):
        table = Table(self.screen)
        while True:
            self.screen.fill("gray")
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    pygame.quit()
                    sys.exit()
                if event.type == pygame.MOUSEBUTTONDOWN:
                    if not table.game_over:
                        table.handle_mouse_click(event.pos)
            # lower screen display
            if not table.game_over:
                my_lives = self.lives_font.render(f"Lives Left: {table.lives}", True, pygame.Color("black"))
                self.screen.blit(my_lives, ((WIDTH // table.SRN) - (CELL_SIZE[0] // 2), HEIGHT + (CELL_SIZE[1] * 2.2)))
            else:
                if table.lives <= 0:
                    message = self.message_font.render("GAME OVER!!", True, pygame.Color("red"))
                    self.screen.blit(message, (CELL_SIZE[0] + (CELL_SIZE[0] // 2), HEIGHT + (CELL_SIZE[1] * 2)))
                elif table.lives > 0:
                    message = self.message_font.render("You Made It!!!", True, self.color)
                    self.screen.blit(message, (CELL_SIZE[0] , HEIGHT + (CELL_SIZE[1] * 2)))
            table.update()
            pygame.display.flip()
            self.FPS.tick(30)

if __name__ == "__main__":
    play = Main(screen)
    play.main()

From the name itself, the Main class will be the main class of our game. It takes an argument of screen which will serve as the game window, for animating the game.

The main() function will run and update our game. It will initialize the Table first (serves as our puzzle table). To keep the game running without intentionally exiting, we put a while loop inside it. Inside our loop, we place another loop (the for loop), which will catch all the events going on inside our game window, events such as key click, mouse movement, mouse button click or when the player hits the exit button.

The main() is also responsible for displaying players' "lives left" and game-over messages, whether the player wins or loses. To update the game, we call the table.update() to update the changes in our game table. Then, to render the changes, the pygame.display.flip() does the job done. The self.FPS.tick(30) controls the framerate update speed.

Generating Sudoku Puzzle

The Sudoku() class will be responsible for generating a random Sudoku puzzle for us. In sudoku.py, create a class and name it Sudoku. Let's begin by importing the necessary modules: random, math, and copy:

# sudoku.py
import random
import math
import copy

class Sudoku:
    def __init__(self, N, E):
        self.N = N
        self.E = E
        # compute square root of N
        self.SRN = int(math.sqrt(N))
        self.table = [[0 for x in range(N)] for y in range(N)]
        self.answerable_table = None
        self._generate_table()

    def _generate_table(self):
        # fill the subgroups diagonally table/matrices
        self.fill_diagonal()
        # fill remaining empty subgroups
        self.fill_remaining(0, self.SRN)
        # Remove random Key digits to make game
        self.remove_digits()

The class has an initializer (__init__()) method that takes two parameters N and E, representing the size of the Sudoku grid and the number of cells to be removed to create a puzzle. The class attributes include N (grid size), E (number of cells to remove), SRN (square root of N), table (Sudoku grid), and answerable_table (a copy of the grid with some cells removed). The _generate_table() method is immediately called upon object creation to set up the Sudoku puzzle.

Primary number filling:

    def fill_diagonal(self):
        for x in range(0, self.N, self.SRN):
            self.fill_cell(x, x)
    
    def not_in_subgroup(self, rowstart, colstart, num):
        for x in range(self.SRN):
            for y in range(self.SRN):
                if self.table[rowstart + x][colstart + y] == num:
                    return False
        return True
    
    def fill_cell(self, row, col):
        num = 0
        for x in range(self.SRN):
            for y in range(self.SRN):
                while True:
                    num = self.random_generator(self.N)
                    if self.not_in_subgroup(row, col, num):
                        break
                self.table[row + x][col + y] = num

The fill_diagonal() method fills subgroups diagonally by calling the fill_cell() method for each subgroup. The fill_cell() method generates and places a unique number in each subgroup cell.

    def random_generator(self, num):
        return math.floor(random.random() * num + 1)

    def safe_position(self, row, col, num):
        return (self.not_in_row(row, num) and self.not_in_col(col, num) and self.not_in_subgroup(row - row % self.SRN, col - col % self.SRN, num))
    
    def not_in_row(self, row, num):
        for col in range(self.N):
            if self.table[row][col] == num:
                return False
        return True
    
    def not_in_col(self, col, num):
        for row in range(self.N):
            if self.table[row][col] == num:
                return False
        return True
    
    def fill_remaining(self, row, col):
        # check if we have reached the end of the matrix
        if row == self.N - 1 and col == self.N:
            return True
        # move to the next row if we have reached the end of the current row
        if col == self.N:
            row += 1
            col = 0
        # skip cells that are already filled
        if self.table[row][col] != 0:
            return self.fill_remaining(row, col + 1)
        # try filling the current cell with a valid value
        for num in range(1, self.N + 1):
            if self.safe_position(row, col, num):
                self.table[row][col] = num
                if self.fill_remaining(row, col + 1):
                    return True
                self.table[row][col] = 0
        # no valid value was found, so backtrack
        return False

Several helper methods (random_generator(), safe_position(), not_in_row(), not_in_col(), and not_in_subgroup()) are defined. These methods assist in generating random numbers, checking if a position is safe to place a number, and ensuring that a number is not already present in a row, column, or subgroup.

    def remove_digits(self):
        count = self.E
        # replicates the table so we can have a filled and pre-filled copy
        self.answerable_table = copy.deepcopy(self.table)
        # removing random numbers to create the puzzle sheet
        while (count != 0):
            row = self.random_generator(self.N) - 1
            col = self.random_generator(self.N) - 1
            if (self.answerable_table[row][col] != 0):
                count -= 1
                self.answerable_table[row][col] = 0

The remove_digits() method removes a specified number of random digits from the filled grid to create the puzzle. It also creates a copy of the grid (answerable_table) before removing digits.

    def puzzle_table(self):
        return self.answerable_table

    def puzzle_answers(self):
        return self.table

    def print_sudoku(self):
        for row in range(self.N):
            for col in range(self.N):
                print(self.table[row][col], end=" ")
            print()
        print("")
        for row in range(self.N):
            for col in range(self.N):
                print(self.answerable_table[row][col], end=" ")
            print()

if __name__ == "__main__":
    N = 9
    E = (N * N) // 2
    sudoku = Sudoku(N, E)
    sudoku.print_sudoku()

The last 3 methods are responsible for returning and printing the puzzle and/or answers. The puzzle_table() returns the answerable table (puzzle with some cells removed). The puzzle_answers() returns the complete Sudoku table. The print_sudoku() prints both the complete Sudoku grid and the answerable grid.

Creating the Game Table

Before making the game grid, let's create our table cells. In cell.py, make the function Cell():

# cell.py
import pygame
from settings import convert_list

pygame.font.init()

class Cell:
    def __init__(self, row, col, cell_size, value, is_correct_guess = None):
        self.row = row
        self.col = col
        self.cell_size = cell_size
        self.width = self.cell_size[0]
        self.height = self.cell_size[1]
        self.abs_x = row * self.width
        self.abs_y = col * self.height
        self.value = value
        self.is_correct_guess = is_correct_guess
        self.guesses = None if self.value != 0 else [0 for x in range(9)]
        self.color = pygame.Color("white")
        self.font = pygame.font.SysFont('monospace', self.cell_size[0])
        self.g_font = pygame.font.SysFont('monospace', (cell_size[0] // 3))
        self.rect = pygame.Rect(self.abs_x,self.abs_y,self.width,self.height)

    def update(self, screen, SRN = None):
        pygame.draw.rect(screen, self.color, self.rect)
        if self.value != 0:
            font_color = pygame.Color("black") if self.is_correct_guess else pygame.Color("red")
            num_val = self.font.render(str(self.value), True, font_color)
            screen.blit(num_val, (self.abs_x, self.abs_y))
        elif self.value == 0 and self.guesses != None:
            cv_list = convert_list(self.guesses, [SRN, SRN, SRN])
            for y in range(SRN):
                for x in range(SRN):
                    num_txt = " "
                    if cv_list[y][x] != 0:
                        num_txt = cv_list[y][x]
                    num_txt = self.g_font.render(str(num_txt), True, pygame.Color("orange"))
                    abs_x = (self.abs_x + ((self.width // SRN) * x))
                    abs_y = (self.abs_y + ((self.height // SRN) * y))
                    abs_pos = (abs_x, abs_y)
                    screen.blit(num_txt, abs_pos)

The Cell() class has attributes such as row and col (cell position in the table), cell_size, width and height, abs_x and abs_y (absolute x and y coordinates of the cell on the screen), value (numerical value, 0 for an empty cell), is_correct_guess (indicating whether the current value is a correct guess), and guesses (list representing possible guesses for an empty cell or None if the cell is filled)

The update() method is responsible for updating the graphical representation of the cell on the screen. It draws a rectangle with the specified color using pygame.draw.rect. Depending on whether the cell is filled (value != 0) or empty (value == 0), it either draws the value in a filled cell or the possible guesses in an empty cell.

If the cell is empty and has possible guesses, it converts the guess list into a 2D list using the convert_list() function. It then iterates through the converted list and draws each guess in the corresponding position within the cell. It renders each guess as text using the small font (g_font). It calculates the absolute position within the cell for each guess based on the position within the 2D list. Then, blits (draws) the text onto the screen at the calculated position.

Now, let's move on to creating the game table. Create a class and name it Table in table.py. It uses the Pygame library to create the Sudoku grid, handle user inputs, and display the puzzle, number choices, buttons, and timer.

import pygame
import math
from cell import Cell
from sudoku import Sudoku
from clock import Clock

from settings import WIDTH, HEIGHT, N_CELLS, CELL_SIZE

pygame.font.init()

class Table:
    def __init__(self, screen):
        self.screen = screen
        self.puzzle = Sudoku(N_CELLS, (N_CELLS * N_CELLS) // 2)
        self.clock = Clock()
        self.answers = self.puzzle.puzzle_answers()
        self.answerable_table = self.puzzle.puzzle_table()
        self.SRN = self.puzzle.SRN
        self.table_cells = []
        self.num_choices = []
        self.clicked_cell = None
        self.clicked_num_below = None
        self.cell_to_empty = None
        self.making_move = False
        self.guess_mode = True
        self.lives = 3
        self.game_over = False
        self.delete_button = pygame.Rect(0, (HEIGHT + CELL_SIZE[1]), (CELL_SIZE[0] * 3), (CELL_SIZE[1]))
        self.guess_button = pygame.Rect((CELL_SIZE[0] * 6), (HEIGHT + CELL_SIZE[1]), (CELL_SIZE[0] * 3), (CELL_SIZE[1]))
        self.font = pygame.font.SysFont('Bauhaus 93', (CELL_SIZE[0] // 2))
        self.font_color = pygame.Color("white")
        self._generate_game()
        self.clock.start_timer()

    def _generate_game(self):
        # generating sudoku table
        for y in range(N_CELLS):
            for x in range(N_CELLS):
                cell_value = self.answerable_table[y][x]
                is_correct_guess = True if cell_value != 0 else False
                self.table_cells.append(Cell(x, y, CELL_SIZE, cell_value, is_correct_guess))
        # generating number choices
        for x in range(N_CELLS):
            self.num_choices.append(Cell(x, N_CELLS, CELL_SIZE, x + 1))

The Table class' __init__() method (constructor) initializes various attributes such as the Pygame screen, the Sudoku puzzle, the clock, answers, the answerable table, and other game-related variables.

    def _draw_grid(self):
        grid_color = (50, 80, 80)
        pygame.draw.rect(self.screen, grid_color, (-3, -3, WIDTH + 6, HEIGHT + 6), 6)
        i = 1
        while (i * CELL_SIZE[0]) < WIDTH:
            line_size = 2 if i % 3 > 0 else 4
            pygame.draw.line(self.screen, grid_color, ((i * CELL_SIZE[0]) - (line_size // 2), 0), ((i * CELL_SIZE[0]) - (line_size // 2), HEIGHT), line_size)
            pygame.draw.line(self.screen, grid_color, (0, (i * CELL_SIZE[0]) - (line_size // 2)), (HEIGHT, (i * CELL_SIZE[0]) - (line_size // 2)), line_size)
            i += 1

    def _draw_buttons(self):
        # adding delete button details
        dl_button_color = pygame.Color("red")
        pygame.draw.rect(self.screen, dl_button_color, self.delete_button)
        del_msg = self.font.render("Delete", True, self.font_color)
        self.screen.blit(del_msg, (self.delete_button.x + (CELL_SIZE[0] // 2), self.delete_button.y + (CELL_SIZE[1] // 4)))
        # adding guess button details
        gss_button_color = pygame.Color("blue") if self.guess_mode else pygame.Color("purple")
        pygame.draw.rect(self.screen, gss_button_color, self.guess_button)
        gss_msg = self.font.render("Guess: On" if self.guess_mode else "Guess: Off", True, self.font_color)
        self.screen.blit(gss_msg, (self.guess_button.x + (CELL_SIZE[0] // 3), self.guess_button.y + (CELL_SIZE[1] // 4)))

The _draw_grid() method is responsible for drawing the Sudoku grid; it uses Pygame functions to draw the grid lines based on the size of the cells. The _draw_buttons() method is responsible for drawing the delete and guess buttons; it uses Pygame functions to draw rectangular buttons with appropriate colors and messages.

    def _get_cell_from_pos(self, pos):
        for cell in self.table_cells:
            if (cell.row, cell.col) == (pos[0], pos[1]):
                return cell

The _get_cell_from_pos() method returns the Cell object at a given position (row, col) in the Sudoku table.

    # checking rows, cols, and subgroups for adding guesses on each cell
    def _not_in_row(self, row, num):
        for cell in self.table_cells:
            if cell.row == row:
                if cell.value == num:
                    return False
        return True
    
    def _not_in_col(self, col, num):
        for cell in self.table_cells:
            if cell.col == col:
                if cell.value == num:
                    return False
        return True

    def _not_in_subgroup(self, rowstart, colstart, num):
        for x in range(self.SRN):
            for y in range(self.SRN):
                current_cell = self._get_cell_from_pos((rowstart + x, colstart + y))
                if current_cell.value == num:
                    return False
        return True

    # remove numbers in guess if number already guessed in the same row, col, subgroup correctly
    def _remove_guessed_num(self, row, col, rowstart, colstart, num):
        for cell in self.table_cells:
            if cell.row == row and cell.guesses != None:
                for x_idx,guess_row_val in enumerate(cell.guesses):
                    if guess_row_val == num:
                        cell.guesses[x_idx] = 0
            if cell.col == col and cell.guesses != None:
                for y_idx,guess_col_val in enumerate(cell.guesses):
                    if guess_col_val == num:
                        cell.guesses[y_idx] = 0
        for x in range(self.SRN):
            for y in range(self.SRN):
                current_cell = self._get_cell_from_pos((rowstart + x, colstart + y))
                if current_cell.guesses != None:
                    for idx,guess_val in enumerate(current_cell.guesses):
                        if guess_val == num:
                            current_cell.guesses[idx] = 0

The methods _not_in_row(), _not_in_col(), _not_in_subgroup(), and _remove_guessed_num() are responsible for checking whether a number is valid in a row, column, or subgroup and removing guessed numbers when correctly placed.

    def handle_mouse_click(self, pos):
        x, y = pos[0], pos[1]
        # getting table cell clicked
        if x <= WIDTH and y <= HEIGHT:
            x = x // CELL_SIZE[0]
            y = y // CELL_SIZE[1]
            clicked_cell = self._get_cell_from_pos((x, y))
            # if clicked empty cell
            if clicked_cell.value == 0:
                self.clicked_cell = clicked_cell
                self.making_move = True
            # clicked unempty cell but with wrong number guess
            elif clicked_cell.value != 0 and clicked_cell.value != self.answers[y][x]:
                self.cell_to_empty = clicked_cell
        # getting number selected
        elif x <= WIDTH and y >= HEIGHT and y <= (HEIGHT + CELL_SIZE[1]):
            x = x // CELL_SIZE[0]
            self.clicked_num_below = self.num_choices[x].value
        # deleting numbers
        elif x <= (CELL_SIZE[0] * 3) and y >= (HEIGHT + CELL_SIZE[1]) and y <= (HEIGHT + CELL_SIZE[1] * 2):
            if self.cell_to_empty:
                self.cell_to_empty.value = 0
                self.cell_to_empty = None
        # selecting modes
        elif x >= (CELL_SIZE[0] * 6) and y >= (HEIGHT + CELL_SIZE[1]) and y <= (HEIGHT + CELL_SIZE[1] * 2):
            self.guess_mode = True if not self.guess_mode else False
        # if making a move
        if self.clicked_num_below and self.clicked_cell != None and self.clicked_cell.value == 0:
            current_row = self.clicked_cell.row
            current_col = self.clicked_cell.col
            rowstart = self.clicked_cell.row - self.clicked_cell.row % self.SRN
            colstart = self.clicked_cell.col - self.clicked_cell.col % self.SRN
            if self.guess_mode:
                # checking the vertical group, the horizontal group, and the subgroup
                if self._not_in_row(current_row, self.clicked_num_below) and self._not_in_col(current_col, self.clicked_num_below):
                    if self._not_in_subgroup(rowstart, colstart, self.clicked_num_below):
                        if self.clicked_cell.guesses != None:
                            self.clicked_cell.guesses[self.clicked_num_below - 1] = self.clicked_num_below
            else:
                self.clicked_cell.value = self.clicked_num_below
                # if the player guess correctly
                if self.clicked_num_below == self.answers[self.clicked_cell.col][self.clicked_cell.row]:
                    self.clicked_cell.is_correct_guess = True
                    self.clicked_cell.guesses = None
                    self._remove_guessed_num(current_row, current_col, rowstart, colstart, self.clicked_num_below)
                # if guess is wrong
                else:
                    self.clicked_cell.is_correct_guess = False
                    self.clicked_cell.guesses = [0 for x in range(9)]
                    self.lives -= 1
            self.clicked_num_below = None
            self.making_move = False
        else:
            self.clicked_num_below = None

The handle_mouse_click() method processes mouse clicks based on the position on the screen. It updates game variables like clicked_cell, clicked_num_below, and cell_to_empty accordingly.

    def _puzzle_solved(self):
        check = None
        for cell in self.table_cells:
            if cell.value == self.answers[cell.col][cell.row]:
                check = True
            else:
                check = False
                break
        return check

The _puzzle_solved() method checks if the Sudoku puzzle is solved by comparing the values in each cell with the correct answers.

    def update(self):
        [cell.update(self.screen, self.SRN) for cell in self.table_cells]
        [num.update(self.screen) for num in self.num_choices]
        self._draw_grid()
        self._draw_buttons()
        if self._puzzle_solved() or self.lives == 0:
            self.clock.stop_timer()
            self.game_over = True
        else:
            self.clock.update_timer()
        self.screen.blit(self.clock.display_timer(), (WIDTH // self.SRN,HEIGHT + CELL_SIZE[1]))

The update method is responsible for updating the display. It updates the graphical representation of cells and numbers, draws the grid and buttons, checks if the puzzle is solved or the game is over, and updates the timer.

Adding a Game Timer

And for the last part of our code, we're making a class for timer. Create Clock class in clock.py:

import pygame, time
from settings import CELL_SIZE

pygame.font.init()

class Clock:
    def __init__(self):
        self.start_time = None
        self.elapsed_time = 0
        self.font = pygame.font.SysFont("monospace", CELL_SIZE[0])
        self.message_color = pygame.Color("black")

    # Start the timer
    def start_timer(self):
        self.start_time = time.time()

    # Update the timer
    def update_timer(self):
        if self.start_time is not None:
            self.elapsed_time = time.time() - self.start_time

    # Display the timer
    def display_timer(self):
        secs = int(self.elapsed_time % 60)
        mins = int(self.elapsed_time / 60)
        my_time = self.font.render(f"{mins:02}:{secs:02}", True, self.message_color)
        return my_time

    # Stop the timer
    def stop_timer(self):
        self.start_time = None

The start_timer() method sets the start_time attribute to the current time using time.time() when called. This marks the beginning of the timer.

The update_timer() method calculates the elapsed time since the timer started. If the start_time is not None, it updates the elapsed_time by subtracting the current time from the start_time.

The display_timer() method converts the elapsed time into minutes and seconds. It then creates a text representation of the time in the format "MM:SS" using the Pygame font. The rendered text is returned.

The stop_timer() method resets the start_time to None, effectively stopping the timer.

And now, we are done coding!! To try our game, simply run python main.py or python3 main.py on your terminal once you're inside our project directory. Here are some game snapshots:

Or a video of me playing the game:

Conclusion

In conclusion, the tutorial outlines the development of a Sudoku game in Python using the Pygame library. The implementation covers key aspects, including Sudoku puzzle generation, graphical representation, user interaction, and a timer feature. By breaking down the code into modular classes, such as Sudoku, Cell, Table, and Clock, the tutorial emphasizes a structured and organized approach to game development. This tutorial is a valuable resource for those seeking to create their own Sudoku game or enhance their understanding of Python game development with Pygame.

Here are some game dev tutorials:

Happy Coding!

Take the stress out of learning Python. Meet our Python Code Assistant – your new coding buddy. Give it a whirl!

View Full Code Explain The Code for Me
Sharing is caring!



Read Also



Comment panel

    Got a coding query or need some guidance before you comment? Check out this Python Code Assistant for expert advice and handy tips. It's like having a coding tutor right in your fingertips!