UMPSA STEM LAB · Step 9 of 10
Reorganise the entire game into Python classes — Snake, Food, and Game. Learn encapsulation, __init__, self, and methods.
Object-Oriented Programming groups related data (attributes) and behaviour (methods) into a single unit called a class. A class is a blueprint; an object (instance) is a specific copy of that blueprint.
Currently, the snake's data (the list) is separate from its behaviour (insert/pop, draw). OOP bundles them: my_snake.move() and my_snake.draw() are part of the snake object. This matches how we think about real things — a snake is something that moves and draws itself, not just a list of positions.
snake = [(10,7),(9,7),(8,7)] # data
def update_game(snake,...): # behaviour
snake.insert(...)
snake.pop()
# data and behaviour are SEPARATED
class Snake:
def __init__(self):
self.body = [(10,7),(9,7),(8,7)]
def move(self, direction):
self.body.insert(...)
# data and behaviour TOGETHER
A class is just a blueprint — it describes what a snake could look like. An object (instance) is a real snake created from that blueprint, with actual data filled in. Click to create objects:
import pygame, random
CELL_SIZE = 30
GREEN = (0, 200, 80); DKGREEN = (0, 160, 50)
MOVES = {"RIGHT":(1,0),"LEFT":(-1,0),"UP":(0,-1),"DOWN":(0,1)}
OPPOSITES = {"RIGHT":"LEFT","LEFT":"RIGHT","UP":"DOWN","DOWN":"UP"}
class Snake:
def __init__(self, start=(10, 7)):
"""Initialise the snake with a 3-cell body at start position."""
self.body = [start, (start[0]-1, start[1]), (start[0]-2, start[1])]
self.direction = "RIGHT"
self.alive = True
self._grow_next = False # flag: grow on next move
def head(self):
"""Return the head position (first element)."""
return self.body[0]
def set_direction(self, key):
"""Change direction from keyboard key, ignoring reversal."""
key_map = {
pygame.K_UP: "UP", pygame.K_DOWN: "DOWN",
pygame.K_LEFT: "LEFT", pygame.K_RIGHT: "RIGHT"
}
new_dir = key_map.get(key)
if new_dir and new_dir != OPPOSITES[self.direction]:
self.direction = new_dir
def move(self, grid_cols, grid_rows):
"""Move one step. Returns True if still alive."""
dx, dy = MOVES[self.direction]
hx, hy = self.head()
new_head = ((hx + dx) % grid_cols, (hy + dy) % grid_rows)
if new_head in self.body[1:]: # self-collision
self.alive = False; return False
self.body.insert(0, new_head)
if self._grow_next:
self._grow_next = False # consumed — don't pop tail
else:
self.body.pop()
return True
def grow(self):
"""Schedule the snake to grow on the next move."""
self._grow_next = True
def draw(self, screen):
"""Draw every segment to the screen surface."""
for i, (cx, cy) in enumerate(self.body):
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)
RED = (220, 50, 50)
class Food:
def __init__(self, grid_cols, grid_rows, avoid=None):
"""Spawn food at a random cell, not inside avoid (snake body)."""
self.value = 1
self.respawn(grid_cols, grid_rows, avoid or [])
def respawn(self, grid_cols, grid_rows, avoid):
"""Move food to a random empty cell."""
while True:
self.pos = (random.randint(0, grid_cols-1),
random.randint(0, grid_rows-1))
if self.pos not in avoid: # fix the Step 4 bug!
break
def draw(self, screen):
fx, fy = self.pos
pygame.draw.circle(screen, RED,
(fx*CELL_SIZE + CELL_SIZE//2,
fy*CELL_SIZE + CELL_SIZE//2), CELL_SIZE//3)
The Game class owns everything — it creates the Snake and Food, runs the loop, and updates them each frame.
WHITE = (255, 255, 255); BG = (30, 30, 30)
GRID_COLS = 20; GRID_ROWS = 15; SNAKE_SPEED = 8
class Game:
def __init__(self):
pygame.init()
self.screen = pygame.display.set_mode(
(GRID_COLS*CELL_SIZE, GRID_ROWS*CELL_SIZE))
pygame.display.set_caption("Snake OOP")
self.font = pygame.font.SysFont(None, 28)
self.clock = pygame.time.Clock()
self.reset()
def reset(self):
"""Restart game state."""
self.snake = Snake()
self.food = Food(GRID_COLS, GRID_ROWS, self.snake.body)
self.score = 0
self.running = True
def handle_events(self):
for event in pygame.event.get():
if event.type == pygame.QUIT: self.running = False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE: self.running = False
self.snake.set_direction(event.key)
def update(self):
if not self.snake.move(GRID_COLS, GRID_ROWS):
self.running = False; return
if self.snake.head() == self.food.pos:
self.score += self.food.value
self.snake.grow()
self.food.respawn(GRID_COLS, GRID_ROWS, self.snake.body)
def draw(self):
self.screen.fill(BG)
self.snake.draw(self.screen)
self.food.draw(self.screen)
self.screen.blit(
self.font.render(f"Score: {self.score}", True, WHITE),
(8, 6))
pygame.display.flip()
def run(self):
while self.running:
self.handle_events()
self.update()
self.draw()
self.clock.tick(SNAKE_SPEED)
pygame.quit()
# Entry point
if __name__ == "__main__":
Game().run()
Game.run() contains the exact same game loop as always: handle events → update → draw. But now it's a method of Game, which means the game owns its own loop. You could create two games: game1 = Game(); game2 = Game() — each with its own state. This is what game engines like Unity do: every Scene is its own "Game" object.
selfself is how an instance refers to its own attributes. It's the first parameter of every method but you never pass it yourself — Python passes it automatically.
| Write this | Python sees this |
|---|---|
snake.move(GRID_COLS, GRID_ROWS) | Snake.move(snake, GRID_COLS, GRID_ROWS) |
self.body (inside a method) | This instance's body attribute |
self.grow() (inside a method) | Call this instance's grow method |
Add self.speed = SNAKE_SPEED to Game.__init__. Every time the snake eats food, increase self.speed by 0.5. Use self.clock.tick(self.speed). The game gets harder as you score!
Create a Scoreboard class with load(), save(score), and is_record(score) methods. Use it inside Game.run() after the loop ends.
Create class BonusFood(Food): that inherits from Food. Override __init__ to set self.value = 3 and add self.timer = 180. Add a tick() method that counts down and returns False when expired.
These are the errors beginners make most often in Step 9. Read them now so you can recognise them in your own code.
self as the first parameterclass Snake:
def move(direction): # missing self!
...
my_snake.move(direction) passes my_snake as the first argument, but the function only has one parameter (direction). Python raises TypeError: move() takes 1 positional argument but 2 were given.class Snake:
def move(self, direction): # self = the object being called
...
my_snake.move # just references the method — doesn't call it!
(), Python evaluates the expression as the method object itself — no code runs. The snake never moves. No error is raised either, so this bug is hard to spot.my_snake.move(direction) # parentheses = call it
Snake.move(direction) # calling on the class, not an object!
Snake is a blueprint; my_snake = Snake() creates a real object. Methods must be called on the object — only the object has actual data (self.body, etc.).my_snake = Snake() # create an instance my_snake.move(direction) # call method on the instance
Three questions — not graded. They help you spot gaps before the activities.
self refer to inside a class method?my_snake = Snake()?Snake (capital S) and my_snake?You've covered all the concepts for Step 9. Time to apply them.
Start Activities → Tier 1 → 2 → 3 → 4