SeeThinkExploreMarvel

UMPSA STEM LAB · Step 4 of 10

Step 4 — Snake Body ⭐

Use a list of tuples to store the growing snake body, spawn food randomly, detect collisions, and make the game actually playable.

ListTupleinsert / poprandomfor + enumerate
Step 4 / 10

What you'll build in Step 4

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.

🎯 Learning goals

Lists as ordered sequences, tuples as coordinate pairs, list operations (insert/pop/slice), the random module, and for loops with enumerate.

🐍 Snake game milestone

After this step the game is fully playable. Steps 5–9 improve, extend, and refactor it — but you already have Snake.

CT skills this step

🗂 Data Representation 🔄 Pattern Recognition 💡 Abstraction 📋 Algorithm Design

The Big Idea — Snake as a List

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.

🗂 Data Representation

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.

The snake list — frame by frame

Click the buttons to simulate one game frame. Watch how insert(0, new_head) adds to the front and pop() removes the tail:

Watch it happen on the grid

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:

Press a button below to animate a frame.
Click a button below to simulate a frame.

Why a list and not individual variables?

❌ Individual variables (bad)

hx0,hy0 = 10,7  # head
hx1,hy1 =  9,7  # body 1
hx2,hy2 =  8,7  # body 2
# grows to 20 segments... 40 variables!

✅ List (correct)

snake = [(10,7),(9,7),(8,7)]
# add head: snake.insert(0, new)
# remove tail: snake.pop()
# works for ANY length!

List Operations — the snake's three key moves

Only three list operations power the entire snake body mechanic.

1

hx, hy = snake[0] — read the head

Index [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)
2

snake.insert(0, new_head) — add new head to FRONT

insert(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)]
3

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
When the snake eats food — skip the pop(). The new head was already inserted, so the list is one item longer. Score goes up, new food spawns randomly.

Collision Detection

Two types of collision end the game. Both use the in operator — Python's elegant membership test.

📋 Algorithm Design — boundary checking

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.

🧱 Wall collision

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).

🐍 Self-collision

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.

Why 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.

Random Food Placement

Food spawns at a random grid cell each time it's eaten. Python's random module generates the coordinates.

🔄 Pattern Recognition — randomness in games

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))
Known bug we're leaving for now Food can spawn inside the snake. The fix (check that the random cell isn't in the snake list) is a good bonus challenge. Step 4b introduces it for the bonus food items.

Drawing with for + enumerate

To 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)ColourReason
0(10, 7)DKGREENHead — darker to show direction
1(9, 7)GREENBody segment
2(8, 7)GREENBody segment
……GREENAll remaining body

The Code — Step 4

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)

Try It — Exercises

1

Longer start 🗂 Data

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?

2

Head and tail 🔄 Pattern

Add print(f"Head:{snake[0]} Tail:{snake[-1]} Len:{len(snake)}") inside the loop. Eat some food. Do you see the length increase?

3

Slice quiz 📋 Algorithm

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?

4

Fix the food-in-snake bug 💡 Abstraction

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".

⚠️ Common Mistakes — Spot These Before You Start

These are the errors beginners make most often in Step 4. Read them now so you can recognise them in your own code.

✗ Mistake 1: Forgetting snake.pop() — snake grows forever
new_head = (snake[0][0] + dx, snake[0][1] + dy)
snake.insert(0, new_head)
# pop() missing — tail never removed!
🐞 The snake gets one cell longer every frame. After a few seconds the screen is full. And collision with itself triggers immediately because the snake fills everything.
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
✗ Mistake 2: Checking collision before moving
if new_head in snake:       # checks OLD position — wrong!
    running = False
snake.insert(0, new_head)
snake.pop()
🐞 Collision check runs on the old snake before the head moved. The snake can pass through its own body on the first frame and never die properly.
snake.insert(0, new_head)   # move first
snake.pop()
if new_head in snake[1:]:   # then check the new head vs rest
    running = False
⚡ Mistake 3: Forgetting [1:] in the self-collision check
if new_head in snake:   # includes snake[0] = new_head itself!
🐞 The new head IS already in the list at index 0, so 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)

✅ Quick Check — Are You Ready?

Three questions — not graded. They help you spot gaps before the activities.

1 How does the snake appear to move without actually shifting every segment?
2 When does the snake grow longer?
3 Why check 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
Previous← Direction Control