Python Coding Projects

Project Highlight #1: Real-Time Snake Game with Tkinter GUI

This project is a real-time implementation of the classic Snake game developed in Python as part of the CPEN 333 course. Python’s built-in threading, queue, and Tkinter libraries were used, with the GUI serving as a visualization layer, and the back-end systems managing game flow, movement, and state tracking.

Overview

The snake moves in a grid, responding to real-time keyboard input controlled by the player using the arrow keys. Each movement step is handled in a background thread and passed to the GUI via a thread-safe queue. Prey spawns in valid locations away from walls, the scoreboard, and the snake itself. When the prey is captured, the snake grows and the score increases. The game ends when the snake hits the wall or itself.

Core Components Include: a continuous movement loop (superloop), directional logic and boundary-aware movement, prey spawning with spatial constraints, collision detection for walls and self intersection, and task queue to sync logic and rendering

Gameplay Example

This screenshot shows the game running in real-time. The pink snake is actively moving, the prey is visible in the play area, and the live score is displayed at the top.

GUI showing a gameplay example

Snake Movement and Growth Logic

Snake movement is handled by shifting a list of (x, y) coordinate tuples. At each step, a new head coordinate is added based on direction, and the tail is removed unless prey was just captured.

def move(self):
    new_head = self.calculateNewCoordinates()
    self.snakeCoordinates.append(new_head)

    if prey_captured(new_head):
        self.score += 1
        self.queue.put_nowait({"score": self.score})
        self.createNewPrey()
    else:
        self.snakeCoordinates.pop(0)

    self.queue.put_nowait({"move": self.snakeCoordinates})
    self.isGameOver(new_head)

Direction handling uses pixel-based offsets:

calculateNewCoordinates(self):
    x, y = self.snakeCoordinates[-1]
    if self.direction == "Up": return (x, y - 10)
    if self.direction == "Down": return (x, y + 10)
    if self.direction == "Left": return (x - 10, y)
    if self.direction == "Right": return (x + 10, y)

Prey Placement Constraints

Prey can only spawn in the allowed locations: away from walls, not under the scoreboard, and not on the snake’s body. This logic includes spatial filtering and grid alignment.

while True:
    x = random.randrange(THRESHOLD, WINDOW_WIDTH - THRESHOLD, 5)
    y = random.randrange(SCOREBOARD_HEIGHT + THRESHOLD, WINDOW_HEIGHT - THRESHOLD, 5)

    if x % 10 != 0 and y % 10 != 0 and (x, y) not in self.snakeCoordinates:
        break

self.queue.put_nowait({"prey": (x - 5, y - 5, x + 5, y + 5)})
Visualizing prey spawn boundaries

The diagram above depicts the prey placement. Green boxes show valid placements, red boxes are invalid such as on the wall, under the scoreboard, or on the snake’s body.

Collision and Game Over

Collision logic checks for wall boundaries and self-biting:

def isGameOver(self, coord):
    x, y = coord
    if x > WINDOW_WIDTH or x < 0 or y > WINDOW_HEIGHT or y < 0 or self.snakeCoordinates.count((x, y)) > 1:
        self.gameNotOver = False
        self.queue.put_nowait({"game_over": True})

If a collision is detected, the game is ended and the GUI displays the “Game Over” notice which closes the window:

A visible “Game Over” button appears when the snake collides with a wall or itself and the game is terminated

A Development Challenge and Solution

In an initial implementation, sometimes the prey wouldn’t register as eaten. This was resolved by adjusting the capture logic so the prey would be ‘eaten’ if the center of the it falls within the snake’s head width.

Capturing prey is determined by checking if the center of the prey lies within the snake head’s width, instead of relying on exact coordinate matches which in the initial implementation had proved unreliable due to slight misalignments. The final logic checks if the prey’s bounding box overlaps the head of the snake, as seen in the diagram below:

An illustration of correct vs incorrect prey capture, showing how the prey center must intersect the head’s bounds.

This prevents premature or missed captures and improves reliability. The prey is only replaced, and the snake only grows if this exact overlap occurs. Otherwise, the snake’s tail segment is removed to maintain its length. A snippet of code can be seen below:

prey_coords = gui.canvas.coords(gui.preyIcon)
prey_left, prey_top, prey_right, prey_bottom = map(int, prey_coords)

head_x, head_y = NewSnakeCoordinates

if prey_left <= head_x < prey_right and prey_top <= head_y < prey_bottom:
    # Prey is captured
    self.score += 1
    self.queue.put_nowait({"score": self.score})
    self.createNewPrey()

Event-Driven Architecture

The game logic runs on a background thread:

superloop(self):
    while self.gameNotOver:
        self.move()
        time.sleep(0.15)

Tasks are passed to a GUI-safe QueueHandler:

if "move" in task:
    gui.canvas.coords(gui.snakeIcon, *points)
elif "prey" in task:
    gui.canvas.coords(gui.preyIcon, *task["prey"])

This queue-based system ensures the UI thread never blocks on logic operations.

Based on the design of the game, some areas for possible further development include: adding a leaderboard using a dictionary-based high score tracker, implementing a “Play Again” button to reset the game state, randomizing prey and snake colors for visual feedback, making the snake speed increase as the score grows, and decreasing prey size over time to raise difficulty.

Project Highlight #2: 2048 Command Line Game

This project is a text-based recreation of the popular 2048 puzzle game, implemented entirely in Python and played through the command line. The focus was on board manipulation logic, merging rules, and user input handling.

Overview

The player interacts using WASD keys to move numbered tiles across a 4×4 grid. Matching tiles combine and double their value, contributing to the score. After every move, a new “2” spawns in a random empty spot. The game ends when there are no empty spaces left.

A snapshot of the board mid-game, showing different tile values and layout.

Board Initialization

The board is stored as a 4×4 list of lists. Two starting “2” tiles are placed in random empty positions using random.sample()

def init() -> None:
    for _ in range(4):
        rowList = [''] * 4
        board.append(rowList)

    twoRandomNumbers = random.sample(range(16), 2)
    twoRandomCells = ((twoRandomNumbers[0]//4, twoRandomNumbers[0]%4),
                      (twoRandomNumbers[1]//4, twoRandomNumbers[1]%4))
    for cell in twoRandomCells:
        board[cell[0]][cell[1]] = 2
Example of the board after initialization with two random 2s.

Movement and Merge Logic

Each input direction (‘W’, ‘A’, ‘S’, ‘D’) triggers a sliding and merging operation either on rows or columns. Tiles slide toward the intended direction, and matching adjacent tiles merge.

def process_row_or_column(cells: list[int]) -> list[int]:
    new_row = [cell for cell in cells if cell != '']
    for i in range(len(new_row) - 1):
        if new_row[i] == new_row[i + 1]:
            new_row[i] *= 2
            new_row[i + 1] = ''
    new_row = [cell for cell in new_row if cell != '']
    return new_row + [''] * (4 - len(new_row))

The function is reused for all movement directions by reversing the list for right/down and restoring the order after processing.

Side-by-side example of a board state before and after a merge:

Board before the “D” (right slide) key is pressed
Board after the “D” (right slide) key is pressed

Score Tracking

The current score is calculated by summing all non-empty cells on the board:

def getCurrentScore() -> int:
    total_score = 0
    for row in board:
        for cell in row:
            if cell != '':
                total_score += cell
    return total_score

The score is printed each turn alongside the updated board.

Terminal output showing board + live score counter.

New Tile Spawning

After each valid move, a new “2” is placed in a randomly selected empty tile:

def addANewTwoToBoard() -> None:
    empty_cells = [(r, c) for r in range(4) for c in range(4) if board[r][c] == '']
    if empty_cells:
        r, c = random.choice(empty_cells)
        board[r][c] = 2

This randomness adds challenge and variability, forcing the player to strategize around board space.

Game Over Detection

The game ends when there are no empty cells left:

def isFull() -> bool:
    for row in range(4):
        for col in range(4):
            if board[row][col] == '':
                return False
    return True
Shows a full board with no moves left and the game over message.

This project provided a hands-on way to explore logic-based game mechanics using Python. It strengthened my understanding of list manipulation, merging algorithms, and input handling in a terminal environment.

Leave a comment