UMPSA STEM LAB · Step 4 of 10
Use a list of tuples to store the growing snake body, spawn food randomly, detect collisions, and make the game actually playable.
This is the biggest step — you go from a moving dot to a real Snake game. The snake grows when it eats food, and dies when it hits a wall or itself.
Lists as ordered sequences, tuples as coordinate pairs, list operations (insert/pop/slice), the random module, and for loops with enumerate.
After this step the game is fully playable. Steps 5–9 improve, extend, and refactor it — but you already have Snake.
The snake body is stored as a Python list of tuples. Each tuple is one grid cell (col, row). The head is always snake[0] — the first item.
Choosing the right data structure is the first step in algorithm design. A list of tuples is perfect for the snake: it's ordered (head first, tail last), variable-length (grows when eating), and each position is an (x,y) pair that doesn't change.
Click the buttons to simulate one game frame. Watch how insert(0, new_head) adds to the front and pop() removes the tail:
The canvas below shows an 8×6 mini-grid. Press Move or Eat to see the snake update — the list changes at the same time as the grid:
hx0,hy0 = 10,7 # head hx1,hy1 = 9,7 # body 1 hx2,hy2 = 8,7 # body 2 # grows to 20 segments... 40 variables!
snake = [(10,7),(9,7),(8,7)] # add head: snake.insert(0, new) # remove tail: snake.pop() # works for ANY length!
Only three list operations power the entire snake body mechanic.
hx, hy = snake[0] — read the headIndex [0] gets the first item. Python automatically unpacks the tuple (10, 7) into hx=10, hy=7. This is tuple unpacking — the same syntax as hx, hy = 10, 7 from Step 3.
snake = [(10,7),(9,7),(8,7)] hx, hy = snake[0] # hx=10, hy=7 print(snake[-1]) # (8,7) — last item (tail)
snake.insert(0, new_head) — add new head to FRONTinsert(0, x) inserts item x at index 0, pushing everything else right. This is O(n) — every existing item shifts one position. For our short snake this is fine; for 10,000 segments you'd use a deque.
snake.insert(0, (11,7)) # snake is now [(11,7),(10,7),(9,7),(8,7)]
snake.pop() — remove tail (no growth)pop() with no argument removes and returns the last item. Combined with insert at front, this keeps the snake the same length — it moves forward without growing.
snake.pop() # removes (8,7), returns it # snake is now [(11,7),(10,7),(9,7)] ← same length
pop(). The new head was already inserted, so the list is one item longer. Score goes up, new food spawns randomly.Two types of collision end the game. Both use the in operator — Python's elegant membership test.
Collision detection is a classic algorithm pattern: after every move, check all possible "death" conditions before committing the move. This is called a validity check — common in chess engines, physics simulations, and robot pathfinding.
if (hx < 0 or hx >= GRID_COLS
or hy < 0 or hy >= GRID_ROWS):
running = False
Check all four boundaries. hx >= GRID_COLS catches the right wall (grid is 0–19, so col 20 is out).
if new_head in snake[1:]:
running = False
snake[1:] is a slice — the body without the old head. The in operator checks if new_head matches any tuple in that list.
snake[1:] not snake?After snake.insert(0, new_head), the new head is already at snake[0]. If we checked new_head in snake, it would always be True (it's there!). We check snake[1:] — the body excluding the new head — so only a genuine self-hit triggers game over.
Food spawns at a random grid cell each time it's eaten. Python's random module generates the coordinates.
Controlled randomness is a game design pattern: deterministic rules (snake movement, collision) combined with random elements (food position) creates replayability. The same pattern appears in card shuffling, procedural map generation, and enemy AI decisions.
import random
# Spawn food anywhere in the grid
food = (random.randint(0, GRID_COLS-1),
random.randint(0, GRID_ROWS-1))
# Each time the snake eats:
if new_head == food:
score += 1
food = (random.randint(0, GRID_COLS-1), # new random position
random.randint(0, GRID_ROWS-1))
for + enumerateTo draw every snake segment we iterate the list. enumerate gives us both the index and the value — we need the index to colour the head differently.
# Step 4-9: Draw every segment — head darker than body
for i, (cx, cy) in enumerate(snake):
colour = DKGREEN if i == 0 else GREEN # head vs body
pygame.draw.rect(screen, colour,
pygame.Rect(cx*CELL_SIZE, cy*CELL_SIZE,
CELL_SIZE-2, CELL_SIZE-2), border_radius=4)
enumerate(snake) yields pairs: (0, (10,7)), (1, (9,7)), … The pattern for i, (cx, cy) unpacks both the index and the tuple in one line.
| i (index) | (cx, cy) | Colour | Reason |
|---|---|---|---|
| 0 | (10, 7) | DKGREEN | Head — darker to show direction |
| 1 | (9, 7) | GREEN | Body segment |
| 2 | (8, 7) | GREEN | Body segment |
| … | … | GREEN | All remaining body |
import random # Step 4-0: add to the top import line
# Step 4-1: Snake is a LIST of (column, row) tuples
snake = [(10, 7), (9, 7), (8, 7)] # starts 3 cells long, moving right
# Step 4-2: Food at a random grid position, plus score
food = (random.randint(0, GRID_COLS-1),
random.randint(0, GRID_ROWS-1))
score = 0
# ── inside the game loop ─────────────────────────────────────────
# Step 4-3: Read head position from the front of the list
hx, hy = snake[0]
# ... then move hx, hy with direction (same as Step 3) ...
new_head = (hx, hy)
# Step 4-4: Die if head hits a wall
if hx < 0 or hx >= GRID_COLS or hy < 0 or hy >= GRID_ROWS:
running = False
# Step 4-5: Did we eat the food?
ate_food = (new_head == food)
# Step 4-6: Add new head to the FRONT of the list
snake.insert(0, new_head)
if ate_food:
score += 1
food = (random.randint(0, GRID_COLS-1), # spawn new food
random.randint(0, GRID_ROWS-1))
else:
snake.pop() # Step 4-7: Remove tail — snake stays same length
# Step 4-8: Self-collision — head inside body?
if new_head in snake[1:]:
running = False
# Step 4-9: Draw every cell (head = DKGREEN, body = GREEN)
for i, (cx, cy) in enumerate(snake):
colour = DKGREEN if i == 0 else GREEN
pygame.draw.rect(screen, colour,
pygame.Rect(cx*CELL_SIZE, cy*CELL_SIZE,
CELL_SIZE-2, CELL_SIZE-2), border_radius=4)
# Step 4-10: Draw food as a red circle
fx, fy = food
pygame.draw.circle(screen, RED,
(fx*CELL_SIZE + CELL_SIZE//2,
fy*CELL_SIZE + CELL_SIZE//2), CELL_SIZE//3)
import pygame, random
pygame.init()
GRID_COLS = 20; GRID_ROWS = 15; CELL_SIZE = 30; SNAKE_SPEED = 8
WHITE=(255,255,255); GREEN=(0,200,80); DKGREEN=(0,160,50)
RED=(220,50,50); BG=(30,30,30)
WINDOW_W = GRID_COLS * CELL_SIZE
WINDOW_H = GRID_ROWS * CELL_SIZE
screen = pygame.display.set_mode((WINDOW_W, WINDOW_H))
pygame.display.set_caption("Snake")
font = pygame.font.SysFont(None, 28)
clock = pygame.time.Clock()
running = True
direction = "RIGHT"
snake = [(10,7),(9,7),(8,7)]
food = (random.randint(0,GRID_COLS-1), random.randint(0,GRID_ROWS-1))
score = 0
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT: running = False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE: running = False
if event.key==pygame.K_UP and direction!="DOWN": direction="UP"
elif event.key==pygame.K_DOWN and direction!="UP": direction="DOWN"
elif event.key==pygame.K_LEFT and direction!="RIGHT": direction="LEFT"
elif event.key==pygame.K_RIGHT and direction!="LEFT": direction="RIGHT"
hx, hy = snake[0]
if direction=="RIGHT": hx+=1
elif direction=="LEFT": hx-=1
elif direction=="UP": hy-=1
elif direction=="DOWN": hy+=1
new_head = (hx, hy)
if hx<0 or hx>=GRID_COLS or hy<0 or hy>=GRID_ROWS:
running = False; continue
ate_food = (new_head == food)
snake.insert(0, new_head)
if ate_food:
score += 1
food = (random.randint(0,GRID_COLS-1), random.randint(0,GRID_ROWS-1))
else:
snake.pop()
if new_head in snake[1:]: running = False
screen.fill(BG)
for i, (cx, cy) in enumerate(snake):
c = DKGREEN if i==0 else GREEN
pygame.draw.rect(screen, c,
pygame.Rect(cx*CELL_SIZE, cy*CELL_SIZE, CELL_SIZE-2, CELL_SIZE-2),
border_radius=4)
fx, fy = food
pygame.draw.circle(screen, RED,
(fx*CELL_SIZE+CELL_SIZE//2, fy*CELL_SIZE+CELL_SIZE//2), CELL_SIZE//3)
screen.blit(font.render(f"Score: {score}", True, WHITE), (8, 6))
pygame.display.flip()
clock.tick(SNAKE_SPEED)
pygame.quit()
snake = [(10,7),(9,7),(8,7)]A list literal — square brackets containing three tuples. Lists are mutable (can change), ordered (index matters), and heterogeneous (can hold any type). Index 0 is always the head.
hx, hy = snake[0]Two operations in one: snake[0] retrieves the first tuple; hx, hy = ... unpacks it. Python matches values left-to-right. If the tuple had three values, you'd need three variables.
snake.insert(0, new_head)insert(index, value) inserts before the given index. Index 0 means "at the very front". All existing items shift one position to the right (their index increases by 1).
snake.pop()Removes and returns the last item. Since we just inserted at the front, removing from the back keeps the length constant — simulating forward movement without growing.
new_head in snake[1:]snake[1:] is list slicing: a new list of everything from index 1 to the end. The in operator tests membership using == on each element — O(n) search. Since we already inserted new_head at [0], we skip it by slicing from [1].
for i, (cx, cy) in enumerate(snake):Nested unpacking: enumerate yields (0, (10,7)) pairs; Python then unpacks (cx,cy) from the inner tuple. This is a very Pythonic pattern — concise and readable.
Change the starting snake to 5 cells: [(10,7),(9,7),(8,7),(7,7),(6,7)]. Run — does the snake look longer at start? What does print(len(snake)) show?
Add print(f"Head:{snake[0]} Tail:{snake[-1]} Len:{len(snake)}") inside the loop. Eat some food. Do you see the length increase?
In Python shell: s=[(10,7),(9,7),(8,7)]; print(s[1:]). What do you get? Now try s[:-1] and s[1:3]. Can you explain each result?
After spawning food, add a while food in snake: loop that keeps re-rolling until food is on an empty cell. This is a common game dev pattern called "rejection sampling".
These are the errors beginners make most often in Step 4. Read them now so you can recognise them in your own code.
snake.pop() — snake grows forevernew_head = (snake[0][0] + dx, snake[0][1] + dy) snake.insert(0, new_head) # pop() missing — tail never removed!
new_head = (snake[0][0] + dx, snake[0][1] + dy)
snake.insert(0, new_head)
if not ate_food:
snake.pop() # remove tail; skip this line to grow
if new_head in snake: # checks OLD position — wrong!
running = False
snake.insert(0, new_head)
snake.pop()
snake.insert(0, new_head) # move first
snake.pop()
if new_head in snake[1:]: # then check the new head vs rest
running = False
[1:] in the self-collision checkif new_head in snake: # includes snake[0] = new_head itself!
new_head in snake is always True. The game ends on the very first frame.if new_head in snake[1:]: # check against body only (skip index 0)
Three questions — not graded. They help you spot gaps before the activities.
new_head in snake[1:] instead of new_head in snake?You've covered all the concepts for Step 4. Time to apply them.
Start Activities → Tier 1 → 2 → 3 → 4