July 2025

Re-Direct

Python Pygame Pygbag Git

Overview

Re-Direct is a top-down 2D action game built in 48 hours for the 2025 Kenney Jam, a game development hackathon. The game was co-developed with Henry Seefeldt, a fellow student at Texas A&M.

The core mechanic revolves around turning an enemy's own firepower against them: when an enemy bullet enters the player's grab radius, it becomes grabbable. Holding the grab button lets the player draw a circle with the mouse: the tighter and more complete the circle, the faster and larger the redirected shot becomes when released back at the nearest enemy.

The game was built with Pygame and structured to compile to WebAssembly via Pygbag, allowing it to run directly in the browser rather than requiring a local Python install.

Play Re-Direct in your browser
Re-Direct gameplay screenshot
The player redirecting an enemy bullet mid-combat.

Gameplay & Core Mechanic

The central skill-based mechanic, redirecting bullets by drawing a circle, required a way to evaluate how "circular" a freeform mouse path actually is, in real time, without relying on any external libraries.

The circularity check works by first finding the average center point of the drawn path, then measuring how much each point's distance from that center deviates from the average radius. A path with low variance, points sitting roughly equidistant from the center, reads as more circular; a jagged or elongated path reads as less circular. The path must also loop back near its starting point to be considered "closed."

utilities.py
def is_circle(path, tolerance=0.4):
    if len(path) < 30:
        return False  # Not enough points to form a circle

    xs, ys = zip(*path)
    cx = sum(xs) / len(xs)
    cy = sum(ys) / len(ys)

    distances = [math.hypot(x - cx, y - cy) for x, y in path]
    avg_radius = sum(distances) / len(distances)

    radius_variance = sum((d - avg_radius) ** 2 for d in distances) / len(distances)
    circularity = radius_variance / (avg_radius ** 2 + 1e-5)

    start = pygame.Vector2(path[0])
    end = pygame.Vector2(path[-1])
    loop_closed = start.distance_to(end) < avg_radius * 0.5

    return circularity < tolerance and loop_closed


def calculate_circularity(path):
    if len(path) < 10:
        return float('inf')  # not enough data, worst possible score

    xs, ys = zip(*path)
    cx = sum(xs) / len(xs)
    cy = sum(ys) / len(ys)

    distances = [math.hypot(x - cx, y - cy) for x, y in path]
    avg_radius = sum(distances) / len(distances)

    radius_variance = sum((d - avg_radius) ** 2 for d in distances) / len(distances)
    return radius_variance / (avg_radius ** 2 + 1e-5)


def circularity_to_accuracy(circularity, tolerance=0.4):
    capped = min(circularity, tolerance * 2)
    raw_score = 1.0 - (capped / (tolerance * 2))
    accuracy = int(raw_score * 100)
    return max(0, min(accuracy, 100)) / 100

Rather than treating the redirect as a binary success or failure, calculate_circularity() returns a continuous score, which circularity_to_accuracy() converts into a 0–1 accuracy value. That accuracy is then used to scale the launch speed and hitbox size of the redirected bullet: a clean circle produces a faster, larger, more dangerous shot, while a sloppy one produces a weak one.

player.py — redirect()
def redirect(self):
    if not self.circle_completed:
        return

    nearest = self.get_nearest_enemy()
    if nearest:
        direction = nearest.pos - self.pos
    else:
        direction = pygame.Vector2(1, 0)

    circle_score = calculate_circularity(self.mouse_path)
    accuracy = circularity_to_accuracy(circle_score)

    min_speed, max_speed = 2, 8
    bullet_speed = min_speed + accuracy * (max_speed - min_speed)

    min_size, max_size = 1, 6
    bullet_size = int(min_size + accuracy * (max_size - min_size))

    bullet = Bullet(self.pos, direction, bullet_size, bullet_speed,
                     grabbable=False, owner='player')

    self.player_bullets_group.add(bullet)
    self.all_sprites_group.add(bullet)
    soundeffects.player_shoot_sound.play()

The game loop itself was also restructured around Python's asyncio, which Pygbag requires in order to yield control back to the browser between frames, a necessary adaptation for running a Pygame project as WebAssembly rather than as a native desktop application.

redirect_game.py — async structure
import asyncio

async def main():
    screen = pygame.display.set_mode((800, 800))
    clock = pygame.time.Clock()
    running = True

    while running:
        dt = clock.tick(60)
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
        # ... input handling, updates, and drawing ...
        pygame.display.flip()

    if not running:
        pygame.quit()
        exit()

    await asyncio.sleep(0)

if __name__ == "__main__":
    asyncio.run(main())

Assets & Sound Design

Alongside the code, I created the game's sprite work — character animations, enemy designs, bullets, and UI elements — as well as the background music and sound effects.

Player idle/vertical movement frame 1 Player idle/vertical movement frame 2 Player horizontal movement frame 1 Player horizontal movement frame 2
Melee enemy Ranged enemy moving frame 1 Ranged enemy moving frame 2 Ranged enemy shooting
Large bullet sprite Small bullet sprite Filled grab radius indicator Grab radius outline indicator
Full heart HUD icon Empty heart HUD icon

ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
0123456789

Consolidated sprite sheet: player, enemy, bullet, and UI assets, along with the pixel font (Eight-Bit Dragon) used throughout the game.
Original background music composed for Re-Direct.

Results & Reflections

If you've ever been in any sort of creative online space, you may have heard the mantra: "just make it exist first, you can make it good later." This project was a lesson in trusting that process. When designing under a time crunch, there's no room to focus on aesthetics or visuals; all of your effort has to go into core logic and functionality. That's especially true for a game, since what you see on the screen is the game.

The project's scope also changed constantly, probably more than ten times over the course of the 48 hours. At one point we wanted to include more than six shapes and a much more convoluted redirect mechanic, but the time limit forced us to pick and choose what we could realistically implement.

← All Projects Next: 5-DOF Robotic Manipulator →