I still remember the first time I shipped a tiny game to a school computer lab. The logic was simple, but the feedback loop was instant: move, collide, reset, repeat. That tight loop is why the classic snake game remains such a good teaching tool. You get animation, input handling, state management, and collision logic without needing a big framework. In this guide, I’m going to build a complete snake game in Python using Turtle, and I’ll do it in a way that mirrors modern development habits: clear structure, readable code, and predictable state updates. You’ll see how to wire keyboard input to movement, keep the game loop stable, grow the snake, and track score without glitches. I’ll also show you the mistakes I still see engineers make when they wire Turtle games together, and how to avoid them. If you want a compact project that reinforces real-world patterns like event handling, game state, and performance control, this is it.
Why Turtle Still Matters in 2026
Turtle is one of those libraries that feels almost too simple, but that simplicity is the point. It creates a 2D canvas, hands you a screen object, and lets you move sprites around with a few lines of code. It’s a great choice when you want to focus on logic rather than framework setup, and it’s ideal for learning the “shape” of a game loop.
In my experience, Turtle also exposes the constraints that matter in game dev:
- You have a frame update loop that must stay stable.
- Input arrives asynchronously and must not conflict with motion.
- Collision checks must be small and predictable.
Those constraints show up in big engines too. Turtle just lets you feel them without distraction. I also like that Turtle is batteries‑included with the CPython installer on Windows and macOS, so newcomers avoid package‑install friction. On a classroom machine with locked-down privileges, it still runs.
Project Setup and Core State
I start by framing the game as a few pieces of state:
- A head that moves on a grid
- A list of body segments that follow the head
- A piece of food that jumps to random coordinates
- A score and high score
- A delay that controls speed
Here’s a full setup that you can run as-is. I’m going to use a 600×600 window, a light blue background, and a 20-pixel grid step so everything aligns. I also prefer a neutral, high-contrast palette to keep the snake readable.
import turtle as t
import time
import random
# Game state
delay = 0.1
score = 0
high_score = 0
# Screen
sc = t.Screen()
sc.title(‘Snake Game‘)
sc.bgcolor(‘#add8e6‘)
sc.setup(width=600, height=600)
sc.tracer(0)
# Snake head
head = t.Turtle()
head.shape(‘square‘)
head.color(‘white‘)
head.penup()
head.goto(0, 0)
head.direction = ‘Stop‘
# Food
food = t.Turtle()
food.speed(0)
food.shape(random.choice([‘square‘, ‘triangle‘, ‘circle‘]))
food.color(random.choice([‘red‘, ‘green‘, ‘black‘]))
food.penup()
food.goto(0, 100)
# Scoreboard
pen = t.Turtle()
pen.speed(0)
pen.shape(‘square‘)
pen.color(‘white‘)
pen.penup()
pen.hideturtle()
pen.goto(0, 260)
pen.write(‘Score : 0 High Score : 0‘, align=‘center‘, font=(‘candara‘, 24, ‘bold‘))
A few notes about these choices:
- The screen tracer is set to 0 so I control updates manually. Without that, you’ll see flicker or inconsistent frame timing.
- The head direction starts as ‘Stop.’ That makes input explicit and prevents the game from moving before the player interacts.
- I draw the score once and update it by clearing the pen and writing again.
Environment Checklist
If you’re on macOS or Linux with Python already installed, Turtle comes bundled. On Windows, install Python 3.11+ from python.org and ensure “tcl/tk and IDLE” is checked; that package contains Turtle. Run python -m turtle in a terminal; a blank window proves the library is working. If you prefer VS Code, use the Python extension and run the script with the “Run Python File” command. No external dependencies mean zero pip friction.
Folder Layout I Use
For a single script, one file is fine. If I’m teaching a group, I sometimes split into small files to model organization:
- snake.py (main loop and wiring)
- settings.py (colors, speeds, grid size)
- sprites.py (factory functions for head/food/segments)
- scoreboard.py (encapsulates the pen updates)
Even if you stay single-file, think in modules; it pays off when you add features.
Movement: Direction, Input, and Safe Turns
Snake movement looks trivial until you implement turns and find that the snake can reverse into itself. I prevent that by blocking opposite-direction changes. This is the most common input bug I see in beginner implementations.
# Direction functions
def go_up():
if head.direction != ‘down‘:
head.direction = ‘up‘
def go_down():
if head.direction != ‘up‘:
head.direction = ‘down‘
def go_left():
if head.direction != ‘right‘:
head.direction = ‘left‘
def go_right():
if head.direction != ‘left‘:
head.direction = ‘right‘
# Movement function
def move():
if head.direction == ‘up‘:
head.sety(head.ycor() + 20)
elif head.direction == ‘down‘:
head.sety(head.ycor() – 20)
elif head.direction == ‘left‘:
head.setx(head.xcor() – 20)
elif head.direction == ‘right‘:
head.setx(head.xcor() + 20)
# Key bindings
sc.listen()
sc.onkeypress(go_up, ‘Up‘)
sc.onkeypress(go_down, ‘Down‘)
sc.onkeypress(go_left, ‘Left‘)
sc.onkeypress(go_right, ‘Right‘)
# Snake body segments
segments = []
I’m using 20-pixel steps because it aligns well with the 600×600 window. That gives you a 30×30 grid that feels responsive but not too fast. If you want a tighter feel, reduce the step to 10 and cut the delay down a little.
A simple analogy that helps: the snake is a train on a grid. You can turn left or right, but you can’t reverse direction instantly because the tracks behind you aren’t supposed to move. In classrooms, I make students physically stand in a line and move like the snake; it clarifies why the opposite-turn rule exists.
Handling Input Latency
Turtle’s keyboard events are queued. If you mash keys quickly, you can over-buffer turns and produce jitter. Two mitigations:
- Keep your delay low (0.05–0.1) so queued events flush quickly.
- Ignore new input while the head is moving between grid cells. You can implement a “turn lock” flag that resets after one move. For beginners, the opposite-turn guard is usually enough.
The Game Loop and Collision Rules
Turtle doesn’t give you a built-in game loop, so you build one with a while True loop and manual screen updates. That loop is where everything happens: input takes effect, movement is applied, collisions are checked, and the score is updated.
while True:
sc.update()
# Check boundary collision
if head.xcor() > 290 or head.xcor() 290 or head.ycor() < -290:
time.sleep(1)
head.goto(0, 0)
head.direction = ‘Stop‘
# Move segments off-screen
for seg in segments:
seg.goto(1000, 1000)
segments.clear()
# Reset score and speed
score = 0
delay = 0.1
pen.clear()
pen.write(f‘Score : {score} High Score : {high_score}‘, align=‘center‘, font=(‘candara‘, 24, ‘bold‘))
# Check food collision
if head.distance(food) < 20:
# Move food to random spot
food.goto(random.randint(-270, 270), random.randint(-270, 270))
# Add a new segment
new_seg = t.Turtle()
new_seg.speed(0)
new_seg.shape(‘square‘)
new_seg.color(‘orange‘)
new_seg.penup()
segments.append(new_seg)
# Speed up and update score
delay = max(0.03, delay – 0.001)
score += 10
if score > high_score:
high_score = score
pen.clear()
pen.write(f‘Score : {score} High Score : {high_score}‘, align=‘center‘, font=(‘candara‘, 24, ‘bold‘))
# Move the end segments first in reverse order
for i in range(len(segments) – 1, 0, -1):
x = segments[i – 1].xcor()
y = segments[i – 1].ycor()
segments[i].goto(x, y)
# Move segment 0 to where the head is
if len(segments) > 0:
segments[0].goto(head.xcor(), head.ycor())
move()
# Check for head collision with the body
for seg in segments:
if seg.distance(head) < 20:
time.sleep(1)
head.goto(0, 0)
head.direction = ‘Stop‘
for seg in segments:
seg.goto(1000, 1000)
segments.clear()
score = 0
delay = 0.1
pen.clear()
pen.write(f‘Score : {score} High Score : {high_score}‘, align=‘center‘, font=(‘candara‘, 24, ‘bold‘))
time.sleep(delay)
That’s the entire game. It’s small but complete. The loop does a few critical things in the right order:
1) Update screen so you see changes.
2) Check boundary collision and reset if needed.
3) Check food collision, add a segment, and update score.
4) Move body segments from tail to head.
5) Move the head.
6) Check for self collision.
7) Sleep to control speed.
If you swap the order of steps 4 and 5, you’ll see a lag where segments follow one frame behind, which feels like input delay. The tail must move first, then the head, for the chain to look continuous.
Collision Math in One Line
I clamp at ±290 because the window is 600 pixels. Subtract 10 per side to avoid partially visible sprites. If you change grid size, keep boundary checks at (width/2 - grid_size) so the head stays fully visible.
Common Bugs I See (and How I Avoid Them)
Here are the issues I still see when people build this for the first time:
- Opposite-direction reversal: If you don’t block turning 180 degrees, the snake can instantly collide with itself. My direction functions prevent that.
- Flicker: Leaving tracer on makes the screen redraw continuously and makes movement look jagged. I always call sc.tracer(0) and update manually.
- Body lag: If you move the head before shifting segments, you get a rubber-banding effect. I always move segments tail-first.
- Food on the wall: If your random range includes ±300, the food can land on or outside the border and be unreachable. I clamp to ±270.
- Speed going negative: If you decrease delay too aggressively, it can hit zero or negative values and hang your program. I clamp with max(0.03, delay – 0.001).
Think of the snake like a line of shopping carts: the last cart moves to the position of the cart in front of it. That’s why we update from the end.
Debugging Checklist
When something looks wrong, I run these quick checks:
- Print head.xcor(), head.ycor() every 10 frames to confirm grid alignment.
- Temporarily set delay to 0.15 to slow everything down and visually inspect movement.
- Color the head differently after each collision reset to confirm reset logic fires.
- Replace random food placement with a fixed list of coordinates to reproduce bugs.
Modern Practices That Still Apply
Even though this is a small Turtle script, I build it with modern habits that scale to bigger projects. Here’s the comparison I use when I teach this internally:
Traditional
—
Many global variables
Inline key handlers
Ad-hoc move calls
Fixed sleep
Print statements
The key idea: structure is not about adding complexity, it’s about removing ambiguity. If your loop always runs in a fixed order, you can reason about state changes without guessing.
When You Should Use Turtle (and When You Shouldn’t)
I use Turtle for:
- Teaching input and movement
- Small prototypes
- Algorithms that need immediate visual feedback (pathfinding, grid traversal)
I avoid Turtle when:
- I need audio or physics
- I need dozens of sprites on screen at once
- I want consistent timing across many machines
For larger projects, I jump to Pygame or a lightweight engine like Arcade or Godot Python bindings. Turtle is still perfect for a first-pass logic test, though. It’s the “whiteboard” of 2D game development.
Performance and Timing Notes
You can usually get stable movement at 30–60 updates per second on a typical laptop with this loop. The delay you choose controls the feel:
- 0.12–0.08 seconds: slow, good for beginners
- 0.07–0.05 seconds: medium, classic arcade feel
- 0.04–0.03 seconds: fast, needs precise input
I keep a floor of around 0.03 so the loop doesn’t saturate the CPU. That range still feels smooth while avoiding random timing artifacts.
Measuring Performance Quickly
If you want to see how hard the loop hits your CPU, wrap the while loop body with a simple frame counter and print frames per second every 5 seconds. Turtle isn’t optimized for profiling, but you’ll at least see whether your delay choice keeps the loop stable. On my 2025 laptop, a delay of 0.04 keeps usage under 10% for this script.
A Clean, Complete Script (Single File)
If you want a single, runnable script with everything wired together, here it is. Save it as snake.py and run it with Python 3.
import turtle as t
import time
import random
# Game state
delay = 0.1
score = 0
high_score = 0
# Screen setup
sc = t.Screen()
sc.title(‘Snake Game‘)
sc.bgcolor(‘#add8e6‘)
sc.setup(width=600, height=600)
sc.tracer(0)
# Snake head
head = t.Turtle()
head.shape(‘square‘)
head.color(‘white‘)
head.penup()
head.goto(0, 0)
head.direction = ‘Stop‘
# Food
food = t.Turtle()
food.speed(0)
food.shape(random.choice([‘square‘, ‘triangle‘, ‘circle‘]))
food.color(random.choice([‘red‘, ‘green‘, ‘black‘]))
food.penup()
food.goto(0, 100)
# Score display
pen = t.Turtle()
pen.speed(0)
pen.shape(‘square‘)
pen.color(‘white‘)
pen.penup()
pen.hideturtle()
pen.goto(0, 260)
pen.write(‘Score : 0 High Score : 0‘, align=‘center‘, font=(‘candara‘, 24, ‘bold‘))
# Direction controls
def go_up():
if head.direction != ‘down‘:
head.direction = ‘up‘
def go_down():
if head.direction != ‘up‘:
head.direction = ‘down‘
def go_left():
if head.direction != ‘right‘:
head.direction = ‘left‘
def go_right():
if head.direction != ‘left‘:
head.direction = ‘right‘
def move():
if head.direction == ‘up‘:
head.sety(head.ycor() + 20)
elif head.direction == ‘down‘:
head.sety(head.ycor() – 20)
elif head.direction == ‘left‘:
head.setx(head.xcor() – 20)
elif head.direction == ‘right‘:
head.setx(head.xcor() + 20)
sc.listen()
sc.onkeypress(go_up, ‘Up‘)
sc.onkeypress(go_down, ‘Down‘)
sc.onkeypress(go_left, ‘Left‘)
sc.onkeypress(go_right, ‘Right‘)
# Snake body segments
segments = []
# Main loop
while True:
sc.update()
# Boundary collision
if head.xcor() > 290 or head.xcor() 290 or head.ycor() < -290:
time.sleep(1)
head.goto(0, 0)
head.direction = ‘Stop‘
for seg in segments:
seg.goto(1000, 1000)
segments.clear()
score = 0
delay = 0.1
pen.clear()
pen.write(f‘Score : {score} High Score : {high_score}‘, align=‘center‘, font=(‘candara‘, 24, ‘bold‘))
# Food collision
if head.distance(food) < 20:
food.goto(random.randint(-270, 270), random.randint(-270, 270))
new_seg = t.Turtle()
new_seg.speed(0)
new_seg.shape(‘square‘)
new_seg.color(‘orange‘)
new_seg.penup()
segments.append(new_seg)
delay = max(0.03, delay – 0.001)
score += 10
if score > high_score:
high_score = score
pen.clear()
pen.write(f‘Score : {score} High Score : {high_score}‘, align=‘center‘, font=(‘candara‘, 24, ‘bold‘))
# Move segments in reverse order
for i in range(len(segments) – 1, 0, -1):
x = segments[i – 1].xcor()
y = segments[i – 1].ycor()
segments[i].goto(x, y)
if len(segments) > 0:
segments[0].goto(head.xcor(), head.ycor())
move()
# Self-collision
for seg in segments:
if seg.distance(head) < 20:
time.sleep(1)
head.goto(0, 0)
head.direction = ‘Stop‘
for seg in segments:
seg.goto(1000, 1000)
segments.clear()
score = 0
delay = 0.1
pen.clear()
pen.write(f‘Score : {score} High Score : {high_score}‘, align=‘center‘, font=(‘candara‘, 24, ‘bold‘))
time.sleep(delay)
That baseline works. Now let’s add depth and polish.
Adding Structure: Functions and Small Helpers
Even in a small script, I like to wrap repeated behaviors in helpers. Examples:
reset_game()handles boundary or self-collision resets.add_segment(color)creates and appends a new body part.update_score(delta)bumps score, clamps delay, and refreshes the pen.
This makes the main loop read like a checklist and reduces copy-paste mistakes. When you later add levels or obstacles, you change a single helper instead of editing the loop in multiple places.
Refactoring to a Class (Optional)
If you want more structure, wrap the whole thing in a class. It mirrors how you’d organize a Pygame project.
class SnakeGame:
def init(self):
self.delay = 0.1
self.score = 0
self.high = 0
self.sc = t.Screen()
self.sc.title(‘Snake Game‘)
self.sc.bgcolor(‘#add8e6‘)
self.sc.setup(600, 600)
self.sc.tracer(0)
self.head = self.maketurtle(‘square‘, ‘white‘)
self.head.direction = ‘Stop‘
self.food = self.makefood()
self.pen = self.makepen()
self.segments = []
self.bindkeys()
def maketurtle(self, shape, color):
obj = t.Turtle()
obj.shape(shape)
obj.color(color)
obj.penup()
return obj
def makefood(self):
obj = self.maketurtle(random.choice([‘square‘, ‘triangle‘, ‘circle‘]),
random.choice([‘red‘, ‘green‘, ‘black‘]))
obj.goto(0, 100)
return obj
def makepen(self):
pen = t.Turtle()
pen.speed(0)
pen.shape(‘square‘)
pen.color(‘white‘)
pen.penup()
pen.hideturtle()
pen.goto(0, 260)
pen.write(‘Score : 0 High Score : 0‘, align=‘center‘, font=(‘candara‘, 24, ‘bold‘))
return pen
def bindkeys(self):
self.sc.listen()
self.sc.onkeypress(lambda: self.turn(‘up‘), ‘Up‘)
self.sc.onkeypress(lambda: self.turn(‘down‘), ‘Down‘)
self.sc.onkeypress(lambda: self.turn(‘left‘), ‘Left‘)
self.sc.onkeypress(lambda: self.turn(‘right‘), ‘Right‘)
def turn(self, direction):
opposite = {‘up‘: ‘down‘, ‘down‘: ‘up‘, ‘left‘: ‘right‘, ‘right‘: ‘left‘}
if self.head.direction != opposite.get(direction):
self.head.direction = direction
def step(self):
self.sc.update()
# boundary, food, movement, collision handled here
def run(self):
while True:
self.step()
time.sleep(self.delay)
This style is friendlier to unit tests. You can instantiate SnakeGame, call step once, and assert positions without running an infinite loop. It also isolates game state from module globals, which matters if you later embed this in a larger teaching tool.
Visual Polish: Color, Grid, and Fonts
I keep backgrounds light because Turtle’s default line aliasing is basic. Dark backgrounds show jagged edges more. I also choose a distinct color for the head so players can see leading direction quickly. If you want a retro feel, set sc.bgcolor(‘#111‘), make the head neon green, and switch the pen font to a pixel-style font if available on your system.
If you prefer a visible grid for teaching, draw thin lines every 20 pixels. Use a hidden Turtle to draw once at startup; leave tracer off so it doesn’t redraw each frame. This helps beginners reason about coordinates.
Sound and Feedback
Turtle itself doesn’t ship audio primitives. On Windows you can import winsound and play short beeps on food collision. On macOS/Linux you can use the bell character print(‘\a‘), but it’s hit-or-miss. I often skip sound for this project to keep dependencies zero. Visual feedback works: briefly flash the head color when eating or colliding. Set head.color(‘yellow‘) for two frames, then revert.
Edge Cases and How I Handle Them
- Food spawns on the snake: Rare but possible. After picking coordinates, check distance to each segment and reroll if needed. Because the grid is large, one reroll is usually enough.
- Pausing: Add a key binding to toggle a paused flag; when paused, skip movement and collisions but still call sc.update() so the window stays responsive.
- Window close: Without handling, closing the window can leave the process running. Bind sc.onkeypress(quit, ‘q‘) and call t.bye() to exit cleanly.
- High score persistence: Write the high score to a small text file and read it at startup. Make sure to handle file absence gracefully.
Difficulty Options and Game Feel
A slider for speed helps classrooms where some students want slow motion. Add keys 1, 2, 3 to set delay presets (0.12, 0.08, 0.05). You can also add a growth factor: instead of one segment per food, sometimes add two. That changes how quickly self-collision becomes a risk.
If you want levels, introduce walls. Represent walls as a list of coordinate pairs and draw them once. In the loop, check head distance to each wall block just like body collision. This adds path-planning lessons.
Clean Restart vs Hard Reset
Notice I move old segments to 1000,1000 instead of deleting them. Turtle object deletion is slower than reusing objects, and this avoids reallocation overhead. If you want to be stricter about memory, you can hide segments with hideturtle() and reuse them by popping from a pool when the snake grows. For a beginner build, the off-screen trick is simple and fast enough.
Testing Small Pieces
Game code resists traditional unit testing, but two pieces are worth testing:
- The turn guard: assert that turn(‘up‘) followed by turn(‘down‘) keeps direction ‘up‘.
- The body-follow logic: given three segments at known coordinates and a head move, assert new coordinates match expectations.
You can expose pure functions for these to keep tests headless. For example, make a function advance_segments(positions) that returns new positions without touching Turtle objects. That lets you run pytest in CI without GUI requirements.
Teaching Pathfinding and Algorithms
Once students grasp movement, I add an “auto-play” mode: a simple greedy bot that moves horizontally toward food, then vertically. It will eventually trap itself, which is a perfect segue into discussing A* search on a grid. Turtle visuals make algorithm mistakes obvious. The key is to keep bot logic pure and feed directions into the same turn() guard the human uses.
Packaging and Sharing
Because it’s a single file, sharing is easy. I still do a few niceties:
- Add a shebang line
#!/usr/bin/env python3for Unix systems. - Put instructions at the top: keys, quit key, restart behavior.
- If I’m handing this to kids, I disable the console window on Windows by creating a
.pywfile so only the Turtle window appears.
If you want to publish, include a requirements note (none) and tested Python version. A tiny README with screenshots helps.
Classroom Exercises (Progressive)
I’ve run these in workshops, roughly 10–15 minutes each:
1) Change grid size to 10 and adjust bounds; observe the feel.
2) Make food worth 5 points but add two segments per bite; discuss difficulty.
3) Add a pause/unpause key.
4) Add walls: draw a rectangle inside the boundary and collide with it.
5) Flash the head color when food is eaten.
6) Save and load high score from a file.
7) Add a slow-motion power-up: spawn a blue circle that increases delay to 0.12 for 5 seconds.
8) Implement wrap-around mode instead of walls; teach modular arithmetic.
These steps keep the code small while layering real design decisions.
Performance Tuning Beyond Delay
If you profile and find hiccups (rare in this simple loop), the culprits are usually:
- Excessive pen.clear() calls with large fonts. Keep the scoreboard text short.
- Creating new Turtles every frame. Avoid it; reuse objects.
- Large tracer values accidentally turned on. Confirm sc.tracer(0) stays set.
You can also decouple logic from render by ticking logic every N milliseconds and rendering every frame, but with Turtle the gains are minimal and the complexity isn’t worth it for this project.
Input Alternatives (WASD, Touch, Gamepad)
Keyboard arrows are standard, but you can bind WASD easily by adding onkeypress handlers for ‘w‘, ‘a‘, ‘s‘, ‘d‘. For touchscreens, Turtle doesn’t expose touch events, but you can simulate taps with mouse clicks: bind onclick to set direction based on click quadrant. Gamepads aren’t directly supported; if you want that, you’re in Pygame territory.
Error Handling and Recovery
Occasionally Turtle windows freeze if the script throws an exception mid-loop. I wrap the main loop call in try/except KeyboardInterrupt so Ctrl+C exits cleanly. If you add file I/O for high scores, catch IOError and default to zero so the game still runs in read-only environments.
Code Style Choices I Make
- Single quotes to reduce escaping in strings.
- snake_case for functions, PascalCase only if I introduce classes.
- Constants like GRID_SIZE = 20 and BORDER = 290 defined near the top to avoid magic numbers.
- Minimal comments; the function names carry meaning. I add comments only where Turtle quirks aren’t obvious (e.g., why tracer(0) matters).
Wrapping Up and Next Steps
With about 120 lines of code, you get a fully interactive game that teaches timing, input handling, and collision math. The real value is in the habits you practice: ordered updates, guard rails on input, and tiny helper functions that make the loop readable. From here you can:
- Add levels with walls or moving obstacles.
- Persist high scores and player names to a JSON file.
- Bolt on a rudimentary AI to chase food and study pathfinding.
- Port the logic to Pygame or Arcade and compare performance.
The beauty of starting with Turtle is that nothing is hidden. You feel every frame and every collision. That transparency forces good habits, and those habits carry straight into larger engines when you’re ready.



