Learn · Python
How to Build a Snake Game in Python – Step by Step
How to Build a Snake Game in Python — Step by Step
By the end of this guide you will have a complete, playable snake game in Python — the same core engine that powers the browser game on this site, rebuilt with pygame. Every block below is part of one final program, and the full code is automatically tested against a real pygame install before it appears here, so you can copy it with confidence.
What you'll need
- Python 3.8 or newer — check with
python --version(orpython3 --version) - The
pygamepackage:
pip install pygame
That's it. We will use only pygame and Python's built-in random module.
Setting up the game window
Every pygame program follows the same skeleton: initialise pygame, create a window, loop until told to quit, then clean up.
import random
import sys
import pygame
CELL = 24 # pixels per grid cell
GRID_WIDTH = 24 # cells across
GRID_HEIGHT = 24 # cells down
TICK_MS = 120 # milliseconds per move: lower = faster snake
WINDOW_WIDTH = CELL * GRID_WIDTH
WINDOW_HEIGHT = CELL * GRID_HEIGHT
def main():
pygame.init()
try:
screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
pygame.display.set_caption("Snake Arena - Python Tutorial")
clock = pygame.time.Clock()
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
pygame.display.flip()
clock.tick(1000 // TICK_MS)
finally:
pygame.quit()
if __name__ == "__main__":
sys.exit(main())
Run it and you should see an empty dark window that closes cleanly. Two details worth noticing:
clock.tick(fps)is how we control game speed. Our snake moves on a fixed tick of 120 ms — the same tick rate as Classic mode on the web version.- The
try/finallyguaranteespygame.quit()runs even if something crashes, so your terminal never gets stuck in a broken window state.
Drawing the grid and the snake
The board is really just a grid of 24×24 cells. The snake lives in cell
coordinates; drawing multiplies by CELL to get pixels. Add the colours and a
game-state factory now:
COLOR_BACKGROUND = (13, 13, 13)
COLOR_SNAKE_HEAD = (57, 255, 106)
COLOR_SNAKE_BODY = (40, 194, 78)
COLOR_FOOD = (255, 71, 71)
UP = (0, -1)
DOWN = (0, 1)
LEFT = (-1, 0)
RIGHT = (1, 0)
def new_game():
"""Return a fresh game state dict."""
return {
"segments": [(12, 12), (11, 12), (10, 12)], # head first
"direction": RIGHT,
"pending_direction": None, # input buffer, applied on next tick
"score": 0,
"alive": True,
}
def spawn_food(segments):
"""Pick a random cell that is not part of the snake."""
occupied = set(segments)
free_cells = [
(x, y)
for x in range(GRID_WIDTH)
for y in range(GRID_HEIGHT)
if (x, y) not in occupied
]
return random.choice(free_cells)
def draw_frame(screen, font, game, food):
screen.fill(COLOR_BACKGROUND)
for index, (x, y) in enumerate(game["segments"]):
color = COLOR_SNAKE_HEAD if index == 0 else COLOR_SNAKE_BODY
rect = pygame.Rect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2)
pygame.draw.rect(screen, color, rect, border_radius=4)
food_rect = pygame.Rect(food[0] * CELL + 4, food[1] * CELL + 4, CELL - 8, CELL - 8)
pygame.draw.rect(screen, COLOR_FOOD, food_rect, border_radius=6)
hud = font.render("SCORE %03d" % game["score"], True, COLOR_TEXT)
screen.blit(hud, (8, 8))
Note segments[0] is always the head. That single convention makes movement,
collision, and drawing all simpler.
Handling movement and input
Here is the trick most beginner snake games get wrong. If you turn the snake immediately when a key is pressed, two quick presses inside one tick can reverse you into your own body. The fix is an input buffer: record the requested turn, apply it only when the next tick happens, and reject any turn that points directly backwards.
def handle_input(events, game):
"""Read one frame of keyboard events into the pending direction buffer."""
for event in events:
if event.type == pygame.KEYDOWN:
key_map = {
pygame.K_UP: UP,
pygame.K_DOWN: DOWN,
pygame.K_LEFT: LEFT,
pygame.K_RIGHT: RIGHT,
}
if event.key in key_map:
game["pending_direction"] = key_map[event.key]
elif event.key == pygame.K_ESCAPE:
pygame.event.post(pygame.event.Event(pygame.QUIT))
return game
def apply_pending_direction(game):
"""Turn the snake unless the buffered turn would reverse it instantly."""
if game["pending_direction"] is None:
return
if (
game["pending_direction"][0] != -game["direction"][0]
or game["pending_direction"][1] != -game["direction"][1]
):
game["direction"] = game["pending_direction"]
game["pending_direction"] = None
Collision detection (walls, self, food)
Movement itself is tiny: take the head cell, add the direction vector, insert the new head at the front of the list, then pop the tail — unless we just ate food, in which case the tail stays and the snake grows by exactly one segment.
def move_snake(game, food):
"""Advance one tick: move the head, then resolve collisions and food."""
if not game["alive"]:
return food
dx, dy = game["direction"]
head_x, head_y = game["segments"][0]
new_head = (head_x + dx, head_y + dy)
hit_wall = not (0 <= new_head[0] < GRID_WIDTH and 0 <= new_head[1] < GRID_HEIGHT)
hit_self = new_head in game["segments"]
if hit_wall or hit_self:
game["alive"] = False
return food
game["segments"].insert(0, new_head)
if new_head == food:
game["score"] += SCORE_PER_FOOD
return spawn_food(game["segments"]) # grew: keep the tail, respawn food
game["segments"].pop() # did not grow: drop the tail cell
return food
Wall collision checks bounds. Self collision is a single set/list membership test — this is why storing segments in a list pays off. Food collision doubles as the growth trigger.
Score tracking and game-over
Score was already handled in move_snake (+10 per food). What remains is the
visible feedback: a game-over message and restart on R. Update
handle_input's key handling with:
SCORE_PER_FOOD = 10
COLOR_TEXT = (57, 255, 106)
# inside handle_input's event loop, after the key_map check:
# elif event.key == pygame.K_r and not game["alive"]:
# return new_game() # restart requested
and append this at the end of draw_frame:
if game["alive"]:
pass # keep playing
else:
message = font.render("GAME OVER - press R to restart", True, COLOR_FOOD)
screen.blit(message, (8, WINDOW_HEIGHT - 30))
Full working code
This exact program is extracted and executed in CI before publishing — run it with
python snake_tutorial.py, steer with the arrow keys, press R after a
crash.
"""
Snake Arena Python Tutorial - the complete snake game we build step by step
at snakegames.in/learn/python-snake-tutorial.
Requirements: Python 3.8+ and pygame (`pip install pygame`).
Run it: python snake_tutorial.py
Controls: arrow keys to steer, R to restart after game over, close the
window (or Esc) to quit.
"""
import random
import sys
import pygame
# --- 1. Set up pygame and the constants that shape the game -----------------
CELL = 24 # pixels per grid cell
GRID_WIDTH = 24 # cells across (matches our web version)
GRID_HEIGHT = 24 # cells down
TICK_MS = 120 # milliseconds per move: lower = faster snake
SCORE_PER_FOOD = 10
WINDOW_WIDTH = CELL * GRID_WIDTH
WINDOW_HEIGHT = CELL * GRID_HEIGHT
COLOR_BACKGROUND = (13, 13, 13)
COLOR_SNAKE_HEAD = (57, 255, 106)
COLOR_SNAKE_BODY = (40, 194, 78)
COLOR_FOOD = (255, 71, 71)
COLOR_TEXT = (57, 255, 106)
UP = (0, -1)
DOWN = (0, 1)
LEFT = (-1, 0)
RIGHT = (1, 0)
def new_game():
"""Return a fresh game state dict."""
return {
"segments": [(12, 12), (11, 12), (10, 12)], # head first
"direction": RIGHT,
"pending_direction": None, # input buffer, applied on next tick
"score": 0,
"alive": True,
}
def spawn_food(segments):
"""Pick a random cell that is not part of the snake."""
occupied = set(segments)
free_cells = [
(x, y)
for x in range(GRID_WIDTH)
for y in range(GRID_HEIGHT)
if (x, y) not in occupied
]
return random.choice(free_cells)
def handle_input(events, game):
"""Read one frame of keyboard events into the pending direction buffer."""
for event in events:
if event.type == pygame.KEYDOWN:
key_map = {
pygame.K_UP: UP,
pygame.K_DOWN: DOWN,
pygame.K_LEFT: LEFT,
pygame.K_RIGHT: RIGHT,
}
if event.key in key_map:
game["pending_direction"] = key_map[event.key]
elif event.key == pygame.K_r and not game["alive"]:
return new_game() # restart requested
elif event.key == pygame.K_ESCAPE:
pygame.event.post(pygame.event.Event(pygame.QUIT))
return game
def apply_pending_direction(game):
"""Turn the snake unless the buffered turn would reverse it instantly."""
if game["pending_direction"] is None:
return
if (
game["pending_direction"][0] != -game["direction"][0]
or game["pending_direction"][1] != -game["direction"][1]
):
game["direction"] = game["pending_direction"]
game["pending_direction"] = None
def move_snake(game, food):
"""Advance one tick: move the head, then resolve collisions and food."""
if not game["alive"]:
return food
dx, dy = game["direction"]
head_x, head_y = game["segments"][0]
new_head = (head_x + dx, head_y + dy)
hit_wall = not (0 <= new_head[0] < GRID_WIDTH and 0 <= new_head[1] < GRID_HEIGHT)
hit_self = new_head in game["segments"]
if hit_wall or hit_self:
game["alive"] = False
return food
game["segments"].insert(0, new_head)
if new_head == food:
game["score"] += SCORE_PER_FOOD
return spawn_food(game["segments"]) # grew: keep the tail, respawn food
game["segments"].pop() # did not grow: drop the tail cell
return food
def draw_frame(screen, font, game, food):
screen.fill(COLOR_BACKGROUND)
for index, (x, y) in enumerate(game["segments"]):
color = COLOR_SNAKE_HEAD if index == 0 else COLOR_SNAKE_BODY
rect = pygame.Rect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2)
pygame.draw.rect(screen, color, rect, border_radius=4)
food_rect = pygame.Rect(food[0] * CELL + 4, food[1] * CELL + 4, CELL - 8, CELL - 8)
pygame.draw.rect(screen, COLOR_FOOD, food_rect, border_radius=6)
hud = font.render("SCORE %03d" % game["score"], True, COLOR_TEXT)
screen.blit(hud, (8, 8))
if not game["alive"]:
message = font.render("GAME OVER - press R to restart", True, COLOR_FOOD)
screen.blit(message, (8, WINDOW_HEIGHT - 30))
def main():
pygame.init()
try:
screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
pygame.display.set_caption("Snake Arena - Python Tutorial")
clock = pygame.time.Clock()
font = pygame.font.SysFont("monospace", 18, bold=True)
game = new_game()
food = spawn_food(game["segments"])
running = True
while running:
events = pygame.event.get()
for event in events:
if event.type == pygame.QUIT:
running = False
game = handle_input(events, game)
apply_pending_direction(game)
food = move_snake(game, food)
draw_frame(screen, font, game, food)
pygame.display.flip()
clock.tick(1000 // TICK_MS) # TICK_MS per grid step
finally:
pygame.quit()
if __name__ == "__main__":
sys.exit(main())
Where to go next
- Speed ramp: shrink
TICK_MSby 5 every 100 points, with a floor of 60 ms — exactly how the web version accelerates. - Maze mode: keep a set of obstacle cells and treat them like walls (try it in the browser).
- Two players: split the window into two half-grids and give each snake its own input keys.