A Pong clone built in Python with Pygame, featuring an AI opponent, frame-rate independent movement and a score that persists between sessions.
A recreation of the classic arcade game, built to learn Pygame's sprite system and, more importantly, how to handle collisions correctly. The project is split across four modules so that configuration, entities and game loop stay separate.
- AI opponent that tracks the ball's vertical position, deliberately capped at a lower speed than the player so the game stays winnable
- Delta-time movement — every position update is multiplied by the frame time, so the ball and paddles move at the same real-world speed regardless of frame rate
- Previous-frame collision detection to prevent tunnelling (see below)
- Persistent score saved to JSON on exit and reloaded on the next launch
- Serve delay — a 1.2 second pause after each point before the ball starts moving, giving the player time to reposition
Requires Python 3.10+ and pygame-ce.
git clone https://github.com/AugustSud/Python-Game-Pong.git
cd Python-Game-Pong/Python_Game_Pong/Project/Pong
pip install pygame-ce
python code/main.pyRun from the Pong directory, not from inside code/. The game loads and saves its score at the relative path data/score.txt, so the working directory has to be Pong/ or the score will not persist.
| Key | Action |
|---|---|
| ↑ | Move paddle up |
| ↓ | Move paddle down |
Pong/
├── code/
│ ├── main.py # Game class: loop, score display and persistence
│ ├── sprites.py # Paddle, Player, Opponent, Ball
│ ├── settings.py # window size, speeds, positions, colour palette
│ └── groups.py # custom sprite group
└── data/
└── score.txt # persisted score, written as JSON
Player and Opponent both inherit from a shared Paddle base class that holds movement, screen clamping and the update cycle. The only thing the subclasses implement is get_direction() — the player reads the keyboard, the opponent compares its own centre against the ball's. Same movement code, two different control sources.
Collision without tunnelling. A fast ball can move far enough in one frame to end up past a paddle before any overlap is detected. To avoid this, every sprite stores old_rect, a copy of its rectangle from the previous frame. A collision only counts if the ball is overlapping the paddle now and was on the correct side of it last frame:
if self.rect.right >= sprite.rect.left and self.old_rect.right <= sprite.old_rect.left:
self.rect.right = sprite.rect.left
self.direction.x *= -1This also fixes the ball sticking to a paddle, because the position is snapped flush to the paddle edge before the direction flips.
Axis-separated movement. Horizontal position is updated and resolved first, then vertical. Moving both axes at once makes it impossible to tell which side a collision came from.
Randomised serve. After each point the ball resets to the centre with a random horizontal direction and a vertical component between 0.7 and 0.8, so no two rallies open identically.
- The score file is written only on a clean quit — closing the window through the task manager loses the session.
- There is no win condition or match end; the score simply keeps counting.
__pycache__is currently committed to the repository and should be in.gitignore.
The collision handling was the real lesson here. My first version checked only for overlap, which produced two bugs that looked unrelated but had the same cause: the ball occasionally passed straight through a paddle at high speed, and it sometimes stuck to a paddle and vibrated. Keeping the previous frame's rectangle and resolving one axis at a time fixed both. It is also the first time I appreciated why delta time matters — before it, the game ran at completely different speeds on two different machines.
